chore: restructure the project, make it not use the astal application stuff

now it's more organized and I have more control over the shell behaviour
This commit is contained in:
retrozinndev
2025-08-06 15:25:21 -03:00
parent 5a6d5b47c6
commit d549ad9596
191 changed files with 529 additions and 1000 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
@girs/
build/
pnpm-lock.yaml
+203
View File
@@ -0,0 +1,203 @@
// fix ags needing --gtk 4
// import app from "ags/gtk4/app";
import {
PluginApps,
PluginClipboard,
PluginMedia,
PluginShell,
PluginWallpapers,
PluginWebSearch
} from "./runner/plugins";
import { Wireplumber } from "./scripts/volume";
import { handleArguments } from "./scripts/arg-handler";
import { Runner } from "./runner/Runner";
import { Windows } from "./windows";
import { Notifications } from "./scripts/notifications";
import { Wallpaper } from "./scripts/wallpaper";
import { Stylesheet } from "./scripts/stylesheet";
import { Clipboard } from "./scripts/clipboard";
import { Config } from "./scripts/config";
import { Gdk, Gtk } from "ags/gtk4";
import { createRoot, getScope } from "ags";
import { triggerOSD } from "./window/OSD";
import { programArgs, programInvocationName } from "system";
import { encoder, decoder } from "./scripts/utils";
import GObject, { register } from "ags/gobject";
import AstalNotifd from "gi://AstalNotifd";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
const runnerPlugins: Array<Runner.Plugin> = [
PluginApps,
PluginShell,
PluginWebSearch,
PluginMedia,
PluginWallpapers,
PluginClipboard
];
const defaultWindows: Array<string> = [];
@register({ GTypeName: "Shell" })
export class Shell extends Gtk.Application {
private static instance: Shell;
#loop!: GLib.MainLoop;
#scope!: ReturnType<typeof getScope>;
#stylesheet: Uint8Array|undefined;
#styleProvider: Gtk.CssProvider;
get scope() { return this.#scope; }
constructor() {
super({
applicationId: "io.github.retrozinndev.colorshell",
flags: Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
version: "1.1.0",
});
this.#styleProvider = Gtk.CssProvider.new();
}
public static getDefault(): Shell {
if(!this.instance)
this.instance = new Shell();
return this.instance;
}
public resetStyle(): void {
this.#stylesheet = undefined;
Gtk.StyleContext.remove_provider_for_display(
Gdk.Display.get_default()!,
this.#styleProvider
);
}
public applyStyle(stylesheet: string): void {
const previous = this.#stylesheet ? decoder.decode(this.#stylesheet) : undefined;
let final = "";
if(previous)
final = previous + "\n";
this.#stylesheet = encoder.encode(stylesheet);
final = final.concat(stylesheet);
this.#styleProvider.load_from_string(final);
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default()!,
this.#styleProvider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
);
}
vfunc_command_line(cmd: Gio.ApplicationCommandLine): number {
const args = cmd.get_arguments();
if(cmd.isRemote) {
cmd.print_literal(handleArguments(args));
cmd.done();
return 0;
}
this.main(args);
return 0;
}
private main(_args: Array<string>): void {
this.#loop = GLib.MainLoop.new(null, false);
const connections = new Map<GObject.Object, Array<number> | number>();
connections.set(this, this.connect("shutdown", () => this.#scope.dispose()));
createRoot(() => {
console.log(`Colorshell: initialized instance as: "colorshell"`);
this.#scope = getScope();
Stylesheet.getDefault().compileApply();
// Init clipboard module
Clipboard.getDefault();
console.log("Initializing wallpaper handler");
Wallpaper.getDefault();
console.log("Adding runner plugins");
runnerPlugins.forEach(plugin => Runner.addPlugin(plugin));
connections.set(Wireplumber.getDefault(),
Wireplumber.getDefault().getDefaultSink().connect("notify::volume", () =>
triggerOSD())
);
connections.set(Notifications.getDefault(), [
Notifications.getDefault().connect("notification-added", (_, _notif: AstalNotifd.Notification) => {
Windows.getDefault().open("floating-notifications");
}),
Notifications.getDefault().connect("notification-removed", (_: Notifications, _id: number) => {
_.notifications.length === 0 && Windows.getDefault().close("floating-notifications");
})
]);
defaultWindows.forEach(w => Windows.getDefault().open(w));
});
this.#scope.onCleanup(() => {
console.log("Colorshell: disposing connections and quitting because of ::shutdown");
connections.forEach((ids, obj) => Array.isArray(ids) ?
ids.forEach(id => obj.disconnect(id))
: obj.disconnect(ids));
});
this.#loop.run();
}
}
const generalConfigDefaults = {
notifications: {
timeout_low: 4000,
timeout_normal: 6000,
timeout_critical: 0
},
night_light: {
/** whether to save night light values to disk */
save_on_shutdown: true
},
workspaces: {
/** breaks `enable_helper`, makes all workspaces show their respective ID
* by default */
always_show_id: false,
/** this is the function that shows the Workspace's IDs
* around the current workspace if one breaks the crescent order.
* It basically helps keyboard navigation between workspaces.
* ---
* Example: 1(empty, current, shows ID), 2(empty, does not appear(makes
* the previous not to be in a crescent order)), 3(not empty, shows ID) */
enable_helper: true
},
clock: {
/** use the same format as gnu's `date` command */
date_format: "%A %d, %H:%M"
},
misc: {
play_bell_on_volume_change: true
}
};
export const generalConfig = new Config<keyof typeof generalConfigDefaults,
typeof generalConfigDefaults[keyof typeof generalConfigDefaults]>(
`${GLib.get_user_config_dir()}/colorshell/config.json`, generalConfigDefaults
);
Shell.getDefault().runAsync([ programInvocationName, ...programArgs ]);
+21
View File
@@ -0,0 +1,21 @@
declare const SRC: string
declare module "inline:*" {
const content: string
export default content
}
declare module "*.scss" {
const content: string
export default content
}
declare module "*.blp" {
const content: string
export default content
}
declare module "*.css" {
const content: string
export default content
}
+57
View File
@@ -0,0 +1,57 @@
import GLib from "gi://GLib?version=2.0";
const i18nKeys = {
"en_US": (await import("./lang/en_US")).default,
"pt_BR": (await import("./lang/pt_BR")).default,
"ru_RU": (await import("./lang/ru_RU")).default
};
const languages: Array<string> = Object.keys(i18nKeys);
let language: string = getSystemLanguage();
export function getSystemLanguage(): string {
const sysLanguage: (string|null|undefined) = GLib.getenv("LANG") || GLib.getenv("LANGUAGE");
if(!sysLanguage) {
console.warn(`Intl: Couldn't get system language, fallback to default ${languages[0]}`);
console.log("Intl: Please set the LANG or LANGUAGE environment variable");
return languages[0];
}
return sysLanguage.split('.')[0];
}
export function setLanguage(lang: string): string {
languages.map((cur: string) => {
if(cur === lang) {
language = lang;
return lang;
}
});
throw new Error(`Intl: couldn't set language: ${lang}`, {
cause: `language ${lang} not found in languages of type ${typeof languages}`
});
}
export function tr(key: string): string {
let result = i18nKeys[language as keyof typeof i18nKeys],
defResult = i18nKeys[languages[0] as keyof typeof i18nKeys];
for(const keyString of key.split('.')) {
result = result[keyString as keyof typeof result] as never;
defResult = defResult[keyString as keyof typeof defResult] as never;
}
return (typeof result == "string") ?
result
: ((typeof defResult == "string") ?
defResult
: "not found / is not of type \"string\"");
}
export function trGet() {
return i18nKeys[getSystemLanguage() as keyof typeof i18nKeys];
}
+86
View File
@@ -0,0 +1,86 @@
import { i18nStruct } from "../struct";
export default {
language: "English (United States)",
cancel: "Cancel",
accept: "Ok",
devices: "Devices",
others: "Others",
connected: "Connected",
disconnected: "Disconnected",
unknown: "Unknown",
connecting: "Connecting",
none: "None",
limited: "Limited",
apps: "Applications",
clear: "Clear",
connect: "Connect",
disconnect: "Disconnect",
control_center: {
tiles: {
enabled: "Enabled",
disabled: "Disabled",
more: "More",
network: {
network: "Network",
wireless: "Wireless",
wired: "Wired"
},
recording: {
title: "Screen Recording",
disabled_desc: "Start recording",
enabled_desc: "Stop recording",
},
dnd: {
title: "Do Not Disturb"
},
night_light: {
title: "Night Light",
default_desc: "Fidelity"
}
},
pages: {
more_settings: "More settings",
sound: {
title: "Sound",
description: "Configure the audio output"
},
microphone: {
title: "Microphone",
description: "Configure the audio input"
},
night_light: {
title: "Night Light",
description: "Control Night Light and Gamma filters",
gamma: "Gamma",
temperature: "Temperature"
},
bluetooth: {
title: "Bluetooth",
description: "Manage Bluetooth devices",
new_devices: "New devices",
adapters: "Adapters",
paired_devices: "Paired Devices",
start_discovering: "Start discovering",
stop_discovering: "Stop discovering",
untrust_device: "Untrust device",
unpair_device: "Unpair device",
trust_device: "Trust device",
pair_device: "Pair device"
},
network: {
title: "Network",
interface: "Interface"
}
}
},
ask_popup: {
title: "Question"
}
} as i18nStruct;
+86
View File
@@ -0,0 +1,86 @@
import { i18nStruct } from "../struct";
export default {
language: "Português (Brasil)",
cancel: "Cancelar",
accept: "Ok",
devices: "Dispositivos",
others: "Outros",
connected: "Conectado",
disconnected: "Desconectado",
unknown: "Desconhecido",
connecting: "Conectando",
limited: "Limitado",
none: "Nenhum",
disconnect: "Desconectar",
connect: "Conectar",
apps: "Aplicativos",
clear: "Limpar",
control_center: {
tiles: {
enabled: "Ligado",
disabled: "Desligado",
more: "Mais",
network: {
network: "Rede",
wireless: "Wi-Fi",
wired: "Cabeada"
},
recording: {
title: "Gravação de Tela",
disabled_desc: "Iniciar gravação",
enabled_desc: "Parar gravação",
},
dnd: {
title: "Não Perturbe"
},
night_light: {
title: "Luz Noturna",
default_desc: "Fidelidade"
}
},
pages: {
more_settings: "Mais configurações",
sound: {
title: "Som",
description: "Controle a saída de áudio"
},
microphone: {
title: "Microfone",
description: "Configure a entrada de áudio"
},
night_light: {
title: "Luz Noturna",
description: "Controle os filtros de Luz Noturna e Gama",
temperature: "Temperatura",
gamma: "Gama"
},
bluetooth: {
title: "Bluetooth",
description: "Gerencie dispositivos Bluetooth",
new_devices: "Novos Dispositivos",
adapters: "Adaptadores",
paired_devices: "Dispositivos Pareados",
start_discovering: "Começar a procurar dispositivos",
stop_discovering: "Parar de procurar dispositivos",
pair_device: "Parear dispositivo",
trust_device: "Confiar no dispositivo",
unpair_device: "Desparear dispositivo",
untrust_device: "Deixar de confiar no dispositivo"
},
network: {
title: "Rede",
interface: "Interface"
}
}
},
ask_popup: {
title: "Pergunta"
}
} as i18nStruct;
+86
View File
@@ -0,0 +1,86 @@
import { i18nStruct } from "../struct";
export default {
language: "Русский (Российская Федерация)",
cancel: "Отменить",
accept: "Ок",
devices: "Устройства",
others: "Другие",
connected: "Подключён",
disconnected: "Отключён",
unknown: "Неизвестный",
connecting: "Подключение",
none: "Ничего",
limited: "Ограничен",
apps: "Приложения",
clear: "Очистить",
connect: "Подключиться",
disconnect: "Отключиться",
control_center: {
tiles: {
enabled: "Включить",
disabled: "Отключить",
more: "Больше",
network: {
network: "Инетрнет",
wireless: "Беспроводное",
wired: "Проводное"
},
recording: {
title: "Запись экрана",
disabled_desc: "Начать запись",
enabled_desc: "Остановить запись",
},
dnd: {
title: "Не беспокоить"
},
night_light: {
title: "Ночной свет",
default_desc: "Тонн"
}
},
pages: {
more_settings: "Больше настроек",
sound: {
title: "Звук",
description: "Настройка вывода звука"
},
microphone: {
title: "Микрофон",
description: "Настройка ввода звука"
},
night_light: {
title: "Ночной свет",
description: "Контроль интенсивности фильтрации синего света",
gamma: "Гамма",
temperature: "Температура"
},
bluetooth: {
title: "Bluetooth",
description: "Управление Bluetooth устройствами",
new_devices: "Новые устройства",
adapters: "Адапреты",
paired_devices: "Привязанные устройства",
start_discovering: "Начать поиск",
stop_discovering: "Остановить поиск",
untrust_device: "Недоверенное устройство",
unpair_device: "Отвязанное устройство",
trust_device: "Доверенное устройство",
pair_device: "Привязанное устройство"
},
network: {
title: "Интернет",
interface: "Интерфейсы"
}
}
},
ask_popup: {
title: "Вопрос"
}
} as i18nStruct;
+86
View File
@@ -0,0 +1,86 @@
export type i18nStruct = {
language: string,
cancel: string,
accept: string,
connected: string,
disconnected: string,
connecting: string,
unknown: string,
none: string,
limited: string,
devices: string,
others: string,
disconnect: string,
connect: string,
apps: string;
clear: string;
control_center: {
tiles: {
enabled: string,
disabled: string,
more: string,
network: {
network: string,
wireless: string,
wired: string
},
recording: {
title: string,
disabled_desc: string,
enabled_desc: string
},
dnd: {
title: string
},
night_light: {
title: string,
default_desc: string
}
},
pages: {
more_settings: string,
sound: {
title: string,
description: string
},
microphone: {
title: string,
description: string
},
network: {
title: string,
interface: string
},
bluetooth: {
title: string,
description: string,
adapters: string,
new_devices: string,
paired_devices: string,
start_discovering: string,
stop_discovering: string,
trust_device: string,
untrust_device: string,
pair_device: string,
unpair_device: string
},
night_light: {
title: string,
description: string,
temperature: string,
gamma: string
}
}
},
ask_popup: {
title: string
}
};
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="16"
viewBox="0 0 16 16"
width="16"
version="1.1"
id="svg4"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<path
d="M 2.133333,0 C 0.970833,0 0,0.9708331 0,2.1333333 v 3.2 c 0,1.1625003 0.970833,2.1333334 2.133333,2.1333334 h 3.2 c 1.162501,0 2.133334,-0.9708331 2.133334,-2.1333334 v -3.2 C 7.466667,0.9708331 6.495834,0 5.333333,0 Z m 8.533334,0 C 9.504166,0 8.533333,0.9708331 8.533333,2.1333333 v 3.2 c 0,1.1625003 0.970833,2.1333334 2.133334,2.1333334 h 3.2 C 15.029167,7.4666667 16,6.4958336 16,5.3333333 v -3.2 C 16,0.9708331 15.029167,0 13.866667,0 Z M 2.133333,2.1333333 h 3.2 v 3.2 h -3.2 z m 8.533334,0 h 3.2 v 3.2 h -3.2 z m -8.533334,6.4 C 0.970833,8.5333333 0,9.5041664 0,10.666667 v 3.2 C 0,15.029167 0.970833,16 2.133333,16 h 3.2 c 1.162501,0 2.133334,-0.970833 2.133334,-2.133333 v -3.2 c 0,-1.1625006 -0.970833,-2.1333337 -2.133334,-2.1333337 z m 8.533334,0 c -1.162501,0 -2.133334,0.9708331 -2.133334,2.1333337 v 3.2 C 8.533333,15.029167 9.504166,16 10.666667,16 h 3.2 C 15.029167,16 16,15.029167 16,13.866667 v -3.2 C 16,9.5041664 15.029167,8.5333333 13.866667,8.5333333 Z M 2.133333,10.666667 h 3.2 v 3.2 h -3.2 z m 8.533334,0 h 3.2 v 3.2 h -3.2 z m 0,0"
fill="#2e3436"
id="path1"
style="fill:#000000;stroke-width:1.06667" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 7.40625 1 c -0.613281 0.007812 -1.234375 0.089844 -1.847656 0.253906 c -3.273438 0.878906 -5.558594 3.855469 -5.558594 7.246094 s 2.285156 6.367188 5.558594 7.242188 c 3.273437 0.878906 6.742187 -0.558594 8.4375 -3.492188 c 0.273437 -0.480469 0.109375 -1.089844 -0.367188 -1.367188 c -0.476562 -0.273437 -1.089844 -0.109374 -1.367187 0.367188 c -1.246094 2.160156 -3.777344 3.207031 -6.1875 2.5625 c -2.40625 -0.644531 -4.074219 -2.820312 -4.074219 -5.3125 c 0 -2.496094 1.667969 -4.667969 4.074219 -5.3125 c 2.410156 -0.644531 4.941406 0.402344 6.1875 2.5625 c 0.058593 0.085938 0.125 0.164062 0.203125 0.226562 l -0.019532 0.015626 l -0.007812 0.007812 h -1.4375 c -0.550781 0 -1 0.449219 -1 1 c 0 0 0 1 1 1 h 5 v -5 s 0.003906 -1 -1 -1 c -0.550781 0 -1 0.449219 -1 1 v 1.6875 l -0.015625 0.011719 l -0.011719 0.011719 c -1.277344 -2.179688 -3.53125 -3.519532 -5.953125 -3.691407 c -0.203125 -0.015625 -0.40625 -0.019531 -0.613281 -0.019531 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 8 1 c -3.855469 0 -7 3.144531 -7 7 s 3.144531 7 7 7 s 7 -3.144531 7 -7 s -3.144531 -7 -7 -7 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 5.019531 -0.00390625 c -0.96875 0 -2 1.05078125 -2 2.00000025 v 2.988281 c 0 0.429687 0.222657 0.675781 0.554688 1.007813 l 2.023437 2.003906 l -2.007812 1.992187 c -0.367188 0.363281 -0.570313 0.6875 -0.570313 1 v 3.007813 c 0 1.011718 0.988281 2 2 2 h 6 c 1.007813 0 2 -1.011719 2 -2.003906 v -3.003907 c 0 -0.3125 -0.222656 -0.628906 -0.570312 -0.976562 l -2.015625 -2.015625 l 1.988281 -1.988282 c 0.261719 -0.261718 0.585937 -0.6875 0.597656 -1.015624 v -2.996094 c 0 -1.003906 -1.007812 -2.00000025 -2 -2.00000025 z m 6 5.00000025 h -6 v -3 h 6 m -3.589843 7 h 1.175781 l 2.414062 2.414062 v 1.585938 l -3 -2 l -3 2 v -1.613282 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 803 B

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 4 3 c -2.199219 0 -4 1.800781 -4 4 v 2 c 0 2.199219 1.800781 4 4 4 c 0.550781 0 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 c -1.125 0 -2 -0.875 -2 -2 v -2 c 0 -1.125 0.875 -2 2 -2 h 8 c 1.125 0 2 0.875 2 2 v 2 c 0 1.125 -0.875 2 -2 2 h -4 c -0.550781 0 -1 0.449219 -1 1 s 0.449219 1 1 1 h 4 c 2.199219 0 4 -1.800781 4 -4 v -2 c 0 -2.199219 -1.800781 -4 -4 -4 z m 0 0"/><path d="m 10 10.996094 v -2.003906 h -1 v 0.007812 c -0.265625 0 -0.519531 0.105469 -0.707031 0.289062 l -2 2 c -0.390625 0.390626 -0.390625 1.023438 0 1.414063 l 2 2 c 0.1875 0.183594 0.441406 0.289063 0.707031 0.285156 v 0.011719 h 1 v -1.992188"/></g></svg>

After

Width:  |  Height:  |  Size: 781 B

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 8 1.03125 c -3.871094 0 -7 3.128906 -7 7 s 3.128906 7 7 7 s 7 -3.128906 7 -7 s -3.128906 -7 -7 -7 z m -4 6 h 8 v 2 h -8 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 0 2.316406 v 5.507813 c 0 2.214843 1.1875 4.257812 3.109375 5.355469 l 4.890625 2.796874 l 4.890625 -2.796874 c 1.921875 -1.097657 3.109375 -3.140626 3.109375 -5.355469 v -5.507813 l -8 -2.285156 z m 14.726562 1.71875 l -0.726562 -0.964844 v 4.753907 c 0 1.496093 -0.800781 2.875 -2.101562 3.617187 l -4.394532 2.511719 h 0.992188 l -4.394532 -2.511719 c -1.300781 -0.742187 -2.101562 -2.121094 -2.101562 -3.617187 v -4.753907 l -0.726562 0.964844 l 7 -2 h -0.546876 z m 0 0"/><path d="m 5.941406 6.957031 l 3.058594 3.058594 c 0.292969 0.292969 0.765625 0.292969 1.058594 0 c 0.292968 -0.292969 0.292968 -0.765625 0 -1.058594 l -3.058594 -3.058593 c -0.292969 -0.292969 -0.765625 -0.292969 -1.058594 0 c -0.292968 0.292968 -0.292968 0.765624 0 1.058593 z m 0 0"/><path d="m 9 5.898438 l -3.058594 3.058593 c -0.292968 0.292969 -0.292968 0.765625 0 1.058594 c 0.292969 0.292969 0.765625 0.292969 1.058594 0 l 3.058594 -3.058594 c 0.292968 -0.292969 0.292968 -0.765625 0 -1.058593 c -0.292969 -0.292969 -0.765625 -0.292969 -1.058594 0 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 0 2.316406 v 5.507813 c 0 2.214843 1.1875 4.257812 3.109375 5.355469 l 4.890625 2.796874 l 4.890625 -2.796874 c 1.921875 -1.097657 3.109375 -3.140626 3.109375 -5.355469 v -5.507813 l -8 -2.285156 z m 14.726562 1.71875 l -0.726562 -0.964844 v 4.753907 c 0 1.496093 -0.800781 2.875 -2.101562 3.617187 l -4.394532 2.511719 h 0.992188 l -4.394532 -2.511719 c -1.300781 -0.742187 -2.101562 -2.121094 -2.101562 -3.617187 v -4.753907 l -0.726562 0.964844 l 7 -2 h -0.546876 z m 0 0"/><path d="m 5.46875 7.78125 l 2 2 c 0.292969 0.292969 0.769531 0.292969 1.0625 0 l 3 -3 c 0.292969 -0.292969 0.292969 -0.769531 0 -1.0625 s -0.769531 -0.292969 -1.0625 0 l -3 3 h 1.0625 l -2 -2 c -0.292969 -0.292969 -0.769531 -0.292969 -1.0625 0 s -0.292969 0.769531 0 1.0625 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 928 B

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 1 3 h 14 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 h -14 c -0.550781 0 -1 -0.449219 -1 -1 s 0.449219 -1 1 -1 z m 0 0"/>
<path d="m 4 4 v -1.5 c 0 -1.386719 1.113281 -2.5 2.5 -2.5 h 2.980469 c 1.382812 0 2.5 1.113281 2.5 2.5 v 1.5 h -2 v -1.5 c 0 -0.269531 -0.230469 -0.5 -0.5 -0.5 h -2.980469 c -0.269531 0 -0.5 0.230469 -0.5 0.5 v 1.5 z m 0 0"/>
<path d="m 4 4 v 9 c 0 0.546875 0.453125 1 1 1 h 6 c 0.546875 0 1 -0.453125 1 -1 v -9 h 2 v 9 c 0 1.660156 -1.339844 3 -3 3 h -6 c -1.660156 0 -3 -1.339844 -3 -3 v -9 z m 0 0"/>
<path d="m 7 7 v 5 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -5 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 s 0.5 0.222656 0.5 0.5 z m 0 0"/>
<path d="m 10 7 v 5 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -5 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 s 0.5 0.222656 0.5 0.5 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://www.schemastore.org/package.json",
"name": "colorshell",
"packageManager": "pnpm@10.12.1",
"scripts": {
"start": "ags run",
"restart": "ags request reload",
"stop": "ags quit",
"bundle": "ags bundle"
},
"dependencies": {
"ags": "link:../../.local/share/pnpm/global/5/node_modules/ags"
}
}
+2
View File
@@ -0,0 +1,2 @@
overrides:
ags: link:../../../../usr/share/ags/js
+335
View File
@@ -0,0 +1,335 @@
import { Astal, Gdk, Gtk } from "ags/gtk4";
import { getPopupWindowContainer, PopupWindow } from "../widget/PopupWindow";
import { updateApps } from "../scripts/apps";
import { ResultWidget, ResultWidgetProps } from "./widgets/ResultWidget";
import { Windows } from "../windows";
import { timeout } from "ags/time";
import AstalHyprland from "gi://AstalHyprland";
import AstalIO from "gi://AstalIO";
export namespace Runner {
export type RunnerProps = {
halign?: Gtk.Align;
valign?: Gtk.Align;
width?: number;
height?: number;
entryPlaceHolder?: string;
initialText?: string;
resultsLimit?: number;
showResultsPlaceHolderOnStartup?: boolean;
};
type Result = ResultWidgetProps;
export interface Plugin {
/** prefix to call the plugin. if undefined, will be triggered like applications plugin */
readonly prefix?: string;
/** name of the plugin. e.g.: websearch, shell */
readonly name?: string;
/** runs when runner opens */
readonly init?: () => void;
/** handle the user input to return results (does not include plugin's prefix) */
readonly handle: (inputText: string) => (Result|Array<Result>|null|undefined);
/** runs when runner closes */
readonly onClose?: () => void;
/** prioritize this plugin's results over other results.
* (hides other results that aren't from this plugin on list) */
prioritize?: boolean;
}
export let instance: (Astal.Window|null) = null;
let gtkEntry: (Gtk.SearchEntry|null) = null;
const plugins = new Set<Runner.Plugin>();
const ignoredKeys = [
Gdk.KEY_space,
Gdk.KEY_Shift_L,
Gdk.KEY_Shift_R,
Gdk.KEY_Shift_Lock,
Gdk.KEY_Return,
Gdk.KEY_Tab,
Gdk.KEY_Control_L,
Gdk.KEY_Control_R,
Gdk.KEY_Alt_L,
Gdk.KEY_Alt_R,
Gdk.KEY_Option,
Gdk.KEY_Super_L,
Gdk.KEY_Super_R,,
Gdk.KEY_F5,
Gdk.KEY_Up,
Gdk.KEY_Down,
Gdk.KEY_Left,
Gdk.KEY_Right
];
export function close() { instance?.close(); }
export function regExMatch(search: string, item: (string|number)): boolean {
search = search.replace(/[\\^$.*?()[\]{}|]/g, "\\$&");
if(typeof item === "number")
return new RegExp(`${search.split('').map(c =>
`[${c}]`).join('')}`,
"g").test(item.toString());
return new RegExp(`${search.split('').map(c =>
`${c}`).join('')}`,
"gi").test(item);
}
export function addPlugin(plugin: Runner.Plugin, force?: boolean) {
if(!force && plugin.prefix && plugins.has(plugin))
throw new Error(`Runner plugin with prefix ${plugin.prefix} already exists`);
plugins.delete(plugin);
plugins.add(plugin);
}
export function getPlugins(): Array<Runner.Plugin> {
return [...plugins.values()];
}
/** Removes a plugin from the runner plugins list
* @returns true if plugin was removed or false if plugin wasn't found
*/
export function removePlugin(plugin: Plugin): boolean {
return plugins.delete(plugin);
}
export function setEntryText(text: string): void {
gtkEntry?.set_text(text);
gtkEntry?.set_position(gtkEntry.text.length);
gtkEntry?.grab_focus();
}
export function openDefault(initialText?: string) {
return Runner.openRunner({
entryPlaceHolder: "Start typing...",
initialText,
showResultsPlaceHolderOnStartup: false,
resultsLimit: 24
} as Runner.RunnerProps, [
{
icon: "application-x-executable-symbolic",
title: "Use your applications",
description: "Search for any app installed in your computer",
closeOnClick: false,
actionClick: () => gtkEntry?.grab_focus()
},
{
icon: "edit-paste-symbolic",
title: "See your clipboard history",
description: "Start your search with '>' to go through your clipboard history",
closeOnClick: false,
actionClick: () => setEntryText('>')
},
{
icon: "image-x-generic-symbolic",
title: "Change your wallpaper",
description: "Add '#' at the start to search through the wallpapers folder!",
closeOnClick: false,
actionClick: () => setEntryText('#'),
},
{
icon: "utilities-terminal-symbolic",
title: "Run shell commands",
description: "Add '!' before your command to run it (pro tip: add a second '!' to show command output)",
closeOnClick: false,
actionClick: () => setEntryText('!!')
},
{
icon: "media-playback-start-symbolic",
title: "Control media",
description: "Type ':' to control playing media",
closeOnClick: false,
actionClick: () => setEntryText(':')
},
{
icon: "applications-internet-symbolic",
title: "Search the Web",
description: "Start typing with '?' prefix to search the web",
closeOnClick: false,
actionClick: () => setEntryText('?')
}
]);
}
function getPluginResults(input: string, limit?: number): Array<Result> {
let calledPlugins: Array<Plugin> = getPlugins().filter((plugin) =>
plugin.prefix ? (input.startsWith(plugin.prefix) ? true : false) : true
).sort((plugin) => plugin.prefix != null ? 0 : 1);
for(const plugin of calledPlugins) {
if(plugin.prioritize) {
calledPlugins = [ plugin ];
break;
}
}
const results = calledPlugins.map(plugin => plugin.handle(
plugin.prefix ? input.replace(plugin.prefix, "") : input)
).filter(value => value !== undefined && value !== null).flat(1);
return limit != null && limit > 0 ?
results.splice(0, limit)
: results;
}
function updateResultsList(listbox: Gtk.ListBox, input: string, placeholders?: Array<Result>) {
const newResults: Array<Result> = [],
scrolledWindow = listbox.parent.parent as Gtk.ScrolledWindow;
listbox.remove_all();
getPluginResults(input).forEach((result) => {
listbox.insert(<ResultWidget {...result} /> as ResultWidget, -1);
newResults.push(result);
});
// Insert placeholder if there are no results
if(placeholders && newResults.length < 1)
placeholders.forEach(phdlr => listbox.insert(
<ResultWidget {...phdlr} /> as ResultWidget, -1
));
newResults.length > 0 ?
(!scrolledWindow.visible && scrolledWindow.show())
: scrolledWindow.hide();
}
function selectPreviousItem(listbox: Gtk.ListBox) {
const selectedRow = listbox.get_selected_row();
const prevRow = selectedRow?.get_prev_sibling();
if(!prevRow || selectedRow === listbox.get_first_child())
return;
const viewport = listbox.parent as Gtk.Viewport;
const vadjustment = (viewport.parent as Gtk.ScrolledWindow).get_vadjustment();
const [, , prevRowY] = prevRow.translate_coordinates(viewport,
prevRow.get_allocation().x, prevRow.get_allocation().y);
listbox.select_row(prevRow as Gtk.ListBoxRow);
if(prevRowY < vadjustment.get_value())
vadjustment.set_value(prevRowY);
}
function selectNextItem(listbox: Gtk.ListBox) {
const selectedRow = listbox.get_selected_row();
const nextRow = selectedRow?.get_next_sibling();
if(!nextRow || selectedRow === listbox.get_last_child())
return;
const viewport = listbox.parent as Gtk.Viewport;
const vadjustment = viewport.vadjustment;
const [, , nextRowY] = nextRow.translate_coordinates(viewport,
nextRow.get_allocation().x, nextRow.get_allocation().y);
listbox.select_row(nextRow as Gtk.ListBoxRow);
if(vadjustment.value < nextRowY)
vadjustment.set_value(nextRowY - vadjustment.value);
}
export function openRunner(props: RunnerProps, placeholders?: Array<Result>): Astal.Window {
props.width ??= 780;
props.height ??= 420;
let clickTimeout: AstalIO.Time|undefined;
if(!instance)
instance = Windows.getDefault().createWindowForFocusedMonitor((mon, root) =>
<PopupWindow namespace={"runner"} monitor={mon} widthRequest={props.width}
heightRequest={props.height} exclusivity={Astal.Exclusivity.IGNORE} halign={Gtk.Align.CENTER}
marginTop={(AstalHyprland.get_default().get_monitor(mon)?.height / 2) - (props.height! / 2)}
valign={Gtk.Align.START} $={() => {
plugins.forEach(plugin =>
plugin.init?.());
props.initialText &&
Runner.setEntryText(props.initialText);
}} actionKeyPressed={(self, keyval) => {
const listbox = ((getPopupWindowContainer(self).get_first_child()!
.get_last_child()! as Gtk.ScrolledWindow).get_child()! as Gtk.Viewport)
.get_child()! as Gtk.ListBox;
switch(keyval) {
case Gdk.KEY_F5:
updateApps();
return;
case Gdk.KEY_Left:
case Gdk.KEY_Up:
selectPreviousItem(listbox);
gtkEntry?.grab_focus();
return;
case Gdk.KEY_Right:
case Gdk.KEY_Down:
selectNextItem(listbox);
gtkEntry?.grab_focus();
return;
}
for(const key of ignoredKeys) {
if(keyval === key)
return;
}
!gtkEntry?.hasFocus &&
gtkEntry?.grab_focus();
}} actionClosed={() => {
[...plugins.values()].forEach(plugin => plugin?.onClose?.());
root.dispose();
instance = null;
gtkEntry = null;
}}>
<Gtk.Box class={`runner main`} orientation={Gtk.Orientation.VERTICAL} hexpand
valign={Gtk.Align.START}>
<Gtk.SearchEntry class={"search"} placeholderText={props.entryPlaceHolder ?? ""}
$={(self) => gtkEntry = self} searchDelay={0} onSearchChanged={(self) => {
const listbox = self.get_next_sibling()?.get_first_child()?.get_first_child() as Gtk.ListBox;
updateResultsList(listbox, self.text, placeholders);
listbox.get_row_at_index(0) &&
listbox.select_row(listbox.get_row_at_index(0));
}} onActivate={(self) => {
const listbox = self.parent.get_last_child()?.get_first_child()?.get_first_child() as Gtk.ListBox;
const resultWidget = listbox.get_selected_row()?.get_child();
if(resultWidget instanceof ResultWidget) {
resultWidget.actionClick();
resultWidget.closeOnClick &&
Runner.close();
}
}} onStopSearch={() => Runner.close()} // close Runner on Escape
/>
<Gtk.ScrolledWindow class={"results-scrollable"} vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
hscrollbarPolicy={Gtk.PolicyType.NEVER} hexpand vexpand propagateNaturalHeight visible={false}
maxContentHeight={props.height}>
<Gtk.ListBox hexpand activateOnSingleClick selectionMode={Gtk.SelectionMode.SINGLE}
onRowActivated={(_, row) => {
const child = row.get_child()!;
if(child instanceof ResultWidget && !clickTimeout) {
clickTimeout = timeout(250, () => clickTimeout = undefined);
child.actionClick?.();
child.closeOnClick &&
Runner.close();
}
}}
/>
</Gtk.ScrolledWindow>
</Gtk.Box>
</PopupWindow> as Astal.Window
)();
return instance!;
}
}
+18
View File
@@ -0,0 +1,18 @@
import { execApp, getAstalApps, lookupIcon, updateApps } from "../../scripts/apps";
import { Runner } from "../Runner";
export const PluginApps = {
// Do not provide prefix, so it always runs.
name: "Apps",
// asynchronously-refresh apps list on init
init: async () => updateApps(),
handle: (text: string) => {
return getAstalApps().fuzzy_query(text).map(app => ({
title: app.get_name(),
description: app.get_description(),
icon: lookupIcon(app.iconName) ? app.iconName : "application-x-executable-symbolic",
actionClick: () => execApp(app)
})
);
}
} as Runner.Plugin;
+33
View File
@@ -0,0 +1,33 @@
import { Gtk } from "ags/gtk4";
import { Clipboard } from "../../scripts/clipboard";
import { Runner } from "../Runner";
import { jsx } from "ags/gtk4/jsx-runtime";
export const PluginClipboard = {
prefix: '>',
prioritize: true,
handle: (search) => {
if(Clipboard.getDefault().history.length < 1)
return {
icon: "edit-paste-symbolic",
title: "Clipboard is empty",
description: "Copy something and it will be shown right here!"
};
return Clipboard.getDefault().history.filter(item =>
// not the best way to search, but it works
Runner.regExMatch(search, item.id) || Runner.regExMatch(search, item.preview)).map((item) => ({
icon: jsx(Gtk.Label, {
label: `${item.id}`,
css: "font-size: 16px; margin-right: 8px; font-weight: 600;"
}),
title: item.preview,
actionClick: () => Clipboard.getDefault().selectItem(item).catch((err: Error) => {
console.error(`Runner(Plugin/Clipboard): An error occurred while selecting clipboard item. Stderr:\n${
err.message ? `${err.message}\n` : ""}Stack: ${err.stack}`
);
})
}));
}
} as Runner.Plugin;
+16
View File
@@ -0,0 +1,16 @@
import { PluginApps } from "./apps"
import { PluginClipboard } from "./clipboard"
import { PluginMedia } from "./media"
import { PluginShell } from "./shell"
import { PluginWallpapers } from "./wallpapers"
import { PluginWebSearch } from "./websearch"
export {
PluginApps,
PluginWebSearch,
PluginClipboard,
PluginShell,
PluginMedia,
PluginWallpapers
};
+53
View File
@@ -0,0 +1,53 @@
import { createBinding, createComputed } from "ags";
import { Runner } from "../Runner";
import { player } from "../../widget/bar/Media";
import AstalMpris from "gi://AstalMpris";
export const PluginMedia = {
prefix: ":",
handle: () => !player.get().available ? {
icon: "folder-music-symbolic",
title: "Couldn't find any players",
closeOnClick: false,
description: "No media / player found with mpris"
} : [
{
icon: createBinding(player.get(), "playbackStatus").as((status) => status === AstalMpris.PlaybackStatus.PLAYING ?
"media-playback-pause-symbolic"
: "media-playback-start-symbolic"),
closeOnClick: false,
title: createComputed([
createBinding(player.get(), "title"),
createBinding(player.get(), "artist"),
createBinding(player.get(), "playbackStatus")
], (title, artist, status) => `${ status === AstalMpris.PlaybackStatus.PLAYING ?
"Pause" : "Play"
} ${title} | ${artist}`),
actionClick: () => player.get().play_pause()
},
{
icon: "media-skip-backward-symbolic",
closeOnClick: false,
title: createComputed([
createBinding(player.get(), "title"),
createBinding(player.get(), "artist")
], (title, artist) =>
`Go Previous ${ title ? title : player.get().busName }${ artist ? ` | ${artist}` : "" }`
),
actionClick: () => player.get().canGoPrevious && player.get().previous()
},
{
icon: "media-skip-forward-symbolic",
closeOnClick: false,
title: createComputed([
createBinding(player.get(), "title"),
createBinding(player.get(), "artist")
], (title, artist) =>
`Go Next ${ title ? title : player.get().busName }${ artist ? ` | ${artist}` : "" }`
),
actionClick: () => player.get().canGoNext && player.get().next()
}
]
} as Runner.Plugin;
+65
View File
@@ -0,0 +1,65 @@
import { Runner } from "../Runner";
import { Notifications } from "../../scripts/notifications";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
export const PluginShell = (() => {
const shell = GLib.getenv("SHELL") ?? "/bin/sh";
const procLauncher = Gio.SubprocessLauncher.new(
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
procLauncher.set_cwd(GLib.get_home_dir());
return {
prefix: '!',
prioritize: true,
handle: (input) => {
let showOutputNotif: boolean = false;
if(input.startsWith('!')) {
input = input.replace('!', "");
showOutputNotif = true;
}
const command = input ? GLib.shell_parse_argv(input) : undefined;
const shellSplit = shell.split('/'),
shellName = shellSplit[shellSplit.length-1];
return {
actionClick: () => {
if(!command?.[0] || !command[1]) return;
const proc = procLauncher.spawnv([ shell, "-c", `${input}` ]);
proc.communicate_utf8_async(null, null, (_, asyncResult) => {
const [ success, stdout, stderr ] = proc.communicate_utf8_finish(asyncResult);
if(!success || stderr) {
Notifications.getDefault().sendNotification({
appName: shell,
summary: "Command error",
body: `An error occurred on \`${input}\`. Stderr: ${stderr}`
});
return;
}
if(!showOutputNotif) return;
Notifications.getDefault().sendNotification({
appName: shell,
summary: "Command output",
body: stdout
});
});
},
title: `Run ${input ? ` \`${input}\`` : `with ${shellName}`}`,
description: input || showOutputNotif ? `${input ? `${shell}\t` : ""}${
showOutputNotif ? "(showing output on notification)" : "" }`
: "",
icon: "utilities-terminal-symbolic"
};
}
} as Runner.Plugin
})();
+44
View File
@@ -0,0 +1,44 @@
import { Wallpaper } from "../../scripts/wallpaper";
import { Runner } from "../Runner";
import Gio from "gi://Gio?version=2.0";
class _PluginWallpapers implements Runner.Plugin {
prefix = "#";
prioritize = true;
#files: (Array<string>|undefined);
init() {
this.#files = [];
const dir = Gio.File.new_for_path(Wallpaper.getDefault().wallpapersPath);
if(dir.query_file_type(null, null) === Gio.FileType.DIRECTORY) {
for(const file of dir.enumerate_children(
"standard::*",
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
null
)) {
this.#files.push(`${dir.get_path()}/${file.get_name()}`);
}
}
}
handle(search: string) {
if(this.#files!.length > 0)
return this.#files!.filter(file =>
// also not the best way to search, but it works
Runner.regExMatch(search, file.split('/')[file.split('/').length-1])
).map(path => ({
title: path.split('/')[path.split('/').length-1].replace(/\..*$/, ""),
actionClick: () => Wallpaper.getDefault().setWallpaper(path)
}));
return {
title: "No wallpapers found!",
description: "Define the $WALLPAPERS variable on Hyprland or create a ~/wallpapers directory",
icon: "image-missing-symbolic"
};
}
}
export const PluginWallpapers = new _PluginWallpapers();
+27
View File
@@ -0,0 +1,27 @@
import AstalHyprland from "gi://AstalHyprland";
import { Runner } from "../Runner";
const searchEngines = {
duckduckgo: "https://duckduckgo.com/?q=",
google: "https://google.com/search?q=",
yahoo: "https://search.yahoo.com/search?p="
};
let engine: string = searchEngines.google;
export const PluginWebSearch = {
prefix: '?',
name: "Web Search",
prioritize: true,
handle: (search) => ({
icon: "system-search-symbolic",
title: search || "Type your search...",
description: `Search the Web`,
actionClick: () => AstalHyprland.get_default().dispatch(
"exec",
`xdg-open \"${engine + search}\"`
)
})
} as Runner.Plugin;
+69
View File
@@ -0,0 +1,69 @@
import { Accessor, With } from "ags";
import { register } from "ags/gobject";
import { Gtk } from "ags/gtk4";
import Pango from "gi://Pango?version=1.0";
import { variableToBoolean } from "../../scripts/utils";
export { ResultWidget, ResultWidgetProps };
type ResultWidgetProps = {
icon?: string | Accessor<string> | JSX.Element | Accessor<JSX.Element>;
title: string | Accessor<string>;
description?: string | Accessor<string>;
closeOnClick?: boolean;
setup?: () => void;
actionClick?: () => void;
visible?: boolean;
};
@register({ GTypeName: "ResultWidget" })
class ResultWidget extends Gtk.Box {
public readonly actionClick: () => void;
public readonly setup?: () => void;
public icon?: (string | Accessor<string> | JSX.Element | Accessor<JSX.Element>);
public closeOnClick: boolean = true;
constructor(props: ResultWidgetProps) {
super();
this.add_css_class("result");
this.visible = props.visible ?? true;
this.hexpand = true;
this.icon = props.icon;
this.setup = props.setup;
this.closeOnClick = props.closeOnClick ?? true;
this.actionClick = () => props.actionClick?.();
if(this.icon !== undefined) {
if(this.icon instanceof Accessor) {
if(typeof this.icon.get() === "string") {
this.prepend(<Gtk.Image iconName={
this.icon as Accessor<string>
} /> as Gtk.Image);
} else {
this.prepend(<Gtk.Box>
<With value={this.icon as Accessor<Gtk.Widget>}>
{(widget) => widget}
</With>
</Gtk.Box> as Gtk.Box);
}
} else {
if(typeof this.icon === "string")
this.prepend(<Gtk.Image iconName={this.icon as string} /> as Gtk.Image);
else
this.prepend(this.icon as Gtk.Widget);
}
}
this.append(<Gtk.Box orientation={Gtk.Orientation.VERTICAL} valign={Gtk.Align.CENTER}>
<Gtk.Label class={"title"} xalign={0} ellipsize={Pango.EllipsizeMode.END}
label={props.title} />
<Gtk.Label class={"description"} visible={variableToBoolean(props.description)}
ellipsize={Pango.EllipsizeMode.END} xalign={0} label={props.description ?? ""} />
</Gtk.Box> as Gtk.Box);
}
}
+104
View File
@@ -0,0 +1,104 @@
import { Gdk, Gtk } from "ags/gtk4";
import { execAsync } from "ags/process";
import AstalApps from "gi://AstalApps";
import AstalHyprland from "gi://AstalHyprland";
export const uwsmIsActive: boolean = await execAsync(
"uwsm check is-active"
).then(() => true).catch(() => false);
const astalApps: AstalApps.Apps = new AstalApps.Apps();
let appsList: Array<AstalApps.Application> = astalApps.get_list();
export function getApps(): Array<AstalApps.Application> {
return appsList;
}
export function updateApps(): void {
astalApps.reload();
appsList = astalApps.get_list();
}
export function getAstalApps(): AstalApps.Apps {
return astalApps;
}
/** handles running with uwsm if it's installed */
export function execApp(app: AstalApps.Application|string, dispatchExecArgs?: string) {
const executable = (typeof app === "string") ? app
: app.executable.replace(/(%f|%F|%u|%U|%i|%c|%k)/g, "");
AstalHyprland.get_default().dispatch("exec",
`${dispatchExecArgs ? `${dispatchExecArgs} ` : ""}${uwsmIsActive ? "uwsm app -- " : ""}${executable}`
);
}
export function lookupIcon(name: string): boolean {
return Gtk.IconTheme.get_for_display(Gdk.Display.get_default()!)?.has_icon(name);
}
export function getAppsByName(appName: string): (Array<AstalApps.Application>|undefined) {
let found: Array<AstalApps.Application> = [];
getApps().map((app: AstalApps.Application) => {
if(app.get_name().trim().toLowerCase() === appName.trim().toLowerCase()
|| (app?.wmClass && app.wmClass.trim().toLowerCase() === appName.trim().toLowerCase()))
found.push(app);
});
return (found.length > 0 ? found : undefined);
}
export function getIconByAppName(appName: string): (string|undefined) {
if(!appName) return undefined;
if(lookupIcon(appName))
return appName;
if(lookupIcon(appName.toLowerCase()))
return appName.toLowerCase();
const nameReverseDNS = appName.split('.');
const lastItem = nameReverseDNS[nameReverseDNS.length - 1];
const lastPretty = `${lastItem.charAt(0).toUpperCase()}${lastItem.substring(1, lastItem.length)}`;
const uppercaseRDNS = nameReverseDNS.slice(0, nameReverseDNS.length - 1)
.concat(lastPretty).join('.');
if(lookupIcon(uppercaseRDNS))
return uppercaseRDNS;
if(lookupIcon(nameReverseDNS[nameReverseDNS.length - 1]))
return nameReverseDNS[nameReverseDNS.length - 1];
const found: (AstalApps.Application|undefined) = getAppsByName(appName)?.[0];
if(Boolean(found))
return found?.iconName;
return undefined;
}
export function getAppIcon(app: (string|AstalApps.Application)): (string|undefined) {
if(!app) return undefined;
if(typeof app === "string")
return getIconByAppName(app);
if(app.iconName && lookupIcon(app.iconName))
return app.iconName;
if(app.wmClass)
return getIconByAppName(app.wmClass);
return getIconByAppName(app.name);
}
export function getSymbolicIcon(app: (string|AstalApps.Application)): (string|undefined) {
const icon = getAppIcon(app);
return (icon && lookupIcon(`${icon}-symbolic`)) ?
`${icon}-symbolic`
: undefined;
}
+291
View File
@@ -0,0 +1,291 @@
import { Wireplumber } from "./volume";
import { Windows } from "../windows";
import { restartInstance } from "./reload-handler";
import { timeout } from "ags/time";
import { Runner } from "../runner/Runner";
import { showWorkspaceNumber } from "../widget/bar/Workspaces";
import { playSystemBell } from "./utils";
import { player, setPlayer } from "../widget/bar/Media";
import { generalConfig } from "../app";
import AstalIO from "gi://AstalIO";
import AstalMpris from "gi://AstalMpris";
let wsTimeout: (AstalIO.Time|undefined);
export function handleArguments(args: Array<string>): any {
switch(args[0]) {
case "help":
case "h":
return getHelp();
case "open":
case "close":
case "toggle":
case "windows":
case "reopen":
return handleWindowArgs(args);
case "volume":
return handleVolumeArgs(args);
case "media":
return handleMediaArgs(args);
case "reload":
restartInstance();
return "Restarting instance...";
case "runner":
!Runner.instance ?
Runner.openDefault(args[1] || undefined)
: Runner.close();
return `Opening runner${args[1] ? ` with predefined text: "${args[1]}"` : ""}`;
case "peek-workspace-num":
if(wsTimeout)
return "Workspace numbers are already showing";
showWorkspaceNumber(true);
wsTimeout = timeout(Number.parseInt(args[1]) || 2200, () => {
showWorkspaceNumber(false);
wsTimeout = undefined;
});
return "Toggled workspace numbers";
default:
return "Error: command not found! try checking help";
}
}
function handleMediaArgs(args: Array<string>): string {
if(/h|help/.test(args[1]))
return `
Manage colorshell's active player
Options:
play: resume/start active player's media.
pause: pause the active player.
play-pause: toggle play/pause on active player.
stop: stop the active player's media.
previous: go back to previous media if player supports it.
next: jump to next media if player supports it.
bus-name: get active player's mpris bus name.
list: show available players with their bus name.
select bus_name: change the active player, where bus_name is
the desired player's mpris bus name(with the mediaplayer2 prefix).
`.trim();
const activePlayer: AstalMpris.Player|undefined = player.get().available ?
player.get()
: undefined;
const players = AstalMpris.get_default().players.filter(pl => pl.available);
if(!activePlayer)
return `Error: no active player found! try playing some media first`
switch(args[1]) {
case "play":
activePlayer.play();
return "Playing";
case "list":
return `Available players:\n${players.map(pl => {
let playbackStatusStr: string;
switch(pl.playbackStatus) {
case AstalMpris.PlaybackStatus.PAUSED:
playbackStatusStr = "paused";
break;
case AstalMpris.PlaybackStatus.PLAYING:
playbackStatusStr = "playing";
break;
default:
playbackStatusStr = "stopped";
break;
}
return ` ${pl.busName}: ${playbackStatusStr}`;
}).join('\n')}`;
case "pause":
activePlayer.pause();
return "Paused";
case "play-pause":
activePlayer.play_pause();
return activePlayer?.playbackStatus === AstalMpris.PlaybackStatus.PAUSED ?
"Toggled play"
: "Toggled pause";
case "stop":
activePlayer.stop();
return "Stopped!";
case "previous":
activePlayer.canGoPrevious && activePlayer.previous();
return activePlayer.canGoPrevious ?
"Back to previous"
: "Player does not support this command";
case "next":
activePlayer.canGoNext && activePlayer.next();
return activePlayer.canGoNext ?
"Jump to next"
: "Player does not support this command";
case "bus-name":
return activePlayer.busName;
case "select":
if(!args[2] || !players.filter(pl => pl.busName == args[2])?.[0])
return `Error: either no player was specified or the player with specified bus name does not exist/is not available!`;
setPlayer(players.filter(pl => pl.busName === args[2])[0]);
return `Done setting player to \`${args[2]}\`!`
}
return "Error: couldn't handle media arguments, try checking `media help`";
}
function handleWindowArgs(args: Array<string>): string {
switch(args[0]) {
case "reopen":
Windows.getDefault().reopen();
return "Reopening all open windows";
case "windows":
return Object.keys(Windows.getDefault().windows).map(name =>
`${name}: ${Windows.getDefault().isOpen(name) ? "open" : "closed" }`).join('\n');
}
const specifiedWindow: string = args[1];
if(!specifiedWindow)
return "Error: window argument not specified!";
if(!Windows.getDefault().hasWindow(specifiedWindow))
return `Error: "${specifiedWindow}" not found on window list! Make sure to add new windows to the system before using them`;
switch(args[0]) {
case "open":
if(!Windows.getDefault().isOpen(specifiedWindow)) {
Windows.getDefault().open(specifiedWindow);
return `Opening window with name "${args[1]}"`;
}
return `Window is already open, ignored`;
case "close":
if(Windows.getDefault().isOpen(specifiedWindow)) {
Windows.getDefault().close(specifiedWindow);
return `Closing window with name "${args[1]}"`;
}
return `Window is already closed, ignored`;
case "toggle":
if(!Windows.getDefault().isOpen(specifiedWindow)) {
Windows.getDefault().open(specifiedWindow);
return `Toggle opening window "${args[1]}"`;
}
Windows.getDefault().close(specifiedWindow);
return `Toggle closing window "${args[1]}"`;
}
return "Couldn't handle window management arguments";
}
function handleVolumeArgs(args: Array<string>) {
if(!args[1])
return `Please specify what you want to do!\n\n${volumeHelp()}`;
if(/^(sink|source)(\-increase|\-decrease|\-set)$/.test(args[1]) && !args[2])
return `You forgot to add a value to be set!`;
if(Number.isNaN(Number.parseFloat(args[2])) && Number.isSafeInteger(Number.parseFloat(args[2])))
return `Argument "${args[2]} is not a valid number! Please use integers"`;
const command: Array<string> = args[1].split('-');
if(args[1] === "help")
return volumeHelp();
switch(command[1]) {
case "set":
command[0] === "sink" ?
Wireplumber.getDefault().setSinkVolume(Number.parseInt(args[2]))
: Wireplumber.getDefault().setSourceVolume(Number.parseInt(args[2]))
return `Done! Set ${command[0]} volume to ${args[2]}`;
case "mute":
command[0] === "sink" ?
Wireplumber.getDefault().toggleMuteSink()
: Wireplumber.getDefault().toggleMuteSource()
return `Done toggling mute!`;
case "increase":
command[0] === "sink" ?
Wireplumber.getDefault().increaseSinkVolume(Number.parseInt(args[2]))
: Wireplumber.getDefault().increaseSourceVolume(Number.parseInt(args[2]))
generalConfig.getProperty("misc.play_bell_on_volume_change", "boolean") === true &&
playSystemBell();
return `Done increasing volume by ${args[2]}`;
case "decrease":
command[0] === "sink" ?
Wireplumber.getDefault().decreaseSinkVolume(Number.parseInt(args[2]))
: Wireplumber.getDefault().decreaseSourceVolume(Number.parseInt(args[2]))
generalConfig.getProperty("misc.play_bell_on_volume_change", "boolean") === true &&
playSystemBell();
return `Done decreasing volume to ${args[2]}`;
}
return `Couldn't resolve arguments! "${args.join(' ').replace(new RegExp(`^${args[0]}`), "")}"`;
function volumeHelp(): string {
return `
Control speaker and microphone volumes
Options:
(sink|source)-set [number]: set speaker/microphone volume.
(sink|source)-mute: toggle mute for the speaker/microphone device.
(sink|source)-increase [number]: increases speaker/microphone volume.
(sink|source)-decrease [number]: decreases speaker/microphone volume.
`.trim();
}
}
function getHelp(): string {
return `Manage Astal Windows and do more stuff. From retrozinndev's colorshell,
made using Astal Libraries, AGS and Gnim by Aylur.
Window Management:
open [window]: opens the specified window.
close [window]: closes all instances of specified window.
toggle [window]: toggle-open/close the specified window.
windows: list shell windows and their respective status.
reload: quit this instance and start a new one.
reopen: restart all open-windows.
Audio Controls:
volume: speaker and microphone volume controller, see "volume help".
Media Controls:
media: manage colorshell's active player, see "media help".
Other options:
runner [initial_text]: open the application runner, optionally add an initial search.
peek-workspace-num [millis]: peek the workspace numbers on bar window.
h, help: shows this help message.
2025 (c) retrozinndev's colorshell, licensed under the MIT License.
https://github.com/retrozinndev/colorshell
`.split('\n').map(l => l.replace(/^ {8}/, "")).join('\n');
}
+73
View File
@@ -0,0 +1,73 @@
import { execAsync, Gio, GLib, register } from "astal";
import Polkit from "gi://Polkit";
import PolkitAgent from "gi://PolkitAgent";
import { EntryPopup, EntryPopupProps } from "../widget/EntryPopup";
import AstalAuth from "gi://AstalAuth";
import { AskPopup, AskPopupProps } from "../widget/AskPopup";
export { Auth };
@register({ GTypeName: "AuthAgent" })
class Auth extends PolkitAgent.Listener {
private static instance: Auth;
#subject: Polkit.Subject;
constructor() {
super();
this.#subject = Polkit.UnixSession.new(GLib.get_user_name());
this.register(PolkitAgent.RegisterFlags.NONE,
this.#subject,
"/io/github/retrozinndev/Colorshell/PolicyKit/AuthAgent",
null
);
}
vfunc_dispose() {
PolkitAgent.Listener.unregister();
}
static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array<Polkit.Identity>, cancellable?: Gio.Cancellable, callback?: Gio.AsyncReadyCallback): void | Promise<boolean> {
const authPopup = EntryPopup({
title: "Authentication",
text: message,
isPassword: true,
onFinish: callback,
onCancel: () => cancellable?.cancel(),
closeOnAccept: false,
onAccept: (input: string) => {
if(this.validatePasswd(input)) {
authPopup.close();
}
AskPopup({
} as AskPopupProps)
}
} as EntryPopupProps);
}
public static initAgent(): Auth {
if(!this.instance)
this.instance = new Auth();
return this.instance;
}
private static validatePasswd(passwd: string): boolean {
return AstalAuth.Pam.authenticate(passwd, null);
}
/** @returns if successful, true, or else, false */
public async polkitExecute(cmd: string | Array<string>): Promise<boolean> {
let success: boolean = true;
await execAsync([ "pkexec", "--", ...(Array.isArray(cmd) ?
cmd as Array<string> : [ cmd as string ]) ]
).catch((r) => {
success = false;
console.error(`Polkit: Couldn't authenticate. Stderr: ${r}`);
});
return success;
}
}
+42
View File
@@ -0,0 +1,42 @@
import { exec, execAsync, GObject, monitorFile, readFileAsync, register, signal } from "astal";
import { Connectable } from "astal/binding";
/** !!TODO!! Needs more work and testing
* I(retrozinndev) don't have a monitor that has software-controlled brightness :(
*/
@register({ GTypeName: "Brightness" })
class Brightness extends GObject.Object implements Connectable {
private readonly backlight: string|undefined;
private max: number;
private brightness: number;
@signal(Number)
declare brightnessChanged: (value: number) => void;
constructor(backlightDevice?: string) {
super();
this.backlight = backlightDevice || "intel_backlight";
this.max = Number.parseInt(exec(`brightnessctl -d ${backlightDevice} max`))
this.brightness = Number.parseInt(exec(`brightnessctl -d ${backlightDevice} get`))
readFileAsync(`/sys/class/backlight/${backlightDevice}/brightness`).catch(() => {
throw new Error(`Couldn't find backlight ${backlightDevice}`);
});
monitorFile(`/sys/class/backlight/${backlightDevice}/brightness`, async () => {
this.brightness = Number.parseInt(await execAsync(`brightnessctl -d ${backlightDevice} get`));
this.max = Number.parseInt(await execAsync(`brightnessctl -d ${backlightDevice} max`));
this.emit("brightness-changed", this.brightness);
});
}
public setBrightness(newBrightness: number): void {
execAsync(`brightnessctl -d ${this.backlight} set ${newBrightness || this.brightness}`).catch(() => {
throw new Error(`Couldn't set brightness of backlight ${this.backlight}`);
});
this.emit("brightness-changed", newBrightness);
}
}
+249
View File
@@ -0,0 +1,249 @@
import AstalIO from "gi://AstalIO";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
import GObject, { getter, register, signal } from "ags/gobject";
import { timeout } from "ags/time";
import { monitorFile, readFile } from "ags/file";
import { execAsync } from "ags/process";
interface ClipboardSignals extends GObject.Object.SignalSignatures {
copied: Clipboard["copied"];
wiped: Clipboard["wiped"];
};
export enum ClipboardItemType {
TEXT = 0,
IMAGE = 1
}
export class ClipboardItem {
id: number;
type: ClipboardItemType;
preview: string;
constructor(id: number, type: ClipboardItemType, preview: string) {
this.id = id;
this.type = type;
this.preview = preview;
}
}
export { Clipboard };
/** Cliphist Manager and event listener
* This only supports wipe and store events from cliphist */
@register({ GTypeName: "Clipboard" })
class Clipboard extends GObject.Object {
private static instance: Clipboard;
declare $signals: ClipboardSignals;
#dbFile: Gio.File;
#dbMonitor: Gio.FileMonitor;
#updateDone: boolean = false;
#history = new Array<ClipboardItem>;
#changesTimeout: (AstalIO.Time|undefined);
#ignoreChanges: boolean = false;
@signal(GObject.TYPE_JSOBJECT) copied(_item: object) {}
@signal() wiped() {};
@getter(Array)
public get history() { return this.#history; }
constructor() {
super();
this.#dbFile = this.getCliphistDatabase();
this.#dbMonitor = monitorFile(this.#dbFile.get_path()!, () => {
if(this.#ignoreChanges || this.#changesTimeout)
return;
this.#changesTimeout = timeout(300, () => this.#changesTimeout = undefined);
if(this.#updateDone) {
this.updateDatabase();
return;
}
this.init();
});
if(this.#dbFile.query_exists(null)) {
this.init();
return;
}
console.log("Clipboard: cliphist database not found. Try copying something first!");
}
vfunc_dispose(): void {
this.#dbMonitor.cancel();
this.#dbMonitor.unref();
}
private init() {
console.log("Clipboard: Starting to read cliphist history...");
this.updateDatabase().then(() => {
console.log("Clipboard: Done reading cliphist history!");
}).catch((err) =>
console.error(`Clipboard: An error occurred while reading cliphist history. Stderr: ${err}`)
);
}
public async copyAsync(content: string): Promise<void> {
await execAsync(`wl-copy "${content}"`).catch((err: Gio.IOErrorEnum) => {
console.error(`Clipboard: Couldn't copy text using wl-copy. Stderr:\n\t${err.message
} | Stack:\n\t\t${err.stack}`);
});
}
public async selectItem(itemToSelect: number|ClipboardItem): Promise<boolean> {
const item = await this.getItemContent(itemToSelect);
let res: boolean = true;
if(item)
await this.copyAsync(item).catch(() => res = false);
return res;
}
/** Gets history item's content by its ID.
* @returns the clipboard item's content */
public async getItemContent(item: number|ClipboardItem): Promise<string|undefined> {
const id = (typeof item === "number") ?
item : item.id;
const cmd = Gio.Subprocess.new([ "cliphist", "decode", id.toString() ],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
const [ , stdout, stderr ] = cmd.communicate_utf8(null, null);
if(stderr) {
console.error(`Clipboard: An error occurred while getting item content. Stderr:\n${stderr}`);
return;
}
return stdout;
}
/** Searches for the cliphist database file
* Will not work if cliphist config file is not on default path */
private getCliphistDatabase(): Gio.File {
// Check if env variable is set
const path = GLib.getenv("CLIPHIST_DB_PATH");
if(path != null)
return Gio.File.new_for_path(path);
// Check config file
const confFile = Gio.File.new_for_path(`${GLib.get_user_config_dir()}/cliphist/config`);
if(confFile.query_exists(null)) {
const cliphistConf = readFile(confFile.get_path()!);
for(const line of cliphistConf.split('\n').map(l => l.trim())) {
if(line.startsWith('#'))
continue;
const [ key, value ] = line.split('\s', 1);
if(key === "db-path") {
return Gio.File.new_for_path(value.trimStart());
}
}
}
// return default path if none of the above matches
return Gio.File.new_for_path(`${GLib.get_user_cache_dir()}/cliphist/db`);
}
private getContentType(preview: string): ClipboardItemType {
return /^\[\[.*binary data.*x.*\]\]$/u.test(preview) ?
ClipboardItemType.IMAGE
: ClipboardItemType.TEXT;
}
public async wipeHistory(noExec?: boolean): Promise<void> {
if(noExec) {
this.#history = [];
this.emit("wiped");
return;
}
this.#ignoreChanges = true;
await execAsync("cliphist wipe").then(() => {
this.#history = [];
this.emit("wiped");
}).catch((err: Gio.IOErrorEnum) =>
console.error(`Clipboard: An error occurred on cliphist database wipe. Stderr: ${
err.message ? `${err.message}\n` : ""}${err.stack}`)
).finally(() => this.#ignoreChanges = false);
}
public async updateDatabase(): Promise<void> {
const proc = Gio.Subprocess.new([ "cliphist", "list" ],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
proc.communicate_utf8_async(null, null, (_, asyncRes) => {
const [ success, stdout, stderr ] = proc.communicate_utf8_finish(asyncRes);
if(!success || stderr) {
console.error("Clipboard: Couldn't communicate with cliphist! Is it installed?");
return;
}
if(!stdout.trim()) {
this.wipeHistory(true);
this.notify("history");
return;
}
const items = stdout.split('\n');
if(this.#updateDone) {
const [ id, preview ] = items[0].split('\t');
const clipItem = {
id: Number.parseInt(id),
preview,
type: this.getContentType(preview)
} as ClipboardItem;
this.#history.unshift(clipItem);
this.emit("copied", clipItem);
this.notify("history");
return;
}
for(const item of items) {
if(!item) continue;
const [ id, preview ] = item.split('\t');
const clipItem = {
id: Number.parseInt(id),
preview,
type: this.getContentType(preview)
} as ClipboardItem;
this.#history.push(clipItem);
this.emit("copied", clipItem);
this.notify("history");
}
this.#updateDone = true;
});
}
public static getDefault(): Clipboard {
if(!this.instance)
this.instance = new Clipboard();
return this.instance;
}
}
+71
View File
@@ -0,0 +1,71 @@
import GLib from "gi://GLib?version=2.0";
import GObject, { getter, property, register } from "ags/gobject";
/** WIP Global implementation of a system that supports
* a variety of Wayland Compositors */
export namespace Compositor {
let instance: _Compositor;
@register({ GTypeName: "CompositorMonitor" })
class _CompositorMonitor extends GObject.Object {
public readonly width: number;
public readonly height: number;
@property(Boolean)
public readonly mirror: boolean;
constructor(width: number, height: number, mirror: boolean = false) {
super();
this.width = width;
this.height = height;
this.mirror = mirror;
}
}
@register({ GTypeName: "CompositorWorkspace" })
class _CompositorWorkspace extends GObject.Object {
public readonly id: number;
@getter(_CompositorMonitor)
public readonly monitor: _CompositorMonitor;
constructor(monitor: _CompositorMonitor, id: number) {
super();
this.monitor = monitor;
this.id = id;
}
}
@register({ GTypeName: "Compositor" })
class _Compositor extends GObject.Object {
#workspaces: Array<_CompositorWorkspace> = [];
@property()
public get workspaces() { return this.#workspaces; }
};
export function getDefault(): _Compositor {
if(!instance)
instance = new _Compositor();
return instance;
}
export const Compositor = _Compositor,
CompositorWorkspace = _CompositorWorkspace,
CompositorMonitor = _CompositorMonitor;
/** Uses the XDG_CURRENT_DESKTOP variable to detect running compositor's name.
* ---
* @returns running wayland compositor's name (lowercase) or `undefined` if variable's not set */
export function getName(): string|undefined {
return GLib.getenv("XDG_CURRENT_DESKTOP") ?? undefined;
}
}
+183
View File
@@ -0,0 +1,183 @@
import { timeout } from "ags/time";
import { monitorFile, readFileAsync } from "ags/file";
import { Notifications } from "./notifications";
import { encoder } from "./utils";
import { Accessor } from "ags";
import GObject, { getter, register } from "ags/gobject";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
import AstalIO from "gi://AstalIO";
import AstalNotifd from "gi://AstalNotifd";
export { Config };
type ValueTypes = "string" | "boolean" | "object" | "number" | "undefined" | "any";
@register({ GTypeName: "Config" })
class Config<K extends NonNullable<string|number|symbol>, V extends string|object|any> extends GObject.Object {
declare $signals: GObject.Object.SignalSignatures & {
"notify::entries": (entries: Record<K, V>) => void;
};
/** unmodified object with default entries. User-values are stored
* in the `entries` field */
public readonly defaults: Record<K, V>;
@getter(Object)
public get entries(): object { return this.#entries; }
#file: Gio.File;
#entries: Record<K, V>;
private timeout: (AstalIO.Time|boolean|undefined);
public get file() { return this.#file; };
constructor(filePath: Gio.File|string, defaults?: Record<K, V>) {
super();
this.defaults = (defaults ?? {}) as Record<K, V>;
this.#entries = { ...defaults } as Record<K, V>;
this.#file = (typeof filePath === "string") ?
Gio.File.new_for_path(filePath)
: filePath;
if(!this.#file.query_exists(null)) {
this.#file.make_directory_with_parents(null);
this.#file.delete(null);
this.#file.create_readwrite_async(
Gio.FileCreateFlags.NONE, GLib.PRIORITY_DEFAULT,
null, (_, asyncRes) => {
const ioStream = this.#file.create_readwrite_finish(asyncRes);
ioStream.outputStream.write_bytes_async(
GLib.Bytes.new(encoder.encode(JSON.stringify(this.entries, undefined, 4))),
GLib.PRIORITY_DEFAULT, null,
(_, asyncRes) => {
const writtenBytes = ioStream.outputStream.write_bytes_finish(asyncRes);
if(!writtenBytes)
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Write error",
body: `Couldn't write default configuration file to "${this.#file.get_path()!}"`
});
}
);
});
}
monitorFile(this.#file.get_path()!,
() => {
if(this.timeout) return;
this.timeout = timeout(500, () => this.timeout = undefined);
if(this.#file.query_exists(null)) {
this.timeout?.cancel();
this.timeout = true;
this.readFile().finally(() =>
this.timeout = undefined);
return;
}
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Config error",
body: `Could not hot-reload configuration: config file not found in \`${this.#file.get_path()!}\`, last valid configuration is being used. Maybe it got deleted?`
});
}
);
}
private async readFile(): Promise<void> {
await readFileAsync(this.#file.get_path()!).then((content) => {
let config: (Record<K, V>|undefined);
try {
config = JSON.parse(content) as Record<K, V>;
} catch(e) {
Notifications.getDefault().sendNotification({
urgency: AstalNotifd.Urgency.NORMAL,
appName: "colorshell",
summary: "Config parsing error",
body: `An error occurred while parsing colorshell's config file: \nFile: ${
this.#file.get_path()!}\n${
(e as SyntaxError).message}\n${(e as SyntaxError).stack}`
});
}
if(!config) return;
// only change valid entries that are available in the defaults (with 1 of depth)
for(const k of Object.keys(this.entries)) {
if(config[k as keyof typeof config] === undefined)
return;
// TODO needs more work, like object-recursive(infinite depth) entry attributions
this.#entries[k as keyof Record<K, V>] = config[k as keyof typeof config];
}
this.notify("entries");
}).catch((e: Gio.IOErrorEnum) => {
Notifications.getDefault().sendNotification({
urgency: AstalNotifd.Urgency.NORMAL,
appName: "colorshell",
summary: "Config read error",
body: `An error occurred while reading colorshell's config file: \nFile: ${`${
this.#file.get_path()!}\n${e.message ? `${e.message}\n` : ""}${e.stack}`.replace(/[<>]/g, "\\&")}`
});
});
}
public bindProperty(propertyPath: string, expectType?: ValueTypes): Accessor<any|undefined> {
return new Accessor<Record<K, V>>(() => this.getProperty(propertyPath, expectType), (callback: () => void) => {
const id = this.connect("notify::entries", () => callback());
return () => this.disconnect(id);
});
}
public getProperty(path: string, expectType?: ValueTypes): (any|undefined) {
return this._getProperty(path, this.#entries, expectType);
}
public getPropertyDefault(path: string, expectType?: ValueTypes): (any|undefined) {
return this._getProperty(path, this.defaults, expectType);
}
private _getProperty(path: string, entries: Record<K, V>, expectType?: ValueTypes): (any|undefined) {
let property: any = entries;
const pathArray = path.split('.').filter(str => str);
for(let i = 0; i < pathArray.length; i++) {
const currentPath = pathArray[i];
property = property[currentPath as keyof typeof property];
}
if(expectType !== "any" && typeof property !== expectType) {
console.error(`Config: property with path \`${path
}\` is either \`undefined\` or not in the expected value type \`${expectType
}\`, returning default value`);
property = this.defaults;
for(let i = 0; i < pathArray.length; i++) {
const currentPath = pathArray[i];
property = property[currentPath as keyof typeof property];
}
}
if(expectType !== "any" && typeof property !== expectType) {
console.error(`Config: property with path \`${path}\` not found in defaults/user-entries, returning \`undefined\``);
property = undefined;
}
return property;
}
}
+150
View File
@@ -0,0 +1,150 @@
import AstalIO from "gi://AstalIO";
import GLib from "gi://GLib?version=2.0";
import GObject, { getter, register } from "ags/gobject";
import { execAsync, exec } from "ags/process";
import { interval } from "ags/time";
export { NightLight };
@register({ GTypeName: "NightLight" })
class NightLight extends GObject.Object {
private static instance: NightLight;
#watchInterval: (AstalIO.Time|null) = null;
#temperature: number = 4500;
#gamma: number = 100;
#identity: boolean = false;
#prevTemperature: (number|null) = null;
#prevGamma: (number|null) = null;
@getter(Number)
public get temperature() { return this.#temperature; }
public set temperature(newValue: number) { this.setTemperature(newValue); }
@getter(Number)
public get gamma() { return this.#gamma; }
public set gamma(newValue: number) { this.setGamma(newValue); }
public readonly maxTemperature = 20000;
public readonly minTemperature = 1000;
public readonly identityTemperature = 6000;
public readonly maxGamma = 100;
@getter(Boolean)
public get identity() { return this.#identity; }
public set identity(newValue: boolean) {
newValue ? this.applyIdentity() : this.filter();
}
constructor() {
super();
this.#watchInterval = interval(1000, () => {
execAsync("hyprctl hyprsunset temperature").then(t => {
if(t.trim() !== "" && t.trim().length <= 5) {
const val = Number.parseInt(t.trim());
if(this.#temperature !== val) {
this.#temperature = val;
this.notify("temperature");
}
}
}).catch((r) => console.error(r));
execAsync("hyprctl hyprsunset gamma").then(g => {
if(g.trim() !== "" && g.trim().length <= 5) {
const val = Number.parseInt(g.trim());
if(this.#gamma !== val) {
this.#gamma = val;
this.notify("gamma");
}
}
}).catch((r) => console.error(r));
});
this.vfunc_dispose = () => this.#watchInterval &&
this.#watchInterval.cancel();
}
public static getDefault(): NightLight {
if(!this.instance)
this.instance = new NightLight();
return this.instance;
}
private setTemperature(value: number): void {
if(value === this.temperature) return;
if(value > this.maxTemperature || value < 1000) {
console.error(`Night Light(hyprsunset): provided temperatue ${value
} is out of bounds (min: 1000; max: ${this.maxTemperature})`);
return;
}
execAsync(`hyprctl hyprsunset temperature ${value}`).then(() => {
this.#temperature = value;
this.notify("temperature");
this.#identity = false;
this.#prevTemperature = null;
this.#prevGamma = null;
}).catch((r) => console.error(
`Night Light(hyprsunset): Couldn't set temperature. Stderr: ${r}`
));
}
private setGamma(value: number): void {
if(value === this.gamma) return;
if(value > this.maxGamma || value < 0) {
console.error(`Night Light(hyprsunset): provided gamma ${value
} is out of bounds (min: 0; max: ${this.maxTemperature})`);
return;
}
execAsync(`hyprctl hyprsunset gamma ${value}`).then(() => {
this.#gamma = value;
this.notify("gamma");
this.#identity = false;
this.#prevTemperature = null;
this.#prevGamma = null;
}).catch((r) => console.error(
`Night Light(hyprsunset): Couldn't set gamma. Stderr: ${r}`
));
}
public applyIdentity(): void {
if(this.#identity) return;
this.#prevGamma = this.#gamma;
this.#prevTemperature = this.#temperature;
this.#identity = true;
this.temperature = this.identityTemperature;
this.gamma = this.maxGamma;
}
public filter(): void {
if(!this.#identity) return;
this.#identity = false;
this.setTemperature(this.#prevTemperature ?? this.identityTemperature);
this.setGamma(this.#prevGamma ?? this.maxGamma);
this.#prevTemperature = null;
this.#prevGamma = null;
}
public saveData(): void {
exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/save-hyprsunset.sh`);
}
public loadData(): void {
exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/load-hyprsunset.sh`);
}
}
+313
View File
@@ -0,0 +1,313 @@
import { timeout } from "ags/time";
import { execAsync } from "ags/process";
import { readFile } from "ags/file";
import { generalConfig } from "../app";
import { onCleanup } from "ags";
import GObject, { getter, property, register, signal } from "ags/gobject";
import AstalNotifd from "gi://AstalNotifd";
import AstalIO from "gi://AstalIO";
import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0";
export interface HistoryNotification {
id: number;
appName: string;
body: string;
summary: string;
urgency: AstalNotifd.Urgency;
appIcon?: string;
time: number;
image?: string;
}
@register({ GTypeName: "Notifications" })
class Notifications extends GObject.Object {
private static instance: (Notifications|null) = null;
#notifications: Array<AstalNotifd.Notification> = [];
#history: Array<HistoryNotification> = [];
#notificationsOnHold: Set<number> = new Set<number>();
#connections: Array<number> = [];
@getter(Array<AstalNotifd.Notification>)
public get notifications() { return this.#notifications };
@getter(Array<HistoryNotification>)
public get history() { return this.#history };
@property(Number)
public historyLimit: number = 10;
@signal(AstalNotifd.Notification) notificationAdded(_notification: AstalNotifd.Notification) {};
@signal(Number) notificationRemoved(_id: number) {};
@signal(Object) historyAdded(_notification: Object) {};
@signal(Number) historyRemoved(_id: number) {};
@signal(Number) notificationReplaced(_id: number) {};
constructor() {
super();
this.#connections.push(
AstalNotifd.get_default().connect("notified", (notifd, id) => {
const notification = notifd.get_notification(id);
const notifTimeout = generalConfig.getProperty(
`notifications.timeout_${this.getUrgencyString(notification.urgency).toLowerCase()}`,
"number") as number;
if(this.getNotifd().dontDisturb) {
this.addHistory(notification, () => notification.dismiss());
return;
}
this.addNotification(notification, () => {
if(notification.urgency !== AstalNotifd.Urgency.CRITICAL ||
(notification.urgency === AstalNotifd.Urgency.CRITICAL &&
notifTimeout > 0)) {
let notifTimer: (AstalIO.Time|undefined) = undefined;
let replacedConnectionId: number;
const removeFun = () => { // Funny name haha lmao remove fun :skull:
notifTimer = undefined;
if(this.#notificationsOnHold.has(notification.id)) return;
this.addHistory(notification, () => {
replacedConnectionId && this.disconnect(replacedConnectionId);
this.removeNotification(id);
});
}
notifTimer = timeout(notifTimeout, removeFun);
replacedConnectionId = this.connect("notification-replaced", (_, id: number) => {
if(notification.id !== id) return;
notifTimer?.cancel();
notifTimer = timeout(notifTimeout, removeFun);
});
}
});
}),
AstalNotifd.get_default().connect("resolved", (notifd, id, _reason) => {
this.removeNotification(id);
this.addHistory(notifd.get_notification(id));
})
);
this.retrieveHistoryFromFile();
onCleanup(() => {
this.#connections.map(id =>
AstalNotifd.get_default().disconnect(id));
});
}
public static getDefault(): Notifications {
if(!this.instance)
this.instance = new Notifications();
return this.instance;
}
private retrieveHistoryFromFile(): void {
const historyFile = Gio.File.new_for_path(`${GLib.get_user_state_dir()}/astal/notifd/notifications.json`);
if(!historyFile.query_exists(null)) return;
let content: string;
console.log("Notifications: History file found! Trying to retrieve history from JSON");
try {
content = readFile(historyFile.get_path()!);
} catch(e: any) {
console.error(`Notifications: An error occurred while trying to read the history file. Stderr:\n${
(e as Error).message}\n${(e as Error).stack}`);
return;
}
try {
const historyJSON = JSON.parse(content);
(historyJSON["notifications"] as Array<AstalNotifd.Notification>).reverse()
.forEach(n => this.addHistory(n));
} catch(e: any) {
if(e instanceof SyntaxError) {
console.error(`Notifications: Couldn't parse history JSON because of a SyntaxError:\n${e.message
}\n${e.stack}`);
return;
}
console.error(`Notifications: An error occurred while parsing the history JSON file. Stderr:\n${
e.message}\n${e.stack}`);
return;
}
}
public async sendNotification(props: {
urgency?: AstalNotifd.Urgency;
appName?: string;
image?: string;
summary: string;
body?: string;
replaceId?: number;
actions?: Array<{
id?: (string|number);
text: string;
onAction?: () => void
}>
}): Promise<{
id?: (string|number);
text: string;
onAction?: () => void
}|null|void> {
return await execAsync([
"notify-send",
...(props.urgency ? [
"-u", this.getUrgencyString(props.urgency)
] : []), ...(props.appName ? [
"-a", props.appName
] : []), ...(props.image ? [
"-i", props.image
] : []), ...(props.actions ? props.actions.map((action) =>
[ "-A", action.text ]
).flat(2) : []), ...(props.replaceId ? [
"-r", props.replaceId.toString()
] : []), props.summary, props.body ? props.body : ""
]).then((stdout) => {
stdout = stdout.trim();
if(!stdout) {
if(props.actions && props.actions.length > 0)
return null;
return;
}
if(props.actions && props.actions.length > 0) {
const action = props.actions[Number.parseInt(stdout)];
action?.onAction?.();
return action ?? undefined;
}
}).catch((err: Error) => {
console.error(`Notifications: Couldn't send notification! Is the daemon running? Stderr:\n${
err.message ? `${err.message}\n` : ""}Stack: ${err.stack}`);
});
}
public getUrgencyString(urgency: AstalNotifd.Notification|AstalNotifd.Urgency) {
switch((urgency instanceof AstalNotifd.Notification) ?
urgency.urgency : urgency) {
case AstalNotifd.Urgency.LOW:
return "low";
case AstalNotifd.Urgency.CRITICAL:
return "critical";
}
return "normal";
}
private addHistory(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void {
if(!notif) return;
this.#history.length === this.historyLimit &&
this.removeHistory(this.#history[this.#history.length - 1]);
this.#history.map((notifb, i) =>
notifb.id === notif.id && this.#history.splice(i, 1));
this.#history.unshift({
id: notif.id,
appName: notif.app_name,
body: notif.body,
summary: notif.summary,
urgency: notif.urgency,
appIcon: notif.app_icon,
time: notif.time,
image: notif.image ? notif.image : undefined
} as HistoryNotification);
this.notify("history");
this.emit("history-added", this.#history[0]);
onAdded && onAdded(notif);
}
public async clearHistory(): Promise<void> {
this.#history.reverse().map((notif) => {
this.#history = this.history.filter((n) => n.id !== notif.id);
this.emit("history-removed", notif.id);
});
this.notify("history");
}
public removeHistory(notif: (HistoryNotification|number)): void {
const notifId = (typeof notif === "number") ? notif : notif.id;
this.#history = this.#history.filter((item: HistoryNotification) =>
item.id !== notifId);
this.notify("history");
this.emit("history-removed", notifId);
}
private addNotification(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void {
for(let i = 0; i < this.#notifications.length; i++) {
const item = this.#notifications[i];
if(item.id !== notif.id) continue;
this.#notifications.splice(i, 1);
this.emit("notification-replaced", item.id);
break;
}
this.#notifications.unshift(notif);
this.notify("notifications");
this.emit("notification-added", notif);
onAdded?.(notif);
}
public removeNotification(notif: (AstalNotifd.Notification|number)): void {
const notificationId = (notif instanceof AstalNotifd.Notification) ? notif.id : notif;
this.#notificationsOnHold.delete(notificationId);
this.#notifications = this.#notifications.filter((item) =>
item.id !== notificationId);
AstalNotifd.get_default().get_notification(notificationId)?.dismiss();
this.notify("notifications");
this.emit("notification-removed", notificationId);
}
private getNotificationById(id: number): AstalNotifd.Notification|undefined {
return this.#notifications.filter(notif => notif.id === id)?.[0];
}
public holdNotification(notif: (AstalNotifd.Notification|number)): void {
notif = (typeof notif === "number") ?
this.getNotificationById(notif)!
: notif;
if(!notif) return;
this.#notificationsOnHold.add(notif.id);
}
public toggleDoNotDisturb(value?: boolean): boolean {
value = value ?? !AstalNotifd.get_default().dontDisturb;
AstalNotifd.get_default().dontDisturb = value;
return value;
}
public getNotifd(): AstalNotifd.Notifd { return AstalNotifd.get_default(); }
}
export { Notifications };
+158
View File
@@ -0,0 +1,158 @@
import { execAsync } from "ags/process";
import { getter, register, signal } from "ags/gobject";
import { Gdk } from "ags/gtk4";
import { makeDirectory } from "./utils";
import { Notifications } from "./notifications";
import { time } from "./utils";
import GObject from "ags/gobject";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
export { Recording };
@register({ GTypeName: "Recording" })
class Recording extends GObject.Object {
private static instance: Recording;
@signal() started() {};
@signal() stopped() {};
#recording: boolean = false;
#path: string = "~/Recordings";
/** Default extension: mp4(h264) */
#extension: string = "mp4";
#recordAudio: boolean = false;
#area: (Gdk.Rectangle|null) = null;
#startedAt: number = -1;
#process: (Gio.Subprocess|null) = null;
#output: (string|null) = null;
/** GLib.DateTime of when recording started
* its value can be `-1` if undefined(no recording is happening) */
@getter(Number)
public get startedAt() { return this.#startedAt; }
@getter(Boolean)
public get recording() { return this.#recording; }
private set recording(newValue: boolean) {
(!newValue && this.#recording) ?
this.stopRecording()
: this.startRecording(this.#area || undefined);
this.#recording = newValue;
this.notify("recording");
}
@getter(String)
public get path() { return this.#path; }
public set path(newPath: string) {
if(this.recording) return;
this.#path = newPath;
this.notify("path");
}
@getter(String)
public get extension() { return this.#extension; }
public set extension(newExt: string) {
if(this.recording) return;
this.#extension = newExt;
this.notify("extension");
}
/** Recording output file name. %NULL if screen is not being recorded */
public get output() { return this.#output; }
/** Currently unsupported property */
public get recordAudio() { return this.#recordAudio; }
public set recordAudio(newValue: boolean) {
if(this.recording) return;
this.#recordAudio = newValue;
this.notify("record-audio");
}
constructor() {
super();
const videosDir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_VIDEOS);
if(videosDir) this.#path = `${videosDir}/Recordings`;
}
public static getDefault() {
if(!this.instance)
this.instance = new Recording();
return this.instance;
}
public startRecording(area?: Gdk.Rectangle) {
if(this.recording)
throw new Error("Screen Recording is already running!");
this.#output = `${time.get().format("%Y-%m-%d-%H%M%S")}_rec.${this.extension || "mp4"}`;
this.#recording = true;
this.notify("recording");
this.emit("started");
makeDirectory(this.path);
const cancellable = Gio.Cancellable.new();
cancellable.cancel = () => {};
const areaString = `${area?.x ?? 0},${area?.y ?? 0} ${area?.width ?? 1}x${area?.height ?? 1}`;
this.#process = Gio.Subprocess.new([
"wf-recorder",
...(area ? [ `-g`, areaString ] : []),
"-f",
`${this.path}/${this.output!}`
], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
this.#process.wait_async(cancellable, () => {
this.stopRecording();
});
this.#startedAt = time.get().to_unix();
}
public stopRecording() {
if(!this.#process) return;
!this.#process.get_if_exited() && execAsync([
"kill", "-s", "SIGTERM", this.#process.get_identifier()!
]);
const path = this.#path;
const output = this.#output;
this.#process = null;
this.#recording = false;
this.#startedAt = -1;
this.#output = null;
this.notify("recording");
this.emit("stopped");
Notifications.getDefault().sendNotification({
actions: [
{
text: "View",
onAction: () => {
execAsync(["nautilus", "-s", output!, path]);
}
},
{
text: "Open",
onAction: () => {
execAsync(["xdg-open", `${path}/${output}`]);
}
}
],
appName: "Screen Recording",
summary: "Screen Recording saved",
body: `Saved as ${path}/${output}`
});
}
};
+27
View File
@@ -0,0 +1,27 @@
import { monitorFile } from "ags/file";
import { execAsync } from "ags/process";
import { uwsmIsActive } from "./apps";
import Gio from "gi://Gio?version=2.0";
const monitoringPaths = [ "./scripts", "./window", "./app.ts", "env.d.ts" ];
export function restartInstance(): void {
execAsync(`astal -q "colorshell"`);
Gio.Subprocess.new(
( uwsmIsActive ?
[ "uwsm", "app", "--", "ags", "run" ]
: [ "ags", "run" ]),
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
}
export function monitorPaths(): void {
monitoringPaths.map((path: string) => {
monitorFile(
path,
() => restartInstance()
)
});
}
+87
View File
@@ -0,0 +1,87 @@
import { monitorFile, readFile } from "ags/file";
import { timeout } from "ags/time";
import { exec, execAsync } from "ags/process";
import { Shell } from "../app";
import AstalIO from "gi://AstalIO";
import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0";
/** handles stylesheet compiling and reloading */
export class Stylesheet {
private static instance: Stylesheet;
#watchDelay: (AstalIO.Time|undefined);
#outputPath = Gio.File.new_for_path(`${GLib.get_user_cache_dir()}/colorshell/style`);
#styles = [ "./style", "./style.scss" ];
public get stylePath() { return this.#outputPath.get_path()!; }
public async compileSass(): Promise<void> {
console.log("Stylesheet: Compiling Sass");
exec(`bash -c "sass ${this.#styles.map(style => `-I ${style}`).join('\s')} ${
this.#outputPath.get_path()!}/style.css"`);
}
public async reapply(cssFilePath: string): Promise<void> {
console.log("Stylesheet: Applying stylesheet");
const content = readFile(cssFilePath);
if(content?.trim()) {
Shell.getDefault().resetStyle();
Shell.getDefault().applyStyle(content);
console.log("Stylesheet: done applying stylesheet to shell");
return;
}
console.error(`Stylesheet: An error occurred while trying to read the css file: ${
cssFilePath}`);
}
public async compileApply(): Promise<void> {
await this.compileSass().then(() =>
this.reapply(this.#outputPath.get_path()! + "/style.css")
).catch((err: Error) =>
console.error(`Stylesheet: An error occurred and Sass couldn't be compiled. Stderr:\n${
err.message}\n${err.stack}`)
);
}
public static getDefault(): Stylesheet {
if(!this.instance)
this.instance = new Stylesheet();
return this.instance;
}
constructor() {
(async () => !this.#outputPath.query_exists(null) &&
this.#outputPath.make_directory_with_parents(null))();
this.#styles.map((path: string) =>
monitorFile(
`${path}`,
(file: string) => {
if(this.#watchDelay || file.endsWith('~') || Number.isNaN(file))
return;
this.#watchDelay = timeout(250, () => this.#watchDelay = undefined);
console.log(`Stylesheet: \`${file.startsWith(GLib.get_home_dir()) ?
file.replace(GLib.get_home_dir(), '~')
: file}\` changed`)
this.compileApply();
}
)
)
monitorFile(`${GLib.get_user_cache_dir()}/wal/colors.scss`, (file: string) => {
execAsync(`bash -c "cp -f ${file} ./style/_wal.scss"`).catch(r => {
console.error(`Stylesheet: Failed to copy pywal stylesheet to style dir. Stderr: ${r}`);
});
});
}
}
+214
View File
@@ -0,0 +1,214 @@
import { createPoll } from "ags/time";
import { exec, execAsync } from "ags/process";
import { Accessor, For, With } from "ags";
import { Astal, Gtk } from "ags/gtk4";
import { getSymbolicIcon } from "./apps";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
import Xdp from "gi://Xdp?version=1.0";
/** gnim doesn't export this, so we need to do it again */
export type WidgetNodeType = Array<JSX.Element> | JSX.Element | number | string | boolean | null | undefined;
export const decoder = new TextDecoder("utf-8"),
encoder = new TextEncoder();
export const time = createPoll(GLib.DateTime.new_now_local(), 500, () =>
GLib.DateTime.new_now_local());
export const XdgPortal = Xdp.Portal.new();
export function getHyprlandInstanceSig(): (string|null) {
return GLib.getenv("HYPRLAND_INSTANCE_SIGNATURE");
}
export function getHyprlandVersion(): string {
return exec(`${GLib.getenv("HYPRLAND_CMD") ?? "Hyprland"} --version | head -n1`).split(" ")[1];
}
export function getPlayerIconFromBusName(busName: string): string {
const splitName = busName.split('.').filter(str => str !== "" &&
!str.toLowerCase().includes('instance'));
return getSymbolicIcon(splitName[splitName.length - 1]) ?
getSymbolicIcon(splitName[splitName.length - 1])!
: "folder-music-symbolic";
}
export function escapeUnintendedMarkup(input: string): string {
return input.replace(/<[^>]*>|[<>&"]/g, (s) => {
if(s.startsWith('<') && s.endsWith('>'))
return s;
switch(s) {
case "<": return "&lt;";
case ">": return "&gt;";
case "&": return "&amp;";
case "\"": return "&quot;";
}
return s;
});
}
export function getChildren(widget: Gtk.Widget): Array<Gtk.Widget> {
const firstChild = widget.get_first_child(),
children: Array<Gtk.Widget> = [];
if(!firstChild) return [];
let currentChild = firstChild.get_next_sibling();
while(currentChild != null) {
children.push(currentChild);
currentChild = currentChild.get_next_sibling();
}
return children;
}
export function omitObjectKeys<ObjT = object>(obj: ObjT, keys: keyof ObjT|Array<keyof ObjT>): object {
const finalObject = { ...obj };
for(const objKey of Object.keys(finalObject as object)) {
if(!Array.isArray(keys)) {
if(objKey === keys) {
delete finalObject[keys as keyof typeof finalObject];
break;
}
continue;
}
for(const omitKey of keys) {
if(objKey === omitKey) {
delete finalObject[objKey as keyof typeof finalObject];
break;
}
}
}
return finalObject as object;
}
export function pickObjectKeys<ObjT = object>(obj: ObjT, keys: Array<keyof ObjT>): object {
const finalObject = {} as Record<keyof ObjT, any>;
for(const key of keys) {
for(const objKey of Object.keys(obj as object)) {
if(key === objKey) {
finalObject[key as keyof ObjT] = obj[objKey as keyof ObjT];
break;
}
}
}
return finalObject;
}
export function variableToBoolean(variable: any|Array<any>|Accessor<Array<any>|any>): boolean|Accessor<boolean> {
return (variable instanceof Accessor) ?
variable.as(v => Array.isArray(v) ?
(v as Array<any>).length > 0
: Boolean(v))
: Array.isArray(variable) ?
variable.length > 0
: Boolean(variable);
}
export function pathToURI(path: string): string {
switch(true) {
case (/^[/]/).test(path):
return `file://${path}`;
case (/^[~]/).test(path):
case (/^file:\/\/[~]/i).test(path):
return `file://${GLib.get_home_dir()}/${path.replace(/^(file\:\/\/|[~]|file\:\/\[~])/i, "")}`;
}
return path;
}
export function transform<ValueType = any|Array<any>, RType = any>(
v: Accessor<ValueType>|ValueType, fn: (v: ValueType) => RType
): RType|Accessor<RType> {
return (v instanceof Accessor) ?
v.as(fn)
: fn(v);
}
export function transformWidget<ValueType = unknown>(
v: Accessor<ValueType|Array<ValueType>>|ValueType|Array<ValueType>,
fn: (v: ValueType, i?: Accessor<number>|number) => JSX.Element
): WidgetNodeType {
return (v instanceof Accessor) ?
Array.isArray(v.get()) ?
For({
each: v as Accessor<Array<ValueType>>,
children: (cval, i) => fn(cval, i)
})
: With({
value: v as Accessor<ValueType>,
children: fn
})
: (Array.isArray(v) ?
v.map(val => fn(val))
: fn(v));
}
export function filter<ValueType = unknown, FilterReturnType = unknown>(
v: Accessor<Array<ValueType>>|Array<ValueType>,
fn: (v: ValueType, i: number, array: Array<ValueType>) => FilterReturnType
): Array<ValueType>|Accessor<Array<ValueType>> {
return ((v instanceof Accessor) ?
v(v => v.filter((it, i, arr) => fn(it, i, arr)))
: v.filter((it, i, arr) => fn(it, i, arr)));
}
export function makeDirectory(dir: string): void {
execAsync([ "mkdir", "-p", dir ]);
}
export function deleteFile(path: string): void {
execAsync([ "rm", "-r", path ]);
}
export function playSystemBell(): void {
execAsync("canberra-gtk-play -i bell").catch((e: Error) => {
console.error(`Couldn't play system bell. Stderr: ${e.message}\n${e.stack}`);
});
}
export function isInstalled(commandName: string): boolean {
const proc = Gio.Subprocess.new(["bash", "-c", `command -v ${commandName}`],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
const [ , stdout, stderr ] = proc.communicate_utf8(null, null);
if(stdout && !stderr)
return true;
return false;
}
export function addSliderMarksFromMinMax(slider: Astal.Slider, amountOfMarks: number = 2, markup?: (string | null)) {
if(markup && !markup.includes("{}"))
markup = `${markup}{}`
slider.add_mark(slider.min, Gtk.PositionType.BOTTOM, markup ?
markup.replaceAll("{}", `${slider.min}`) : null);
const num = (amountOfMarks - 1);
for(let i = 1; i <= num; i++) {
const part = (slider.max / num) | 0;
if(i > num) {
slider.add_mark(slider.max, Gtk.PositionType.BOTTOM, `${slider.max}K`);
break;
}
slider.add_mark(part*i, Gtk.PositionType.BOTTOM, markup ?
markup.replaceAll("{}", `${part*i}`) : null);
}
return slider;
}
+148
View File
@@ -0,0 +1,148 @@
import GObject, { register } from "ags/gobject";
import AstalWp from "gi://AstalWp";
export { Wireplumber };
@register({ GTypeName: "Wireplumber" })
class Wireplumber extends GObject.Object {
private static astalWireplumber: (AstalWp.Wp|null) = AstalWp.get_default();
private static inst: Wireplumber;
private defaultSink: AstalWp.Endpoint = Wireplumber.astalWireplumber!.get_default_speaker()!;
private defaultSource: AstalWp.Endpoint = Wireplumber.astalWireplumber!.get_default_microphone()!;
private maxSinkVolume: number = 100;
private maxSourceVolume: number = 100;
constructor() {
super();
if(!Wireplumber.astalWireplumber)
throw new Error("Audio features will not work correctly! Please install wireplumber first", {
cause: "Wireplumber library not found"
});
}
public static getDefault(): Wireplumber {
if(!Wireplumber.inst)
Wireplumber.inst = new Wireplumber();
return Wireplumber.inst;
}
public static getWireplumber(): AstalWp.Wp {
return Wireplumber.astalWireplumber!;
}
public getMaxSinkVolume(): number {
return this.maxSinkVolume;
}
public getMaxSourceVolume(): number {
return this.maxSourceVolume;
}
public getDefaultSink(): AstalWp.Endpoint {
return this.defaultSink;
}
public getDefaultSource(): AstalWp.Endpoint {
return this.defaultSource;
}
public getSinkVolume(): number {
return Math.floor(this.getDefaultSink().get_volume() * 100);
}
public getSourceVolume(): number {
return Math.floor(this.getDefaultSource().get_volume() * 100);
}
public setSinkVolume(newSinkVolume: number): void {
this.defaultSink.set_volume(
(newSinkVolume > this.maxSinkVolume ? this.maxSinkVolume : newSinkVolume) / 100
);
}
public setSourceVolume(newSourceVolume: number): void {
this.defaultSource.set_volume(
newSourceVolume > this.maxSourceVolume ? this.maxSourceVolume : newSourceVolume / 100
);
}
public increaseEndpointVolume(endpoint: AstalWp.Endpoint, volumeIncrease: number): void {
volumeIncrease = Math.abs(volumeIncrease) / 100;
if((endpoint.get_volume() + volumeIncrease) > (this.maxSinkVolume / 100)) {
endpoint.set_volume(1.0);
return;
}
endpoint.set_volume(endpoint.get_volume() + volumeIncrease);
}
public increaseSinkVolume(volumeIncrease: number): void {
this.increaseEndpointVolume(this.getDefaultSink(), volumeIncrease);
}
public increaseSourceVolume(volumeIncrease: number): void {
this.increaseEndpointVolume(this.getDefaultSource(), volumeIncrease);
}
public decreaseEndpointVolume(endpoint: AstalWp.Endpoint, volumeDecrease: number): void {
volumeDecrease = Math.abs(volumeDecrease) / 100;
if((endpoint.get_volume() - volumeDecrease) < 0) {
endpoint.set_volume(0);
return;
}
endpoint.set_volume(endpoint.get_volume() - volumeDecrease);
}
public decreaseSinkVolume(volumeDecrease: number): void {
this.decreaseEndpointVolume(this.getDefaultSink(), volumeDecrease);
}
public decreaseSourceVolume(volumeDecrease: number): void {
this.decreaseEndpointVolume(this.getDefaultSource(), volumeDecrease);
}
public muteSink(): void {
this.getDefaultSink().set_mute(true);
}
public muteSource(): void {
this.getDefaultSource().set_mute(true);
}
public unmuteSink(): void {
this.getDefaultSink().set_mute(false);
}
public unmuteSource(): void {
this.getDefaultSource().set_mute(false);
}
public isMutedSink(): boolean {
return this.getDefaultSink().get_mute();
}
public isMutedSource(): boolean {
return this.getDefaultSource().get_mute();
}
public toggleMuteSink(): void {
if(this.isMutedSink())
return this.unmuteSink();
return this.muteSink();
}
public toggleMuteSource(): void {
if(this.isMutedSource())
return this.unmuteSource();
return this.muteSource();
}
}
+191
View File
@@ -0,0 +1,191 @@
import { execAsync } from "ags/process";
import { timeout } from "ags/time";
import GObject, { register, getter } from "ags/gobject";
import { monitorFile } from "ags/file";
import AstalIO from "gi://AstalIO";
import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0";
export { Wallpaper };
@register({ GTypeName: "Wallpaper" })
class Wallpaper extends GObject.Object {
private static instance: Wallpaper;
#wallpaper: (string|undefined);
#splash: boolean = true;
#monitor: Gio.FileMonitor;
#hyprpaperFile: Gio.File;
#wallpapersPath: string;
#ignoreWatch: boolean = false;
@getter(Boolean)
public get splash() { return this.#splash; }
public set splash(showSplash: boolean) {
this.#splash = showSplash;
this.notify("splash");
}
/** current wallpaper's complete path
* can be an empty string if undefined */
@getter(String)
public get wallpaper() { return this.#wallpaper ?? ""; }
public set wallpaper(newValue: string) { this.setWallpaper(newValue); }
public get wallpapersPath() { return this.#wallpapersPath; }
constructor() {
super();
this.#wallpapersPath = GLib.getenv("WALLPAPERS") ??
`${GLib.get_home_dir()}/wallpapers`;
this.#hyprpaperFile = Gio.File.new_for_path(`${
GLib.get_user_config_dir()}/hypr/hyprpaper.conf`);
this.getWallpaper().then((wall) => {
if(wall?.trim()) this.#wallpaper = wall.trim();
});
let tmeout: (AstalIO.Time|undefined) = undefined;
this.#monitor = monitorFile(this.#hyprpaperFile.get_path()!, (_, event) => {
if(event !== Gio.FileMonitorEvent.CHANGED && event !== Gio.FileMonitorEvent.CREATED &&
event !== Gio.FileMonitorEvent.MOVED_IN)
return;
if(tmeout) return;
else tmeout = timeout(1500, () => tmeout = undefined);
if(this.#ignoreWatch) {
this.#ignoreWatch = false;
return;
}
const [ loaded, text ] = this.#hyprpaperFile.load_contents(null);
if(!loaded)
console.error("Wallpaper: Couldn't read changes inside the hyprpaper file!");
const content = new TextDecoder().decode(text);
if(content) {
let setWall: boolean = true;
for(const line of content.split('\n')) {
if(line.trim().startsWith('#'))
continue;
const lineSplit = line.split('=');
const key = lineSplit[0].trim(),
value = lineSplit.filter((_, i) => i !== 0).join('=').trim();
switch(key) {
case "splash":
this.splash = (/(yes|true|on|enable|enabled|1).*/.test(value)) ? true : false;
break;
case "wallpaper":
if(this.#wallpaper !== value && setWall) {
this.setWallpaper(value, false);
setWall = false; // wallpaper already set
}
break;
}
}
}
});
}
vfunc_dispose(): void {
this.#monitor.cancel();
}
public static getDefault(): Wallpaper {
if(!this.instance)
this.instance = new Wallpaper();
return this.instance;
}
private writeChanges(): void {
this.#ignoreWatch = true; // tell monitor to ignore file replace
this.#hyprpaperFile.replace_async(null, false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
GLib.PRIORITY_DEFAULT, null, (_, result) => {
const res = this.#hyprpaperFile.replace_finish(result);
if(res) {
// success
this.#ignoreWatch = true; // tell monitor to ignore this change
res.write_bytes_async(new TextEncoder().encode(`# This file was automatically generated by color-shell
preload = ${this.#wallpaper}
splash = ${this.#splash}
wallpaper = , ${this.#wallpaper}`.split('\n').map(str => str.trimStart()).join('\n')),
GLib.PRIORITY_DEFAULT, null, (_, asyncRes) => {
if(_!.write_finish(asyncRes)) res.flush(null);
res.close(null);
}
);
return;
}
console.error(`Wallpaper: an error occurred when trying to replace the hyprpaper file`);
}
);
}
public async getWallpaper(): Promise<string|undefined> {
return await execAsync("sh -c \"hyprctl hyprpaper listactive | tail -n 1\"").then(stdout => {
const loaded: (string|undefined) = stdout.split('=')[1]?.trim();
if(!loaded)
console.warn(`Wallpaper: Couldn't get wallpaper. There is(are) no loaded wallpaper(s)`);
return loaded;
}).catch((err: Gio.IOErrorEnum) => {
console.error(`Wallpaper: Couldn't get wallpaper. Stderr: \n${err.message ? `${err.message} /` : ""} Stack: \n ${err.stack}`);
return undefined;
});
}
public reloadColors(): void {
execAsync(`wal -t --cols16 darken -i "${this.#wallpaper}"`).then(() => {
console.log("Wallpaper: reloaded shell colors");
}).catch(r => {
console.error(`Wallpaper: Couldn't update shell colors. Stderr: ${r}`);
});
}
public setWallpaper(path: string|Gio.File, write: boolean = true): void {
execAsync("hyprctl hyprpaper unload all").then(() =>
execAsync(`hyprctl hyprpaper preload ${path}`).then(() =>
execAsync(`hyprctl hyprpaper wallpaper ${path}`).then(() => {
this.#wallpaper = (typeof path === "string") ? path : path.get_path()!;
this.reloadColors();
write && this.writeChanges();
}).catch(r => {
console.error(`Wallpaper: Couldn't set wallpaper. Stderr: ${r}`);
})
).catch(r => {
console.error(`Wallpaper: Couldn't preload image. Stderr: ${r}`);
})
).catch(r => {
console.error(`Wallpaper: Couldn't unload images from memory. Stderr: ${r}`);
});
}
public async pickWallpaper(): Promise<string|undefined> {
return (await execAsync(`zenity --file-selection`).then(wall => {
if(!wall.trim()) return undefined;
this.setWallpaper(wall);
return wall;
}).catch(r => {
console.error(`Wallpaper: Couldn't pick wallpaper, is \`zenity\` installed? Stderr: ${r}`);
return undefined;
}));
}
}
+314
View File
@@ -0,0 +1,314 @@
@use "sass:color";
@use "./style/wal";
@use "./style/mixins";
@use "./style/functions";
@use "./style/colors";
@use "./style/bar";
@use "./style/osd";
@use "./style/control-center";
@use "./style/center-window";
@use "./style/float-notifications";
@use "./style/logout-menu";
@use "./style/apps-window";
@use "./style/runner";
* {
@include mixins.reset-props;
}
entry {
background: colors.$bg-primary;
padding: 10px 9px;
border-radius: 12px;
&:focus {
box-shadow: inset 0 0 0 2px colors.$bg-secondary;
}
& image.left {
margin-right: 6px;
}
}
.custom-dialog-container {
background: colors.$bg-translucent;
padding: 18px;
border-radius: 24px;
& .title {
font-size: 21px;
font-weight: 700;
margin-bottom: 10px;
}
& .text {
font-size: 16px;
font-weight: 400;
}
& .options {
& button {
@include mixins.button-reactive-primary;
background: colors.$bg-primary;
border-radius: 12px;
padding: 9px 6px;
& label {
font-size: 16px;
font-weight: 600;
}
margin: {
left: 4px;
right: 4px;
};
}
}
&.entry-popup-box entry {
margin-bottom: 10px;
&.password {
font-size: 14px;
font-family: "Adwaita Mono", "Cantarell Mono", "Noto Sans Mono", monospace;
font-weight: 400;
}
}
}
.notification {
background: colors.$bg-translucent-secondary;
border-radius: 16px;
& > .top {
padding: 8px;
padding-bottom: 0;
& .app-icon {
margin-right: 6px;
}
& .app-name {
font-size: 12px;
}
& label.time {
font-size: 11px;
font-weight: 500;
color: colors.$fg-disabled;
margin-right: 6px;
}
& button.close {
padding: 2px;
border-radius: 8px;
&:hover {
background: colors.$bg-secondary;
}
}
& icon.close {
font-size: 16px;
}
}
& .content {
padding: 6px;
padding-top: 0;
& .image {
$size: 78px;
min-width: $size;
min-height: $size;
background-size: cover;
background-position: center;
margin: 6px;
border-radius: 8px;
}
& .summary {
font-size: 17.3px;
font-weight: 700;
margin-bottom: 4px;
}
& .body {
font-size: 14.5px;
font-weight: 400;
}
}
& .actions {
padding: 6px;
& button.action {
border-radius: 4px;
background: colors.$bg-secondary;
padding: 6px;
& label {
font-size: 14px;
font-weight: 600;
}
&:hover {
background: colors.$bg-tertiary;
}
&:first-child {
border-top-left-radius: 12px;
border-bottom-left-radius: 12px;
}
&:last-child {
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
}
}
}
}
tooltip > box {
padding: 7px 8px;
margin: 12px;
margin-top: 0;
border-radius: 12px;
background: rgba(colors.$bg-primary, .98);
box-shadow: 0 5px 6px 1px colors.$bg-translucent-primary;
& label {
font-size: 13.1px;
font-weight: 500;
color: colors.$fg-primary;
}
}
popover.menu contents {
background: wal.$background;
border-radius: 14px;
padding: 4px;
& viewport > stack > * > * > * {
& > separator {
min-height: .5px;
margin: 3px 2px;
background: rgba(colors.$fg-disabled, .1);
}
& > *:not(separator) > * {
padding: 6px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
&:hover, &:focus {
background: wal.$color1;
}
}
}
}
.button-row {
& > button {
$active-radius: 8px;
$corner-radius: calc($active-radius + 2px);
background: colors.$bg-secondary;
margin: 0 1px;
padding: 4px 6px;
border-radius: 2px;
transition: 120ms linear;
&:hover {
background: colors.$bg-tertiary;
}
&:active {
border-radius: $active-radius;
}
&:first-child {
margin-left: 0;
&:not(:active) {
border-top-left-radius: $corner-radius;
border-bottom-left-radius: $corner-radius;
}
}
&:last-child {
margin-right: 0;
&:not(:active) {
border-top-right-radius: $corner-radius;
border-bottom-right-radius: $corner-radius;
}
}
}
}
selection {
background: colors.$bg-tertiary;
}
trough {
background: color.adjust($color: colors.$bg-primary, $lightness: -5%);
border-radius: 8px;
margin: 2px 0;
}
trough highlight {
background: wal.$color1;
min-height: .9em;
}
trough slider {
border-radius: 50%;
margin: -4px 0;
background: wal.$foreground;
min-width: 1.2em;
min-height: 1.2em;
}
scrollbar trough {
@include mixins.reset-props;
background: colors.$bg-translucent;
border-radius: 8px;
& slider {
@include mixins.reset-props;
min-width: .45em;
background: colors.$bg-tertiary;
border-radius: 12px;
&:hover, &:active, &:focus {
margin: 2px;
}
}
&:hover, &:active, &:focus {
padding: 2px;
}
}
scale {
& marks mark {
& indicator {
color: colors.$fg-disabled;
min-width: 1px;
min-height: 6px;
}
& label {
font-size: 11px;
font-weight: 400;
color: colors.$fg-disabled;
}
}
}
+50
View File
@@ -0,0 +1,50 @@
@use "sass:color";
@use "./colors";
.apps-window .popup-window-container {
$radius: 48px;
padding: 28px;
background: colors.$bg-translucent;
border-radius: $radius $radius 0 0;
& entry {
background: transparent;
margin-bottom: 32px;
min-width: 450px;
padding: 14px;
border-radius: 18px;
&:focus-within {
box-shadow: inset 0 0 0 2px colors.$bg-tertiary;
background: rgba(colors.$bg-primary, .2);
}
}
& flowbox {
padding: 16px 24px;
& > flowboxchild {
& > button {
padding: 10px;
border-radius: 24px;
& image {
-gtk-icon-size: 64px;
}
& label {
margin-top: 24px;
text-shadow: 1px 1px 1px rgba(colors.$bg-primary, .2);
font-weight: 500;
}
}
&:focus > button,
&:selected > button,
& > button:hover {
background-color: rgba($color: colors.$bg-secondary, $alpha: .5);
}
}
}
}
+207
View File
@@ -0,0 +1,207 @@
@use "sass:color";
@use "./mixins";
@use "./colors";
$radius: 18px;
$padding: 4px;
$color-hover: colors.$bg-primary;
@mixin button-reactivity {
&:active {
box-shadow: 0 0 0 2px $color-hover;
}
}
.bar-container {
padding: 6px;
padding-bottom: 0px;
label {
@include mixins.reset-props;
font-size: 12px;
font-weight: 600;
}
// Style widget groups
& > .bar-centerbox > * {
background: rgba(colors.$bg-translucent, .6);
border-radius: $radius;
padding: 0 $padding;
& > box:not(.workspaces-row):not(.tray):not(.focused-client):not(.media),
& > button {
@include button-reactivity;
border-radius: calc($radius - $padding);
margin: $padding 0;
padding: 0 8px;
&:hover {
background: $color-hover;
}
}
}
.workspaces-row {
padding: 4px;
& .workspace {
transition: 80ms linear;
margin: 3px 0;
border-radius: 16px;
min-width: 14px;
padding: 0 6px;
background: colors.$bg-tertiary;
&:active {
border-radius: 10px;
}
& label.id {
font-weight: 600;
margin-right: 4px;
opacity: 0;
}
&.focus {
background: colors.$fg-primary;
min-width: 32px;
& label.id {
opacity: 0;
color: colors.$fg-light;
margin-right: 0;
}
}
& icon {
font-size: 16px;
}
&.show label.id {
opacity: 1;
}
&:hover:not(.last-client-icon):not(.focus) {
background: color.scale($color: colors.$bg-tertiary, $lightness: 10%);
}
}
}
.focused-client {
padding: 0 6px;
& image {
margin-right: 6px;
-gtk-icon-size: 18px;
}
& .text-content {
& .class {
font-size: 9px;
font-family: monospace;
font-weight: 600;
color: colors.$fg-disabled;
margin-top: 0px;
}
& .title {
font-size: 12px;
font-weight: 500;
margin-top: -2px;
}
}
}
.clock.open {
background: colors.$bg-primary;
}
.media {
$spacing: 5px;
$hover-color: color.scale($color: colors.$bg-primary, $lightness: 15%);
background: colors.$bg-primary;
border-radius: calc($radius - $padding);
margin: $padding 0;
padding: 0 calc($padding + 3px);
& image.player-icon {
-gtk-icon-size: 14px;
margin-right: $spacing;
}
& .media-controls {
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
padding: 4px 0;
margin-left: $spacing;
& > button image {
margin: 0;
-gtk-icon-size: 10px;
}
}
&:hover {
background: $hover-color;
}
&:active {
box-shadow: 0 0 0 2px $hover-color;
}
}
.tray {
padding: 0 6px;
& .item {
all: unset;
}
}
.status {
@include mixins.reset-props;
&.open {
background: colors.$bg-primary;
}
& image {
-gtk-icon-size: 14px;
}
& revealer > box {
background: rgba($color: colors.$bg-tertiary, $alpha: .7);
border-radius: 12px;
margin: 4px 0;
margin-left: 5px;
padding: 2px 6px;
}
& .status-icons {
padding-left: 4px;
& image.notification-count {
-gtk-icon-size: 6px;
margin-top: -14px;
}
}
}
.apps {
min-width: 18px;
& image {
transition: 120ms linear;
-gtk-icon-size: 14px;
}
&.open {
background: colors.$bg-primary;
}
}
}
+163
View File
@@ -0,0 +1,163 @@
@use "sass:color";
@use "./wal";
@use "./colors";
@use "./mixins";
.popup-window.center-window .center-window-container {
background: colors.$bg-translucent;
border-radius: 24px;
padding: 12px;
& .big-media {
margin-top: 9px;
& .image {
background-size: cover;
background-position: center center;
border-radius: 10px;
}
& .info {
padding: {
top: 4px;
bottom: 6px;
};
& .title {
font-size: 16px;
font-weight: 700;
}
& .artist {
font-size: 14px;
font-weight: 600;
color: colors.$fg-disabled;
}
}
& .player-select {
@include mixins.button-reactive-secondary;
border-radius: 14px;
& revealer label {
margin-left: 4px;
}
margin-right: 2px;
margin-bottom: 2px;
image.arrow {
-gtk-icon-size: 12px;
margin-left: 6px;
margin-top: 1px;
}
}
& popover contents {
background: colors.$bg-primary;
border-radius: 16px;
padding: 4px;
& button {
padding: 6px;
border-radius: 12px;
& image {
margin-right: 4px;
}
&:hover {
background: colors.$bg-secondary;
}
}
}
& slider {
all: unset;
opacity: 0;
min-height: .6em;
}
& trough {
border-radius: 4px;
}
& trough highlight {
min-height: .65em;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
}
& .bottom {
& .controls {
margin-top: 9px;
& button {
padding: 7px;
& label {
font-size: 9px;
}
}
}
& .elapsed,
& .length {
font-size: 12px;
color: colors.$fg-disabled;
}
}
}
& .left .datetime {
padding-bottom: 10px;
& .time {
font-size: 28px;
font-weight: 800;
}
& .date {
font-size: 14px;
font-weight: 500;
color: colors.$fg-disabled;
}
}
& calendar.view {
$border-radius: 14px;
font-weight: 600;
background: colors.$bg-primary;
border-radius: $border-radius;
& header {
background: colors.$bg-secondary;
padding: 6px;
border-top-left-radius: $border-radius;
border-top-right-radius: $border-radius;
}
& grid {
margin: 4px;
label.day-number {
min-height: 22px;
}
}
& button {
transition: 80ms linear;
border-radius: 6px;
&:hover {
background-color: colors.$bg-tertiary;
}
}
& label:selected {
background: colors.$bg-secondary;
border-radius: 6px;
}
}
}
+13
View File
@@ -0,0 +1,13 @@
@use "sass:color";
@use "./wal";
$bg-primary: color.adjust($color: wal.$color1, $lightness: -34%);
$bg-secondary: color.adjust($color: wal.$color1, $lightness: -16%);
$bg-tertiary: color.adjust($color: $bg-secondary, $lightness: 10%);
$bg-light: wal.$foreground;
$bg-translucent: color.change($color: $bg-primary, $alpha: 75%);
$bg-translucent-primary: $bg-translucent;
$bg-translucent-secondary: color.change($color: $bg-translucent, $alpha: 78%);
$fg-primary: wal.$foreground;
$fg-light: $bg-primary;
$fg-disabled: color.adjust($color: wal.$foreground, $lightness: -11%);
+295
View File
@@ -0,0 +1,295 @@
@use "sass:color";
@use "./wal";
@use "./colors";
@use "./functions" as funs;
@use "./mixins";
.control-center-container {
@include mixins.reset-props;
background: colors.$bg-translucent;
border-radius: 28px;
padding: 20px;
& > * {
margin: 9px 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
& button:focus-visible {
box-shadow: inset 0 0 0 1px colors.$fg-primary;
}
& .quickactions {
margin-bottom: .8em;
& .hostname {
font-size: 15px;
font-weight: 600;
}
& > box:not(.button-row) image {
-gtk-icon-size: 12px;
color: colors.$fg-disabled;
margin-right: 3px;
}
& .uptime {
font-size: 10.1px;
font-family: "Symbols Nerd Font Mono";
color: colors.$fg-disabled;
}
& .button-row {
& button {
padding: 7px;
margin: {
top: 2px;
bottom: 2px;
};
}
}
}
& .sliders {
icon {
font-size: 16px;
}
button {
padding: 4px;
border-radius: 16px;
icon {
font-size: 14px;
}
&:hover {
background: rgba(colors.$fg-primary, .2);
}
}
& .page .content {
& label.name {
font-size: 14px;
font-weight: 500;
}
& trough {
margin-right: 10px;
}
& label.sub-header {
margin-top: 6px;
}
}
}
& #page {
transition: 120ms linear;
background: colors.$bg-secondary;
padding: 14px;
border-radius: 24px;
& .header {
margin-bottom: 12px;
& .top .title {
font-size: 20px;
font-weight: 600;
}
& .description {
font-size: 12px;
font-weight: 500;
color: colors.$fg-disabled;
}
}
& .sub-header {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
& .page-button, .extra-buttons {
& button {
padding: 6px;
border-radius: 12px;
&.selected {
background: colors.$bg-tertiary;
}
&:hover {
background: rgba(colors.$fg-primary, .1);
}
&:active {
border-radius: 10px;
}
}
& label.title {
font-size: 14px;
}
& label.description {
font-size: 10px;
font-weight: 400;
color: colors.$fg-disabled;
}
& icon {
font-size: 16px;
}
}
& .bottom-buttons button {
& label.title {
font-size: 14px;
font-weight: 500;
}
& label.description {
font-size: 10px;
margin-top: -1px;
font-weight: 400;
color: colors.$fg-disabled;
}
}
& .extra-buttons {
margin-left: 2px;
button {
border-radius: 10px;
&:active {
border-radius: 8px;
}
}
}
}
}
.tiles-container {
@include mixins.reset-props;
& > flowbox {
& > flowboxchild .tile {
$radius: 18px;
&:not(.toggled) > .toggle-button,
&:not(.toggled) > button.more {
background: colors.$bg-primary;
}
&:not(.toggled) > .toggle-button:hover,
&:not(.toggled) > button.more:hover {
background: color.scale($color: colors.$bg-primary, $lightness: 10%);
}
&.toggled .toggle-button:hover,
&.toggled button.more:hover {
background: colors.$bg-tertiary;
}
&.toggled > .toggle-button,
&.toggled > button.more {
background: colors.$bg-secondary;
}
&.has-more > .toggle-button,
&.has-more > button.toggle-button:active {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
& > button.toggle-button {
border-radius: $radius;
&:active {
border-radius: calc($radius - 4px);
}
& .content {
padding: 8px;
& > .icon {
margin-right: 6px;
}
& > .text {
& > .title {
font-weight: 600;
font-size: 15.1px;
}
& > .description {
font-size: 12px;
color: colors.$fg-disabled;
font-weight: 400;
}
}
}
}
& > button.more {
border-top-right-radius: $radius;
border-bottom-right-radius: $radius;
&:active {
border-top-right-radius: calc($radius - 4px);
border-bottom-right-radius: calc($radius - 4px);
}
& label {
font-size: 16px;
}
}
}
}
}
.tile-pages #page {
margin-top: 10px;
}
box.notif-history {
background: colors.$bg-translucent;
border-radius: 24px;
padding: 18px;
transition: 120ms linear;
&.hide {
opacity: 0;
}
& .notifications {
& .notification {
background: colors.$bg-translucent-primary;
padding: 3px;
}
}
& .button-row {
margin-top: 12px;
& button {
@include mixins.button-reactive-secondary;
padding: 7px;
& label {
font-size: 12px;
font-weight: 600;
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
@use "./colors";
@use "./mixins";
.floating-notifications-container {
padding: 16px;
& .float-notification {
$radius: 18px;
box-shadow: 0 0 8px 1px colors.$bg-translucent;
border-radius: $radius;
& .notification {
padding: 4px;
border-radius: $radius;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
@use "sass:color";
/**
* GTK3 only supports sRGB color space, unfortunately
*/
@function toRGB($color) {
@return rgba(
color.channel($color, "red"),
color.channel($color, "green"),
color.channel($color, "blue"),
color.alpha($color)
);
}
+52
View File
@@ -0,0 +1,52 @@
@use "./colors";
.logout-menu-container {
background: rgba($color: colors.$bg-translucent-primary, $alpha: .4);
.top {
.time {
font-size: 128px;
font-weight: 900;
color: colors.$fg-primary;
text-shadow: 1px 1px 2px colors.$bg-translucent;
}
.date {
font-size: 24px;
font-weight: 500;
text-shadow: 1px 1px 2px colors.$bg-translucent;
}
}
.button-row {
$radius: 32px;
all: unset;
margin: 0 150px;
& > button {
& image {
-gtk-icon-size: 128px;
}
&:focus-visible {
box-shadow: inset 0 0 0 5px colors.$fg-primary;
}
&:active {
border-radius: calc($radius - 6px);
}
margin: 0 4px;
border-radius: 6px;
&:first-child:not(:active) {
border-top-left-radius: $radius;
border-bottom-left-radius: $radius;
}
&:last-child:not(:active) {
border-top-right-radius: $radius;
border-bottom-right-radius: $radius;
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
@use "sass:color";
@use "./colors";
@mixin reset-props {
all: unset;
transition: 120ms linear;
font-family: "Adwaita Sans", "Cantarell", "Noto Sans",
"Noto Sans CJK JP", "Noto Sans CJK KR",
"Noto Sans CJK HK", "Noto Sans CJK SC",
"Noto Sans CJK TC", sans-serif,
"Symbols Nerd Font Mono";
color: colors.$fg-primary;
}
@mixin button-reactive-primary {
background: colors.$bg-primary;
border-radius: 14px;
padding: 6px;
&:hover {
background: colors.$bg-secondary;
}
&:active {
border-radius: 10px;
}
}
@mixin button-reactive-secondary {
background: colors.$bg-secondary;
border-radius: 12px;
padding: 6px;
&:hover {
background: colors.$bg-tertiary;
}
&:active {
border-radius: 9px;
}
}
+40
View File
@@ -0,0 +1,40 @@
@use "sass:color";
@use "colors";
.osd {
background: rgba(colors.$bg-translucent-secondary, .6);
padding: 14px 14px;
border-radius: 24px;
min-width: 180px;
.icon {
margin-right: 10px;
-gtk-icon-size: 24px;
}
.volume {
margin-top: -6px;
.device {
margin-bottom: 6px;
font-size: 14px;
font-weight: 600;
}
levelbar trough block {
border-radius: 3px;
background: colors.$bg-primary;
&.filled {
min-height: 8px;
background: colors.$bg-secondary;
}
}
.value {
font-size: 11px;
font-weight: 400;
padding: 0 4px;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
@use "./colors";
.runner.main {
$radius: 24px;
background: rgba($color: colors.$bg-primary, $alpha: .8);
border-radius: $radius;
box-shadow: inset 0 0 0 1px colors.$bg-secondary,
0 0 8px 1px colors.$bg-translucent;
padding: 4px;
& entry {
transition: 80ms ease-in;
min-height: 1.6em;
padding: 14px;
border-radius: inherit;
background: none;
&:focus {
box-shadow: none;
}
& image {
margin-right: 6px;
-gtk-icon-size: 16px;
}
}
& scrolledwindow {
margin: 6px;
}
& list {
& .result {
padding: 10px;
background: colors.$bg-primary;
margin: 2px 0;
border-radius: 14px;
& image {
-gtk-icon-size: 28px;
margin-right: 6px;
}
& .title {
font-weight: 500;
font-size: 16px;
}
& .description {
font-size: 12px;
color: colors.$fg-disabled;
}
}
& > *:selected .result,
& > *:active .result,
& > *:hover .result {
background: colors.$bg-secondary;
}
& > *:first-child {
margin-top: 12px;
}
&:last-child {
margin-bottom: 0;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
// SCSS Variables
// Generated by 'wal'
$wallpaper: "/home/joaov/wallpapers/Anime Girl Drawing Sofa.jpg";
// Special
$background: #0e1721;
$foreground: #c2c5c7;
$cursor: #c2c5c7;
// Colors
$color0: #0e1721;
$color1: #393f39;
$color2: #4d4c46;
$color3: #824f36;
$color4: #6c574a;
$color5: #816956;
$color6: #4e767a;
$color7: #91959b;
$color8: #5d6772;
$color9: #4C544C;
$color10: #67665E;
$color11: #AE6A49;
$color12: #917463;
$color13: #AC8C73;
$color14: #689EA3;
$color15: #c2c5c7;
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"allowJs": false,
"checkJs": true,
"module": "es2022",
"target": "es2020",
"lib": ["ES2024"],
"moduleResolution": "bundler",
"skipLibCheck": true,
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "ags/gtk4"
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Accessor } from "ags";
import { tr } from "../i18n/intl";
import { CustomDialog } from "./CustomDialog";
import { Astal } from "ags/gtk4";
export type AskPopupProps = {
title?: string | Accessor<string>;
text: string | Accessor<string>;
cancelText?: string;
acceptText?: string;
onAccept?: () => void;
onCancel?: () => void;
};
/**
* A Popup Widget that asks yes or no to a defined promt.
* Runs onAccept() when user accepts, or else onDecline() when
* user doesn't accept / closes window.
* This window isn't usually registered in this shell windowing
* system.
*/
export function AskPopup(props: AskPopupProps): Astal.Window {
let accepted: boolean = false;
return <CustomDialog namespace={"ask-popup"} widthRequest={400} heightRequest={250}
title={props.title ?? tr("ask_popup.title")} text={props.text}
onFinish={() => !accepted && props.onCancel?.()} options={[
{ text: props.cancelText ?? tr("cancel") },
{
text: props.acceptText ?? tr("accept"),
onClick: () => {
accepted = true;
props.onAccept?.();
}
}
]} /> as Astal.Window;
}
+90
View File
@@ -0,0 +1,90 @@
import { Accessor } from "ags";
import { Astal, Gdk, Gtk } from "ags/gtk4";
import GObject from "gi://GObject?version=2.0";
const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor;
export type BackgroundWindowProps = {
/** GtkWindow Layer */
layer?: Astal.Layer | Accessor<Astal.Layer>;
/** Monitor number where the window should open */
monitor: number | Accessor<number>;
/** Custom stylesheet used in the window. default: `background: rgba(0, 0, 0, .2)` */
css?: string | Accessor<string>;
/* Function that is called when the user releases a key in the keyboard on the window
* The `Escape` key is not passed to this function */
actionKeyPressed?: (window: Astal.Window, keyval: number, keycode: number) => void;
/** Function that is called when the user triggers a mouse-click or escape action on the window */
actionFired?: (window: Astal.Window) => void;
/** Function that is called when the user clicks on the window with primary mouse button */
actionClickPrimary?: (window: Astal.Window) => void;
/** Function that is called when the user clicks on the window with secodary mouse button */
actionClickSecondary?: (window: Astal.Window) => void;
onCloseRequest?: (window: Astal.Window) => void;
keymode?: Astal.Keymode;
exclusivity?: Astal.Exclusivity;
/** attach this window as a background for another window
* background-window will close when the attached window triggers ::close-request) */
attach?: Astal.Window;
};
/** Creates a fullscreen GtkWindow that is used for making
* the user focus on the content after this window(e.g.: AskPopup,
* Authentication Window(futurely) or any PopupWindow)
*
* @param props Properties for background-window
*
* @returns The generated background-window
*/
export function BackgroundWindow(props: BackgroundWindowProps): Astal.Window {
const conns: Map<GObject.Object, number> = new Map();
return <Astal.Window namespace={"background-window"} monitor={props.monitor} visible
layer={props.layer ?? Astal.Layer.OVERLAY} keymode={props.keymode ?? Astal.Keymode.EXCLUSIVE}
onCloseRequest={props.onCloseRequest} exclusivity={props.exclusivity ?? Astal.Exclusivity.IGNORE}
anchor={TOP | LEFT | BOTTOM | RIGHT} css={props.css ?? "background: rgba(0, 0, 0, .2);"}
$={(self) => {
const gestureClick = Gtk.GestureClick.new(),
eventControllerKey = Gtk.EventControllerKey.new();
gestureClick.set_button(0);
self.add_controller(gestureClick);
self.add_controller(eventControllerKey);
conns.set(eventControllerKey, eventControllerKey.connect("key-released",
(_, keyval, keycode) => {
if(keyval === Gdk.KEY_Escape) {
props.actionFired?.(self);
return;
}
props.actionKeyPressed?.(self, keyval, keycode);
}
));
conns.set(gestureClick, gestureClick.connect("released", (gesture) => {
if(gesture.get_current_button() === Gdk.BUTTON_PRIMARY) {
props.actionClickPrimary?.(self);
return;
}
if(gesture.get_current_button() === Gdk.BUTTON_SECONDARY) {
props.actionClickSecondary?.(self);
return;
}
props.actionFired?.(self);
}));
props.attach &&
conns.set(props.attach, (props.attach as Gtk.Widget).connect("close-request", () =>
self.close()
));
conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) =>
obj.disconnect(id))));
}} /> as Astal.Window;
}
+76
View File
@@ -0,0 +1,76 @@
import { Astal, Gtk } from "ags/gtk4";
import { Windows } from "../windows";
import { PopupWindow } from "./PopupWindow";
import { Separator } from "./Separator";
import { tr } from "../i18n/intl";
import { Accessor } from "ags";
import { transformWidget, variableToBoolean, WidgetNodeType } from "../scripts/utils";
export type CustomDialogProps = {
namespace?: string | Accessor<string>;
className?: string | Accessor<string>;
cssBackground?: string;
title?: string | Accessor<string>;
text?: string | Accessor<string>;
heightRequest?: number | Accessor<number>;
widthRequest?: number | Accessor<number>;
childOrientation?: Gtk.Orientation | Accessor<Gtk.Orientation>;
children?: WidgetNodeType;
onFinish?: () => void;
options?: Array<CustomDialogOption> | Accessor<Array<CustomDialogOption>>;
optionsOrientation?: Gtk.Orientation | Accessor<Gtk.Orientation>;
};
export interface CustomDialogOption {
onClick?: () => void;
text: string | Accessor<string>;
closeOnClick?: boolean | Accessor<boolean>;
}
function CustomDialogOption({closeOnClick = true, ...props}: CustomDialogOption & {
dialog: Astal.Window;
}) {
return <Gtk.Button class="option" hexpand label={props.text}
onClicked={() => {
props.onClick?.();
closeOnClick &&
props.dialog?.close();
}}
/>
}
export function CustomDialog({ options = [{ text: tr("accept") }], ...props}: CustomDialogProps) {
return Windows.getDefault().createWindowForFocusedMonitor((mon) => {
const popup = <PopupWindow namespace={props.namespace ?? "custom-dialog"} monitor={mon}
cssBackgroundWindow={props.cssBackground ?? "background: rgba(0, 0, 0, .3);"}
exclusivity={Astal.Exclusivity.IGNORE} layer={Astal.Layer.OVERLAY}
halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} actionClosed={() => props.onFinish?.()}
widthRequest={props.widthRequest ?? 400} heightRequest={props.heightRequest ?? 220}>
<Gtk.Box class={props.className ?? "custom-dialog-container"}
orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"title"} visible={variableToBoolean(props.title)} label={props.title} />
<Gtk.Label class={"text"} visible={variableToBoolean(props.text)} label={props.text}
vexpand valign={Gtk.Align.START} />
<Gtk.Box class={"custom-children custom-child"} visible={variableToBoolean(props.children)}
orientation={props.childOrientation ?? Gtk.Orientation.VERTICAL}>
{transformWidget(props.children, (child) => child as JSX.Element)}
</Gtk.Box>
<Separator alpha={.2} visible={options && options.length > 0}
spacing={8} orientation={Gtk.Orientation.VERTICAL} />
</Gtk.Box>
</PopupWindow> as Astal.Window;
(popup.get_child()!.get_first_child()!.get_first_child() as Gtk.Box).append(
<Gtk.Box class={"options"} orientation={props.optionsOrientation ?? Gtk.Orientation.HORIZONTAL}
hexpand={true} heightRequest={38} homogeneous={true}>
{transformWidget(options, (props) => <CustomDialogOption {...props} dialog={popup} />)}
</Gtk.Box> as Gtk.Box
);
return popup;
})();
}
+61
View File
@@ -0,0 +1,61 @@
import { Accessor } from "ags";
import { tr } from "../i18n/intl";
import { CustomDialog } from "./CustomDialog";
import { Astal, Gtk } from "ags/gtk4";
export type EntryPopupProps = {
title: string | Accessor<string>;
text?: string | Accessor<string>;
cancelText?: string | Accessor<string>;
acceptText?: string | Accessor<string>;
closeOnAccept?: boolean;
entryPlaceholder?: string | Accessor<string>;
onAccept: (userInput: string) => void;
onCancel?: () => void;
onFinish?: () => void;
isPassword?: boolean | Accessor<string>;
};
export function EntryPopup(props: EntryPopupProps): Astal.Window {
props.closeOnAccept = props.closeOnAccept ?? true;
let entered: boolean = false;
function onActivate(entry: Gtk.Entry|Gtk.PasswordEntry) {
props.closeOnAccept && window.close();
entered = true;
props.onAccept(entry.text);
entry.text = "";
}
const entry = props.isPassword ?
<Gtk.PasswordEntry class={"password"} xalign={.5}
placeholderText={props.entryPlaceholder}
onActivate={onActivate}
/> as Gtk.PasswordEntry
: <Gtk.Entry xalign={.5} placeholderText={props.entryPlaceholder}
onActivate={onActivate} /> as Gtk.Entry;
const window = <CustomDialog namespace={"entry-popup"} widthRequest={420}
heightRequest={220} title={props.title} text={props.text}
options={[
{
text: props.cancelText ?? tr("cancel"),
onClick: props.onCancel
},
{
text: props.acceptText ?? tr("accept"),
closeOnClick: props.closeOnAccept,
onClick: () => {
entered = true;
props.onAccept(entry.text);
entry.text = "";
}
}
]} onFinish={() => {
!entered && props.onCancel?.()
props.onFinish?.();
}}
/> as Astal.Window;
return window;
}
+124
View File
@@ -0,0 +1,124 @@
import { Gdk, Gtk } from "ags/gtk4";
import { Separator } from "./Separator";
import { HistoryNotification, Notifications } from "../scripts/notifications";
import { getAppIcon, getSymbolicIcon } from "../scripts/apps";
import { onCleanup } from "ags";
import AstalNotifd from "gi://AstalNotifd";
import Pango from "gi://Pango?version=1.0";
import GLib from "gi://GLib?version=2.0";
import GObject from "gi://GObject?version=2.0";
import { escapeUnintendedMarkup, pathToURI } from "../scripts/utils";
function getNotificationImage(notif: AstalNotifd.Notification|HistoryNotification): (string|undefined) {
const img = notif.image || notif.appIcon;
if(!img || !img.includes('/'))
return undefined;
return pathToURI(img);
}
export function NotificationWidget({ notification, actionClicked, holdOnHover, showTime, actionClose }: {
notification: AstalNotifd.Notification|number|HistoryNotification;
actionClicked?: (notif: AstalNotifd.Notification|HistoryNotification) => void;
actionClose?: (notif: AstalNotifd.Notification|HistoryNotification) => void;
holdOnHover?: boolean;
showTime?: boolean; // It's showTime :speaking_head: :boom: :bangbang:
}): Gtk.Widget {
notification = (typeof notification === "number") ?
AstalNotifd.get_default().get_notification(notification)
: notification;
const conns: Map<GObject.Object, Array<number>> = new Map();
onCleanup(() =>
conns.forEach((ids, obj) => ids.forEach(id => obj.disconnect(id))));
return <Gtk.Box hexpand class={`notification ${
Notifications.getDefault().getUrgencyString(notification.urgency)
}`} orientation={Gtk.Orientation.VERTICAL} spacing={5}
$={(self) => {
const eventControllerMotion = Gtk.EventControllerMotion.new(),
gestureClick = Gtk.GestureClick.new();
self.add_controller(eventControllerMotion);
self.add_controller(gestureClick);
conns.set(eventControllerMotion, [
eventControllerMotion.connect("enter", () =>
holdOnHover && Notifications.getDefault().holdNotification(notification.id)),
eventControllerMotion.connect("leave", () =>
holdOnHover && notification && Notifications.getDefault().removeNotification(notification.id))
]);
conns.set(gestureClick, [
gestureClick.connect("released", (gesture) => {
gesture.get_current_button() === Gdk.BUTTON_PRIMARY &&
actionClicked?.(notification);
})
]);
}}>
<Gtk.Box class={"top"} hexpand>
<Gtk.Image class="app-icon" $={(self) => {
const icon = getSymbolicIcon(notification.appIcon ?? notification.appName) ??
getSymbolicIcon(notification.appName) ?? getAppIcon(notification.appName);
if(icon) {
self.set_from_icon_name(icon);
return;
}
self.set_visible(false);
}} />
<Gtk.Label class={"app-name"} halign={Gtk.Align.START} hexpand={true}
label={notification.appName || "Application"} />
<Gtk.Label class={"time"} visible={showTime} xalign={1}
label={GLib.DateTime.new_from_unix_local(notification.time).format("%H:%M") ?? ""} />
<Gtk.Button halign={Gtk.Align.END} iconName={"window-close-symbolic"}
class={"close"} onClicked={() => actionClose?.(notification)}/>
</Gtk.Box>
<Separator alpha={.1} orientation={Gtk.Orientation.VERTICAL} />
<Gtk.Box class={"content"}>
{getNotificationImage(notification) &&
<Gtk.Box class={"image"} hexpand={false} vexpand={false}
css={`background-image: url("${getNotificationImage(notification)}");`}
/>
}
<Gtk.Box class={"text"} orientation={Gtk.Orientation.VERTICAL}
vexpand={true}>
<Gtk.Label class={"summary"} useMarkup={true} hexpand xalign={0}
vexpand={false} ellipsize={Pango.EllipsizeMode.END} label={
escapeUnintendedMarkup(notification.summary)}
/>
<Gtk.Label class={"body"} useMarkup={true} xalign={0} wrap={true} hexpand
vexpand wrapMode={Pango.WrapMode.WORD_CHAR} valign={Gtk.Align.START} label={
escapeUnintendedMarkup(notification.body)}
/>
</Gtk.Box>
</Gtk.Box>
<Gtk.Box class={"actions button-row"} hexpand={true} visible={
(notification instanceof AstalNotifd.Notification) &&
(notification.actions.filter(action => action.label.toLowerCase() !== "view").length > 0)
}>
{
(notification instanceof AstalNotifd.Notification) &&
notification.actions.filter(a => a.label.toLowerCase() !== "view").map(action =>
<Gtk.Button class={"action"} label={action.label}
hexpand={true} onClicked={(_) => {
notification.invoke(action.id);
actionClose?.(notification);
}}
/>)
}
</Gtk.Box>
</Gtk.Box> as Gtk.Widget;
}
+161
View File
@@ -0,0 +1,161 @@
import { Astal, Gdk, Gtk } from "ags/gtk4";
import { BackgroundWindow } from "./BackgroundWindow";
import { Accessor, CCProps, createComputed, createRoot, getScope } from "ags";
import { omitObjectKeys, WidgetNodeType } from "../scripts/utils";
import GObject from "ags/gobject";
type PopupWindowSpecificProps = {
$?: (self: Astal.Window) => void;
children?: WidgetNodeType;
/** Stylesheet for the background of the popup-window */
cssBackgroundWindow?: string;
class?: string | Accessor<string>;
actionClosed?: (self: Astal.Window) => void|boolean;
orientation?: Gtk.Orientation | Accessor<Gtk.Orientation>;
actionClickedOutside?: (self: Astal.Window) => void;
actionKeyPressed?: (self: Astal.Window, keyval: number, keycode: number) => void;
};
export type PopupWindowProps = Pick<Partial<CCProps<Astal.Window, Astal.Window.ConstructorProps>>,
"monitor"
| "layer"
| "exclusivity"
| "marginLeft"
| "marginTop"
| "marginRight"
| "marginBottom"
| "cursor"
| "canFocus"
| "hasFocus"
| "tooltipMarkup"
| "tooltipText"
| "namespace"
| "visible"
| "widthRequest"
| "heightRequest"
| "halign"
| "valign"
| "anchor"
| "vexpand"
| "hexpand"> & PopupWindowSpecificProps;
const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor;
export function PopupWindow(props: PopupWindowProps): GObject.Object {
props.visible ??= true;
props.layer ??= Astal.Layer.OVERLAY;
props.actionClickedOutside ??= (self: Astal.Window) => self.close();
let clickedInside: boolean = false;
return <Astal.Window {...omitObjectKeys(props, [
"actionKeyPressed",
"actionClickedOutside",
"cssBackgroundWindow",
"anchor",
"halign",
"valign",
"namespace",
"marginTop",
"widthRequest",
"heightRequest",
"visible",
"marginLeft",
"marginRight",
"marginBottom",
"hexpand",
"vexpand",
"orientation",
"actionClosed",
"$"
])} namespace={props.namespace ?? "popup-window"} class={
(props.class instanceof Accessor) ?
((props.namespace instanceof Accessor) ?
createComputed([props.class, props.namespace], (clss, namespace) =>
`popup-window ${clss} ${namespace}`)
: props.class.as(clss => `popup-window ${clss} ${props.namespace ?? ""}`))
: `popup-window ${props.class ?? ""} ${props.namespace ?? ""}`
} keymode={Astal.Keymode.EXCLUSIVE} exclusivity={props.exclusivity ?? Astal.Exclusivity.NORMAL}
anchor={TOP | LEFT | BOTTOM | RIGHT} visible={false}
onCloseRequest={(self) => props.actionClosed?.(self)}
$={(self) => {
const scope = getScope();
const conns: Map<GObject.Object, number> = new Map();
const gestureClick = Gtk.GestureClick.new();
const keyController = Gtk.EventControllerKey.new();
self.add_controller(gestureClick);
self.add_controller(keyController);
props.cssBackgroundWindow && createRoot((dispose) =>
<BackgroundWindow monitor={props.monitor ?? 0}
layer={props.layer} css={props.cssBackgroundWindow}
keymode={Astal.Keymode.NONE} attach={self}
onCloseRequest={() => dispose()}
/>
);
props.visible && self.show();
conns.set(gestureClick, gestureClick.connect("released", () => {
if(clickedInside) {
clickedInside = false;
return;
}
props.actionClickedOutside!(self);
}));
conns.set(keyController, keyController.connect("key-pressed", (_, keyval, keycode) => {
if(keyval === Gdk.KEY_Escape) {
conns.forEach((id, obj) => {
obj.disconnect(id);
});
props.actionClickedOutside!(self);
return;
}
props.actionKeyPressed?.(self, keyval, keycode);
}));
scope.onCleanup(() => conns.forEach((id, obj) => obj.disconnect(id)));
props.$?.(self);
}}>
<Gtk.Box hexpand={false} vexpand={false}>
<Gtk.Box class={"popup-window-container"} halign={props.halign}
valign={props.valign} widthRequest={props.widthRequest}
hexpand={props.hexpand} vexpand={props.vexpand}
orientation={props.orientation}
heightRequest={props.heightRequest} css={`
margin-left: ${props.marginLeft ?? 0}px;
margin-right: ${props.marginRight ?? 0}px;
margin-top: ${props.marginTop ?? 0}px;
margin-bottom: ${props.marginBottom ?? 0}px;
`} $={(self) => {
const conns = new Map<GObject.Object, number>(),
gestureClick = Gtk.GestureClick.new();
gestureClick.set_button(0);
self.add_controller(gestureClick);
conns.set(gestureClick, gestureClick.connect("released", () =>
clickedInside = true
));
conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) =>
obj.disconnect(id))));
}}>
{props.children}
</Gtk.Box>
</Gtk.Box>
</Astal.Window> as Astal.Window;
}
export function getPopupWindowContainer(popupWindow: Astal.Window): Gtk.Box {
return popupWindow.get_child()!.get_first_child() as Gtk.Box;
}
+48
View File
@@ -0,0 +1,48 @@
import { Accessor } from "ags";
import { Gtk } from "ags/gtk4";
export interface SeparatorProps {
class?: string;
alpha?: number;
cssColor?: string;
orientation?: Gtk.Orientation;
size?: number;
spacing?: number;
margin?: number;
visible?: boolean | Accessor<boolean>;
}
export function Separator(props: SeparatorProps = {
orientation: Gtk.Orientation.HORIZONTAL
}) {
props.alpha = props.alpha ?
(props.alpha > 1 ?
props.alpha / 100
: props.alpha)
: 1;
props.orientation = props.orientation ?? Gtk.Orientation.HORIZONTAL;
return <Gtk.Box name={"separator"} vexpand={props.orientation === Gtk.Orientation.HORIZONTAL}
hexpand={props.orientation === Gtk.Orientation.VERTICAL}
class={`separator ${ props.orientation === Gtk.Orientation.VERTICAL ?
"vertical" : "horizontal" }`} visible={props.visible}
css={`.vertical { padding: ${props.spacing ?? 0}px ${props.margin ?? 7}px; }
.horizontal { padding: ${props.margin ?? 4}px ${props.spacing ?? 0}px; }`}>
<Gtk.Box class={`${props.orientation === Gtk.Orientation.VERTICAL ?
"vertical"
: "horizontal"} ${props.class ?? ""}`}
vexpand={props.orientation === Gtk.Orientation.HORIZONTAL}
hexpand={props.orientation === Gtk.Orientation.VERTICAL}
css={`* {
background: ${ props.cssColor ?? "lightgray" };
opacity: ${props.alpha};
}
.horizontal { min-width: ${ props.size ?? 1 }px; }
.vertical { min-height: ${ props.size ?? 1 }px; }`}
/>
</Gtk.Box>
}
+12
View File
@@ -0,0 +1,12 @@
import { Gtk } from "ags/gtk4";
import { Windows } from "../../windows";
import { createBinding } from "ags";
import { tr } from "../../i18n/intl";
export const Apps = () =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((openWindows) =>
`apps ${Object.hasOwn(openWindows, "apps-window") ? "open" : ""}`
)} iconName={"applications-other-symbolic"} halign={Gtk.Align.CENTER}
hexpand tooltipText={tr("apps")} onClicked={() =>
Windows.getDefault().open("apps-window")}
/>;
+21
View File
@@ -0,0 +1,21 @@
import { Gtk } from "ags/gtk4";
import { Windows } from "../../windows";
import { createBinding } from "ags";
import { time } from "../../scripts/utils";
import { generalConfig } from "../../app";
export const Clock = () =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((wins) =>
`clock ${wins.includes("center-window") ? "open" : ""}`)}
$={(self) => {
const conns: Array<number> = [
self.connect("clicked", (_) => Windows.getDefault().toggle("center-window")),
self.connect("destroy", (_) => conns.forEach(id => self.disconnect(id)))
];
}}
label={time((dt) => dt.format(
generalConfig.getProperty("clock.date_format", "string"))
?? "An error occurred"
)}
/>;
+47
View File
@@ -0,0 +1,47 @@
import { Gtk } from "ags/gtk4";
import { createBinding, With } from "ags";
import { variableToBoolean } from "../../scripts/utils";
import { getAppIcon, getSymbolicIcon } from "../../scripts/apps";
import Pango from "gi://Pango?version=1.0";
import AstalHyprland from "gi://AstalHyprland";
const hyprland = AstalHyprland.get_default();
// Fix empty focused-client on opening a window on an empty workspace
hyprland.connect("client-added", () => hyprland.notify("focused-client"));
export const FocusedClient = () => {
const focusedClient = createBinding(hyprland, "focusedClient");
return <Gtk.Box class={"focused-client"}
visible={variableToBoolean(createBinding(hyprland, "focusedClient"))}>
<With value={focusedClient}>
{(focusedClient) => focusedClient?.class && <Gtk.Box>
<Gtk.Image iconName={createBinding(focusedClient, "class").as((clss) =>
getSymbolicIcon(clss) ?? getAppIcon(clss) ??
getAppIcon(focusedClient.initialClass) ??
"application-x-executable-symbolic"
)} vexpand
/>
<Gtk.Box valign={Gtk.Align.CENTER} class={"text-content"}
orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"class"} xalign={0} maxWidthChars={55}
ellipsize={Pango.EllipsizeMode.END}
label={createBinding(focusedClient, "class")}
tooltipText={createBinding(focusedClient, "class")}
/>
<Gtk.Label class={"title"} xalign={0} maxWidthChars={50}
ellipsize={Pango.EllipsizeMode.END}
label={createBinding(focusedClient, "title")}
tooltipText={createBinding(focusedClient, "title")}
/>
</Gtk.Box>
</Gtk.Box>}
</With>
</Gtk.Box>;
}
+168
View File
@@ -0,0 +1,168 @@
import { Accessor, createBinding, createConnection, createState, onCleanup, With } from "ags";
import { Gtk } from "ags/gtk4";
import { Separator } from "../Separator";
import { Windows } from "../../windows";
import { Clipboard } from "../../scripts/clipboard";
import GObject from "ags/gobject";
import AstalMpris from "gi://AstalMpris";
import Pango from "gi://Pango?version=1.0";
import { decoder, getPlayerIconFromBusName, variableToBoolean } from "../../scripts/utils";
export const dummyPlayer = AstalMpris.Player.new("colorshellDummy");
export let [player, setPlayer] = createState(dummyPlayer);
export const Media = () => {
const connections: Map<GObject.Object, Array<number>|number> = new Map();
if(AstalMpris.get_default().players[0])
setPlayer(AstalMpris.get_default().players[0]);
onCleanup(() => connections.forEach((id, obj) =>
Array.isArray(id) ?
id.forEach(id => obj.disconnect(id))
: obj.disconnect(id)
));
connections.set(AstalMpris.get_default(), [
AstalMpris.get_default().connect("player-added", (_, player) =>
player.available && setPlayer(player)),
AstalMpris.get_default().connect("player-closed", (_, closedPlayer) => {
const players = AstalMpris.get_default().players.filter(pl => pl?.available &&
pl.busName !== closedPlayer.busName);
if(players.length > 0) {
setPlayer(players[0]);
return;
}
setPlayer(dummyPlayer);
})
]);
return <Gtk.Box class={"media"} visible={player((pl) => pl.available)}
$={(self) => {
const gestureClick = Gtk.GestureClick.new(),
controllerMotion = Gtk.EventControllerMotion.new(),
controllerScroll = Gtk.EventControllerScroll.new(
Gtk.EventControllerScrollFlags.VERTICAL);
self.add_controller(gestureClick);
self.add_controller(controllerMotion);
self.add_controller(controllerScroll);
connections.set(gestureClick, gestureClick.connect("released", () =>
Windows.getDefault().toggle("center-window")));
connections.set(controllerScroll,
controllerScroll.connect("scroll", (_, _dx, dy) => {
if(AstalMpris.get_default().players.length === 1 &&
player.get()?.busName === AstalMpris.get_default().players[0].busName)
return true;
const players = AstalMpris.get_default().players;
for(let i = 0; i < players.length; i++) {
const pl = players[i];
if(pl.busName !== player.get().busName)
continue;
if(dy > 0 && players[i-1]) {
setPlayer(players[i-1]);
break;
}
if(dy < 0 && players[i+1]) {
setPlayer(players[i+1]);
break;
}
}
return true;
})
);
connections.set(controllerMotion, [
controllerMotion.connect("enter", () => {
const revealer = self.get_last_child() as Gtk.Revealer;
revealer.set_reveal_child(true);
}),
controllerMotion.connect("leave", () => {
const revealer = self.get_last_child() as Gtk.Revealer;
revealer.set_reveal_child(false);
})
]);
connections.set(self, self.connect("destroy", () =>
connections.forEach((ids, obj) => Array.isArray(ids) ?
ids.forEach(id => obj.disconnect(id))
: obj.disconnect(ids))
));
}}>
<Gtk.Box spacing={4} visible={player(pl => pl.available)}>
<With value={player(pl => pl.available)}>
{(available: boolean) => available && <Gtk.Box>
<Gtk.Image class={"player-icon"} iconName={
createBinding(player.get(), "busName").as(getPlayerIconFromBusName)}
/>
<Gtk.Label class={"title"} label={createBinding(player.get(), "title").as(title =>
title ?? "No Title")} maxWidthChars={20} ellipsize={Pango.EllipsizeMode.END}
/>
<Separator orientation={Gtk.Orientation.HORIZONTAL} size={1} margin={5}
alpha={.3} spacing={6} />
<Gtk.Label class={"artist"} label={createBinding(player.get(), "artist").as(artist =>
artist ?? "No Artist")} maxWidthChars={18} ellipsize={Pango.EllipsizeMode.END}
/>
</Gtk.Box>}
</With>
</Gtk.Box>
<Gtk.Revealer transitionType={Gtk.RevealerTransitionType.SLIDE_RIGHT} transitionDuration={260}
revealChild={false}>
<With value={player(pl => pl.available)}>
{(available: boolean) => available && <Gtk.Box class={"media-controls button-row"}>
<Gtk.Button class={"link"} iconName={"edit-paste-symbolic"}
visible={variableToBoolean(getMediaUrl(player.get()))}
tooltipText={"Copy link to Clipboard"} onClicked={() => {
const url = getMediaUrl(player.get()).get();
url && Clipboard.getDefault().copyAsync(url);
}}
/>
<Gtk.Button class={"previous"} iconName={"media-skip-backward-symbolic"}
tooltipText={"Previous"} onClicked={() =>
player.get().canGoPrevious && player.get().previous()}
/>
<Gtk.Button class={"play-pause"} iconName={createBinding(player.get(), "playbackStatus").as(status =>
status === AstalMpris.PlaybackStatus.PAUSED ?
"media-playback-start-symbolic"
: "media-playback-pause-symbolic")}
tooltipText={
createBinding(player.get(), "playbackStatus").as(status =>
status === AstalMpris.PlaybackStatus.PAUSED ? "Play" : "Pause")
} onClicked={() => player.get().play_pause()}
/>
<Gtk.Button class={"next"} iconName={"media-skip-forward-symbolic"}
tooltipText={"Next"} onClicked={() => player.get().canGoNext &&
player.get().next()}
/>
</Gtk.Box>}
</With>
</Gtk.Revealer>
</Gtk.Box>
}
export function getMediaUrl(player: AstalMpris.Player): Accessor<string|undefined> {
return createConnection(player.get_meta("xesam:url"),
[player, "notify::metadata", () => player.get_meta("xesam:url")]
).as(url => {
const byteString = url?.get_data_as_bytes();
return byteString ?
decoder.decode(byteString.toArray())
: undefined;
})
}
+141
View File
@@ -0,0 +1,141 @@
import { Gtk } from "ags/gtk4";
import { Wireplumber } from "../../scripts/volume";
import { Notifications } from "../../scripts/notifications";
import { Windows } from "../../windows";
import { Recording } from "../../scripts/recording";
import { Accessor, createBinding, createComputed, With } from "ags";
import { time, variableToBoolean } from "../../scripts/utils";
import GObject from "ags/gobject";
import AstalBluetooth from "gi://AstalBluetooth";
import AstalNetwork from "gi://AstalNetwork";
import AstalWp from "gi://AstalWp";
export const Status = () =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((openWins) =>
openWins.includes("control-center") ? "open status" : "status")}
onClicked={() => Windows.getDefault().toggle("control-center")}>
<Gtk.Box>
<Gtk.Box class={"volume-indicators"} spacing={5}>
<VolumeStatus class="sink" endpoint={Wireplumber.getDefault().getDefaultSink()}
icon={createBinding(Wireplumber.getDefault().getDefaultSink(), "volumeIcon").as(icon =>
!Wireplumber.getDefault().isMutedSink() &&
Wireplumber.getDefault().getSinkVolume() > 0 ? icon
: "audio-volume-muted-symbolic")
} />
<VolumeStatus class="source" endpoint={Wireplumber.getDefault().getDefaultSource()}
icon={createBinding(Wireplumber.getDefault().getDefaultSource(), "volumeIcon").as(icon =>
!Wireplumber.getDefault().isMutedSource() &&
Wireplumber.getDefault().getSourceVolume() > 0 ? icon
: "microphone-sensitivity-muted-symbolic")
} />
</Gtk.Box>
<Gtk.Revealer revealChild={createBinding(Recording.getDefault(), "recording")}
transitionDuration={500} transitionType={Gtk.RevealerTransitionType.SLIDE_LEFT}>
<Gtk.Box>
<Gtk.Image class={"recording state"} iconName={"media-record-symbolic"}
css={"margin-right: 6px;"} />
<Gtk.Label class={"rec-time"} label={createComputed([
createBinding(Recording.getDefault(), "recording"),
time
], (recording, dateTime) => {
if(!recording || !Recording.getDefault().startedAt)
return "...";
const startedAtSeconds = dateTime.to_unix() - Recording.getDefault().startedAt!;
if(startedAtSeconds <= 0) return "00:00";
const minutes = Math.floor(startedAtSeconds / 60);
const seconds = Math.floor(startedAtSeconds % 60);
return `${ minutes < 10 ? `0${minutes}` : minutes }:${ seconds < 10 ? `0${seconds}` : seconds }`;
})}
/>
</Gtk.Box>
</Gtk.Revealer>
<StatusIcons />
</Gtk.Box>
</Gtk.Button> as Gtk.Button;
function VolumeStatus(props: { class?: string, endpoint: AstalWp.Endpoint, icon?: (string|Accessor<string>) }) {
return <Gtk.Box spacing={2} class={props.class} $={(self) => {
const conns: Map<GObject.Object, number> = new Map();
const controllerScroll = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.VERTICAL
| Gtk.EventControllerScrollFlags.KINETIC);
conns.set(controllerScroll, controllerScroll.connect("scroll", (_, _dx, dy) => {
console.log`Scrolled! dx: ${_dx}; dy: ${dy}`;
dy > 0 ?
Wireplumber.getDefault().decreaseEndpointVolume(props.endpoint, 5)
: Wireplumber.getDefault().increaseEndpointVolume(props.endpoint, 5);
return true;
}));
conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) =>
obj.disconnect(id))));
}}>
{props.icon && <Gtk.Image iconName={props.icon} />}
<Gtk.Label class={"volume"} label={createBinding(props.endpoint, "volume").as(vol =>
`${Math.floor(vol * 100)}%`)} />
</Gtk.Box> as Gtk.Box;
}
function StatusIcons() {
return <Gtk.Box class={"status-icons"} spacing={8}>
<Gtk.Image iconName={createComputed([
createBinding(AstalBluetooth.get_default(), "isPowered"),
createBinding(AstalBluetooth.get_default(), "isConnected")
], (powered, connected) => {
return powered ? (
connected ?
"bluetooth-active-symbolic"
: "bluetooth-symbolic"
) : "bluetooth-disabled-symbolic"
})} class={"bluetooth state"} visible={
createBinding(AstalBluetooth.get_default(), "adapter").as(Boolean)
}
/>
<Gtk.Box visible={createBinding(AstalNetwork.get_default(), "primary").as(primary =>
primary !== AstalNetwork.Primary.UNKNOWN)}>
<With value={createBinding(AstalNetwork.get_default(), "primary")}>
{(primary: AstalNetwork.Primary) => {
let device: AstalNetwork.Wifi|AstalNetwork.Wired;
switch(primary) {
case AstalNetwork.Primary.WIRED:
device = AstalNetwork.get_default().wired;
break;
case AstalNetwork.Primary.WIFI:
device = AstalNetwork.get_default().wifi;
break;
default:
return <Gtk.Image iconName={"network-no-route-symbolic"} />;
}
return <Gtk.Image iconName={createBinding(device, "iconName")} />;
}}
</With>
</Gtk.Box>
<Gtk.Box>
<Gtk.Image class={"bell state"} iconName={createBinding(
Notifications.getDefault().getNotifd(), "dontDisturb").as(dnd => dnd ?
"minus-circle-filled-symbolic"
: "preferences-system-notifications-symbolic")
}
/>
<Gtk.Image iconName={"circle-filled-symbolic"} class={"notification-count"}
visible={variableToBoolean(createBinding(Notifications.getDefault(), "history"))}
/>
</Gtk.Box>
</Gtk.Box>
}
+58
View File
@@ -0,0 +1,58 @@
import { createBinding, createComputed, For, With } from "ags";
import { Gdk, Gtk } from "ags/gtk4";
import { variableToBoolean } from "../../scripts/utils";
import GObject from "gi://GObject?version=2.0";
import AstalTray from "gi://AstalTray"
import Gio from "gi://Gio?version=2.0";
const astalTray = AstalTray.get_default();
export const Tray = () => {
const items = createBinding(astalTray, "items").as(items => items.filter(item => item?.gicon));
return <Gtk.Box class={"tray"} visible={variableToBoolean(items)} spacing={10}>
<For each={items}>
{(item: AstalTray.TrayItem) => <Gtk.Box class={"item"}>
<With value={createComputed([
createBinding(item, "actionGroup"),
createBinding(item, "menuModel")
])}>
{([actionGroup, menuModel]: [Gio.ActionGroup, Gio.MenuModel]) => {
const popover = Gtk.PopoverMenu.new_from_model(menuModel);
popover.insert_action_group("dbusmenu", actionGroup);
popover.hasArrow = false;
return <Gtk.Box class={"item"} tooltipMarkup={
createBinding(item, "tooltipMarkup")
} tooltipText={
createBinding(item, "tooltipText")
} $={(self) => {
const conns: Map<GObject.Object, number> = new Map();
const gestureClick = Gtk.GestureClick.new();
gestureClick.set_button(0);
self.add_controller(gestureClick);
conns.set(gestureClick, gestureClick.connect("released", (gesture, _, x, y) => {
if(gesture.get_current_button() === Gdk.BUTTON_PRIMARY) {
item.activate(x, y);
return;
}
if(gesture.get_current_button() === Gdk.BUTTON_SECONDARY) {
item.about_to_show();
popover.popup();
}
}))
}}>
<Gtk.Image gicon={createBinding(item, "gicon")} pixelSize={16} />
{popover}
</Gtk.Box>;
}}
</With>
</Gtk.Box>}
</For>
</Gtk.Box>
}
+155
View File
@@ -0,0 +1,155 @@
import { Gtk } from "ags/gtk4";
import AstalHyprland from "gi://AstalHyprland";
import { getAppIcon, getSymbolicIcon } from "../../scripts/apps";
import { Separator } from "../Separator";
import { generalConfig } from "../../app";
import { createBinding, createComputed, createState, For, With } from "ags";
import { variableToBoolean } from "../../scripts/utils";
import GObject from "ags/gobject";
const [showNumbers, setShowNumbers] = createState(false);
export const showWorkspaceNumber = (show: boolean) =>
setShowNumbers(show);
export const Workspaces = () => {
const workspaces = createBinding(AstalHyprland.get_default(), "workspaces"),
defaultWorkspaces = workspaces.as(wss =>
wss.filter(ws => ws.id > 0).sort((a, b) => a.id - b.id)),
specialWorkspaces = workspaces.as(wss =>
wss.filter(ws => ws.id < 0).sort((a, b) => a.id - b.id));
return <Gtk.Box class={"workspaces-row"}>
<Gtk.Box class={"special-workspaces"} spacing={4}>
<For each={specialWorkspaces}>
{(ws: AstalHyprland.Workspace) =>
<Gtk.Button class={"workspace"}
tooltipText={createBinding(ws, "name").as(name => {
name = name.replace(/^special\:/, "");
return name.charAt(0).toUpperCase().concat(name.substring(1, name.length));
})} onClicked={() => AstalHyprland.get_default().dispatch(
"togglespecialworkspace", ws.name.replace(/^special[:]/, "")
)}>
<With value={createBinding(ws, "lastClient")}>
{(lastClient: AstalHyprland.Client|null) => lastClient &&
<Gtk.Image class="last-client" iconName={
createBinding(lastClient, "initialClass").as(initialClass =>
getSymbolicIcon(initialClass) ?? getAppIcon(initialClass) ??
"application-x-executable-symbolic")}
/>
}
</With>
</Gtk.Button>
}
</For>
</Gtk.Box>
<Gtk.Revealer transitionType={Gtk.RevealerTransitionType.SLIDE_RIGHT}
transitionDuration={220} revealChild={variableToBoolean(specialWorkspaces)}>
<Separator alpha={.2} orientation={Gtk.Orientation.HORIZONTAL}
margin={12} spacing={8} visible={variableToBoolean(specialWorkspaces)}
/>
</Gtk.Revealer>
<Gtk.Box class={"default-workspaces"} spacing={4} $={(self) => {
const conns: Map<GObject.Object, Array<number>|number> = new Map();
const controllerScroll = Gtk.EventControllerScroll.new(
Gtk.EventControllerScrollFlags.VERTICAL
), controllerMotion = Gtk.EventControllerMotion.new();
self.add_controller(controllerScroll);
self.add_controller(controllerMotion);
conns.set(controllerScroll, controllerScroll.connect("scroll", (_, _dx, dy) => {
dy > 0 ?
AstalHyprland.get_default().dispatch("workspace", "e-1")
: AstalHyprland.get_default().dispatch("workspace", "e+1");
return true;
}));
conns.set(controllerMotion, [
controllerMotion.connect("enter", () => setShowNumbers(true)),
controllerMotion.connect("leave", () => setShowNumbers(false))
]);
conns.set(self, self.connect("destroy", () => conns.forEach((ids, obj) =>
Array.isArray(ids) ?
ids.forEach(id => obj.disconnect(id))
: obj.disconnect(ids)
)));
}}>
<For each={defaultWorkspaces}>
{(ws: AstalHyprland.Workspace, i) => {
const showId = createComputed([
generalConfig.bindProperty("workspaces.always_show_id", "boolean").as(Boolean),
generalConfig.bindProperty("workspaces.enable_helper", "boolean").as(Boolean),
showNumbers,
i
], (alwaysShowIds, enableHelper, showIds, i) => {
if(enableHelper && !alwaysShowIds) {
const previousWorkspace = defaultWorkspaces.get()[i-1];
const nextWorkspace = defaultWorkspaces.get()[i+1];
if((defaultWorkspaces.get().filter((_, ii) => ii < i).length > 0 &&
previousWorkspace?.id < (ws.id-1)) ||
(defaultWorkspaces.get().filter((_, ii) => ii > i).length > 0 &&
nextWorkspace?.id > (ws.id+1))
|| (i === 0 && ws.id > 1)) {
return true;
}
}
return alwaysShowIds || showIds;
});
return <Gtk.Button class={createComputed([
createBinding(AstalHyprland.get_default(), "focusedWorkspace"),
showId
], (focusedWs, showWsNumbers) =>
`workspace ${focusedWs.id === ws.id ? "focus" : ""} ${
showWsNumbers ? "show" : ""}`
)} tooltipText={createComputed([
createBinding(ws, "lastClient"),
createBinding(AstalHyprland.get_default(), "focusedWorkspace")
], (lastClient, focusWs) => focusWs.id === ws.id ? "" :
`workspace ${ws.id}${ lastClient ? ` - ${
!lastClient.title.toLowerCase().includes(lastClient.class) ?
`${lastClient.get_class()}: `
: ""
} ${lastClient.title}` : "" }`
)} onClicked={() => ws.focus()}>
<With value={createBinding(ws, "lastClient")}>
{(lastClient: AstalHyprland.Client) =>
<Gtk.Box class={"last-client"} hexpand>
<Gtk.Revealer transitionDuration={280} revealChild={showId}
transitionType={createBinding(AstalHyprland.get_default(), "focusedWorkspace")
.as(fws => fws.id !== ws.id ?
Gtk.RevealerTransitionType.SLIDE_LEFT
: Gtk.RevealerTransitionType.SLIDE_RIGHT)}>
<Gtk.Label label={createBinding(ws, "id").as(String)}
class={"id"} hexpand />
</Gtk.Revealer>
{lastClient && <Gtk.Image class={"last-client-icon"} iconName={
createBinding(lastClient, "initialClass").as(initialClass =>
getSymbolicIcon(initialClass) ?? getAppIcon(initialClass) ??
"application-x-executable-symbolic")}
hexpand vexpand visible={createBinding(AstalHyprland.get_default(), "focusedWorkspace")
.as(fws => fws.id !== ws.id)}
/>}
</Gtk.Box>
}
</With>
</Gtk.Button>
}}
</For>
</Gtk.Box>
</Gtk.Box>
}
+193
View File
@@ -0,0 +1,193 @@
import { timeout } from "ags/time";
import { Astal, Gtk } from "ags/gtk4";
import { Clipboard } from "../../scripts/clipboard";
import { getMediaUrl, player, setPlayer } from "../bar/Media";
import { createBinding, For } from "ags";
import { pathToURI, variableToBoolean } from "../../scripts/utils";
import AstalMpris from "gi://AstalMpris";
import AstalIO from "gi://AstalIO";
import Pango from "gi://Pango?version=1.0";
import Adw from "gi://Adw?version=1";
import { register } from "ags/gobject";
let dragTimer: (AstalIO.Time|undefined);
export const BigMedia = () => {
const availablePlayers = createBinding(AstalMpris.get_default(), "players").as(pls =>
pls.filter(p => p.available));
const carousel = <Adw.Carousel orientation={Gtk.Orientation.HORIZONTAL} spacing={6}
onPageChanged={(self, num) => {
const page = self.get_nth_page(num);
if(page instanceof PlayerWidget && player.get().busName !== page.player.busName)
setPlayer(page.player);
}}>
<For each={availablePlayers.as(players => players.sort(pl =>
pl.busName === player.get().busName ? -1 : 1))}>
{(player: AstalMpris.Player) => <PlayerWidget player={player} />}
</For>
</Adw.Carousel> as Adw.Carousel;
return <Gtk.Box class={"big-media"} orientation={Gtk.Orientation.VERTICAL} widthRequest={255}
visible={variableToBoolean(availablePlayers)}>
{carousel}
<Gtk.Revealer revealChild={availablePlayers.as(pls => pls.length > 1)} transitionDuration={300}
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}>
<Adw.CarouselIndicatorDots orientation={Gtk.Orientation.HORIZONTAL} carousel={carousel} />
</Gtk.Revealer>
</Gtk.Box> as Gtk.Box;
}
@register({ GTypeName: "PlayerWidget" })
class PlayerWidget extends Gtk.Box {
#player!: AstalMpris.Player;
get player() { return this.#player; }
constructor({ player }: { player: AstalMpris.Player }) {
super();
this.setPlayer(player);
this.set_orientation(Gtk.Orientation.VERTICAL);
this.set_hexpand(true);
this.append(
<Gtk.Revealer hexpand={false} revealChild={
createBinding(player, "artUrl").as(Boolean)
} transitionType={Gtk.RevealerTransitionType.SLIDE_LEFT} transitionDuration={300}>
<Gtk.Box class={"image"} css={createBinding(player, "artUrl").as((art) =>
`background-image: url("${pathToURI(art)}");`)}
hexpand={false} vexpand={false} widthRequest={132} heightRequest={128}
valign={Gtk.Align.START} halign={Gtk.Align.CENTER}
/>
</Gtk.Revealer> as Gtk.Revealer
);
this.append(
<Gtk.Box class={"info"} orientation={Gtk.Orientation.VERTICAL}
valign={Gtk.Align.CENTER} vexpand hexpand>
<Gtk.Label class={"title"} tooltipText={
createBinding(player, "title").as(title => title ?? "No Title")
} label={
createBinding(player, "title").as(title => title ?? "No Title")
} ellipsize={Pango.EllipsizeMode.END} maxWidthChars={25}
/>
<Gtk.Label class={"artist"} tooltipText={
createBinding(player, "artist").as(artist => artist ?? "No Artist")
} label={
createBinding(player, "artist").as(artist => artist ?? "No Artist")
} ellipsize={Pango.EllipsizeMode.END} maxWidthChars={28}
/>
</Gtk.Box> as Gtk.Box
);
this.append(
<Gtk.Box class={"progress"} hexpand visible={createBinding(player, "canSeek")}>
<Astal.Slider hexpand max={createBinding(player, "length").as(Math.floor)}
value={createBinding(player, "position").as(Math.floor)}
onChangeValue={(_, type, value) => {
if(type === undefined || type === null)
return;
if(!dragTimer) {
dragTimer = timeout(200, () =>
player.position = Math.floor(value));
return;
}
dragTimer.cancel();
dragTimer = timeout(200, () =>
player.position = Math.floor(value));
}}
/>
</Gtk.Box> as Gtk.Box
);
this.append(
<Gtk.CenterBox class={"bottom"} hexpand marginBottom={6}>
<Gtk.Label class={"elapsed"} xalign={0} yalign={0}
halign={Gtk.Align.START} label={createBinding(player, "position").as(pos => {
const sec = Math.floor(pos % 60);
return pos > 0 && player.length > 0 ?
`${Math.floor(pos / 60)}:${sec < 10 ? "0" : ""}${sec}`
: "0:00";
})} $type="start"
/>
<Gtk.Box class={"controls button-row"} $type="center">
<Gtk.Button class={"link"} iconName={"edit-paste-symbolic"}
tooltipText={"Copy link to clipboard"}
visible={variableToBoolean(getMediaUrl(player))}
onClicked={() => {
const url = getMediaUrl(player).get();
url && Clipboard.getDefault().copyAsync(url);
}}
/>
<Gtk.Button class={"shuffle"} visible={createBinding(player, "shuffleStatus").as(status =>
status !== AstalMpris.Shuffle.UNSUPPORTED)} iconName={
createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ?
"media-playlist-shuffle-symbolic"
: "media-playlist-consecutive-symbolic")} tooltipText={
createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ?
"Shuffle"
: "No shuffle")} onClicked={() => player.shuffle()}
/>
<Gtk.Button class={"previous"} iconName={"media-skip-backward-symbolic"}
tooltipText={"Previous"} onClicked={() => player.canGoPrevious && player.previous()}
/>
<Gtk.Button class={"play-pause"} tooltipText={
createBinding(player, "playbackStatus").as(status =>
status === AstalMpris.PlaybackStatus.PLAYING ? "Pause" : "Play")}
iconName={createBinding(player, "playbackStatus").as(status =>
status === AstalMpris.PlaybackStatus.PLAYING ?
"media-playback-pause-symbolic"
: "media-playback-start-symbolic")} onClicked={() => player.play_pause()}
/>
<Gtk.Button class={"next"} iconName={"media-skip-forward-symbolic"}
tooltipText={"Next"} onClicked={() => player.canGoNext && player.next()}
/>
<Gtk.Button class={"repeat"} iconName={createBinding(player, "loopStatus").as(status => {
if(status === AstalMpris.Loop.TRACK)
return "media-playlist-repeat-song-symbolic";
if(status === AstalMpris.Loop.PLAYLIST)
return "media-playlist-repeat-symbolic";
return "loop-arrow-symbolic";
})} visible={createBinding(player, "loopStatus").as(status =>
status !== AstalMpris.Loop.UNSUPPORTED)}
tooltipText={createBinding(player, "loopStatus").as(status => {
if(status === AstalMpris.Loop.TRACK)
return "Loop song";
if(status === AstalMpris.Loop.PLAYLIST)
return "Loop playlist";
return "No loop";
})} onClicked={() => player.loop()}
/>
</Gtk.Box>
<Gtk.Label class={"length"} xalign={1} yalign={0}
halign={Gtk.Align.END} label={createBinding(player, "length").as(len => { /* bananananananana */
const sec = Math.floor(len % 60);
return (len > 0 && Number.isFinite(len)) ?
`${Math.floor(len / 60)}:${sec < 10 ? "0" : ""}${sec}`
: "0:00";
})} $type="end"
/>
</Gtk.CenterBox> as Gtk.CenterBox
);
}
setPlayer(player: AstalMpris.Player) {
this.#player = player;
}
}
@@ -0,0 +1,49 @@
import { Gtk } from "ags/gtk4";
import { HistoryNotification, Notifications } from "../../scripts/notifications";
import { NotificationWidget } from "../Notification";
import { tr } from "../../i18n/intl";
import { createBinding, For } from "ags";
import AstalNotifd from "gi://AstalNotifd?version=0.1";
export const NotifHistory = () =>
<Gtk.Box orientation={Gtk.Orientation.VERTICAL}
class={createBinding(Notifications.getDefault(), "history").as(history =>
`notif-history ${history.length < 1 ? "hide" : ""}`)} vexpand={false}>
<Gtk.ScrolledWindow class={"history-scrollable"} hscrollbarPolicy={Gtk.PolicyType.NEVER}
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC} propagateNaturalHeight={true}
onShow={(self) => {
if(!(self.get_child()! as Gtk.Viewport).get_child()) return;
self.minContentHeight =
((self.get_child()! as Gtk.Viewport).get_child() as Gtk.Box
).get_first_child()!.get_allocation().height
|| 0;
}}>
<Gtk.Box class={"notifications"} hexpand={true} orientation={Gtk.Orientation.VERTICAL}
spacing={4} valign={Gtk.Align.START}>
<For each={createBinding(Notifications.getDefault(), "history")}>
{(notif: AstalNotifd.Notification|HistoryNotification) =>
<NotificationWidget notification={notif} showTime={true}
actionClose={(n) => Notifications.getDefault().removeHistory(n.id)}
actionClicked={(n) => Notifications.getDefault().removeHistory(n.id)}
/>}
</For>
</Gtk.Box>
</Gtk.ScrolledWindow>
<Gtk.Box class={"button-row"} hexpand>
<Gtk.Button class={"clear-all"} halign={Gtk.Align.END}
onClicked={() => Notifications.getDefault().clearHistory()}>
<Gtk.Box hexpand>
<Gtk.Image class={"icon"} iconName={"edit-clear-all-symbolic"}
css={"margin-right: 6px;"} />
<Gtk.Label label={tr("clear")} />
</Gtk.Box>
</Gtk.Button>
</Gtk.Box>
</Gtk.Box> as Gtk.Box;
+99
View File
@@ -0,0 +1,99 @@
import { register } from "ags/gobject";
import { Gtk } from "ags/gtk4";
import { Page } from "./pages/Page";
import { timeout } from "ags/time";
import AstalIO from "gi://AstalIO";
export { Pages };
export type PagesProps = {
initialPage?: Page;
transitionDuration?: number;
};
@register({ GTypeName: "Pages" })
class Pages extends Gtk.Box {
#timeouts: Array<[AstalIO.Time, (() => void)|undefined]> = [];
#page: (Page|undefined);
#transDuration: number;
#transType: Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.SLIDE_DOWN;
get isOpen() { return Boolean(this.#page); }
get page() { return this.#page; }
constructor(props?: PagesProps) {
super({
orientation: Gtk.Orientation.VERTICAL,
cssName: "pages",
name: "pages"
});
this.add_css_class("pages");
this.#transDuration = props?.transitionDuration ?? 280;
if(props?.initialPage)
this.open(props.initialPage);
const destroyId = this.connect("destroy", () => {
this.disconnect(destroyId);
this.#timeouts.forEach((tmout) => {
tmout[0].cancel();
(async () => tmout[1]?.())().catch((err: Error) => {
console.error(`${err.message}\n${err.stack}`);
});
});
});
}
toggle(newPage?: Page, onToggled?: () => void): void {
if(!newPage || (this.#page?.id === newPage.id)) {
this.close(onToggled);
return;
}
if(!this.isOpen) {
newPage && this.open(newPage, onToggled);
return;
}
if(this.#page?.id !== newPage.id) {
this.close();
this.open(newPage, onToggled);
}
}
open(newPage: Page, onOpen?: () => void) {
this.#page = newPage;
this.prepend(
<Gtk.Revealer revealChild={false} transitionType={this.#transType}
transitionDuration={this.#transDuration}>
{newPage.create()}
</Gtk.Revealer> as Gtk.Revealer
);
(this.get_first_child() as Gtk.Revealer)?.set_reveal_child(true);
onOpen?.();
}
close(onClosed?: () => void): void {
const page = this.get_first_child() as Gtk.Revealer|null;
if(!page) return;
this.#page?.actionClosed?.();
this.#page = undefined;
page.set_reveal_child(false);
this.#timeouts.push([
timeout(page.transitionDuration, () => {
this.remove(page);
onClosed?.();
}),
onClosed
]);
}
}
@@ -0,0 +1,87 @@
import { Gtk } from "ags/gtk4";
import { Windows } from "../../windows";
import { Wallpaper } from "../../scripts/wallpaper";
import { execApp } from "../../scripts/apps";
import { Accessor } from "ags";
import { createPoll } from "ags/time";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
const userFace: Gio.File = Gio.File.new_for_path(`${GLib.get_home_dir()}/.face`);
const uptime: Accessor<string> = createPoll("Just turned on", 1000, "uptime -p");
function LockButton(): Gtk.Button {
return <Gtk.Button iconName={"system-lock-screen-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
execApp("hyprlock");
}}
/> as Gtk.Button;
}
function ColorPickerButton(): Gtk.Button {
return <Gtk.Button iconName={"color-select-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
execApp("sh $HOME/.config/hypr/scripts/color-picker.sh");
}}
/> as Gtk.Button;
}
function ScreenshotButton(): Gtk.Button {
return <Gtk.Button iconName={"applets-screenshooter-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
execApp(`sh ${GLib.get_user_config_dir()}/hypr/scripts/screenshot.sh`);
}}
/> as Gtk.Button;
}
function SelectWallpaperButton(): Gtk.Button {
return <Gtk.Button iconName={"preferences-desktop-wallpaper-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
Wallpaper.getDefault().pickWallpaper();
}}
/> as Gtk.Button;
}
function LogoutButton(): Gtk.Button {
return <Gtk.Button iconName={"system-shutdown-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
Windows.getDefault().open("logout-menu");
}}
/> as Gtk.Button;
}
export const QuickActions = () =>
<Gtk.Box class={"quickactions"}>
<Gtk.Box halign={Gtk.Align.START} class={"left"} hexpand>
{userFace.query_exists(null) &&
<Gtk.Box class={"user-face"} css={
`background-image: url("${userFace.get_path()!}");`}
/>
}
<Gtk.Box orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"hostname"} xalign={0} tooltipText={"Host name"}
label={GLib.get_host_name()} />
<Gtk.Box>
<Gtk.Image iconName={"hourglass-symbolic"} />
<Gtk.Label class={"uptime"} xalign={0} tooltipText={"Up time"}
label={uptime.as(str => str.replace(/^up /, ""))} />
</Gtk.Box>
</Gtk.Box>
</Gtk.Box>
<Gtk.Box class={"right button-row"} halign={Gtk.Align.END} hexpand>
<LockButton />
<ColorPickerButton />
<ScreenshotButton />
<SelectWallpaperButton />
<LogoutButton />
</Gtk.Box>
</Gtk.Box> as Gtk.Box;
+53
View File
@@ -0,0 +1,53 @@
import { Astal, Gtk } from "ags/gtk4";
import { Wireplumber } from "../../scripts/volume";
import { Pages } from "./Pages";
import { PageSound } from "./pages/Sound";
import { PageMicrophone } from "./pages/Microphone";
import { createBinding, With } from "ags";
import AstalWp from "gi://AstalWp";
export let slidersPages: Pages|undefined;
export function Sliders() {
return <Gtk.Box class={"sliders"} orientation={Gtk.Orientation.VERTICAL}
hexpand spacing={10} onUnmap={() => slidersPages = undefined}>
<With value={createBinding(Wireplumber.getWireplumber(), "defaultSpeaker")}>
{(sink: AstalWp.Endpoint) => <Gtk.Box class={"sink speaker"} spacing={3}>
<Gtk.Button onClicked={() => Wireplumber.getDefault().toggleMuteSink()}
iconName={createBinding(sink, "volumeIcon").as((icon) =>
(!Wireplumber.getDefault().isMutedSink() &&
Wireplumber.getDefault().getSinkVolume() > 0
) ? icon : "audio-volume-muted-symbolic"
)} />
<Astal.Slider drawValue={false} hexpand value={createBinding(sink, "volume")}
max={Wireplumber.getDefault().getMaxSinkVolume() / 100}
onChangeValue={(_, __, value) => sink.set_volume(value)} />
<Gtk.Button class={"more"} iconName={"go-next-symbolic"} onClicked={() =>
slidersPages?.toggle(PageSound)} />
</Gtk.Box>}
</With>
<With value={createBinding(Wireplumber.getWireplumber(), "defaultMicrophone")}>
{(source: AstalWp.Endpoint) => <Gtk.Box class={"source microphone"} spacing={3}>
<Gtk.Button onClicked={() => Wireplumber.getDefault().toggleMuteSource()}
iconName={createBinding(source, "volumeIcon").as((icon) =>
(!Wireplumber.getDefault().isMutedSource() &&
Wireplumber.getDefault().getSourceVolume() > 0
) ? icon : "microphone-sensitivity-muted-symbolic"
)} />
<Astal.Slider drawValue={false} hexpand value={createBinding(source, "volume")}
max={Wireplumber.getDefault().getMaxSourceVolume() / 100}
onChangeValue={(_, __, value) => source.set_volume(value)} />
<Gtk.Button class={"more"} iconName={"go-next-symbolic"} onClicked={() =>
slidersPages?.toggle(PageMicrophone)} />
</Gtk.Box>}
</With>
<Pages $={(self) => slidersPages = self} />
</Gtk.Box>
}
+32
View File
@@ -0,0 +1,32 @@
import { Gtk } from "ags/gtk4";
import { TileNetwork } from "./tiles/Network";
import { TileBluetooth } from "./tiles/Bluetooth";
import { TileDND } from "./tiles/DoNotDisturb";
import { TileRecording } from "./tiles/Recording";
import { TileNightLight } from "./tiles/NightLight";
import { Pages } from "./Pages";
export let TilesPages: Pages|undefined;
export const tileList: Array<() => JSX.Element|Gtk.Widget> = [
TileNetwork,
TileBluetooth,
TileRecording,
TileDND,
TileNightLight
] as Array<() => Gtk.Widget>;
export function Tiles(): Gtk.Widget {
return <Gtk.Box class={"tiles-container"} orientation={Gtk.Orientation.VERTICAL}
onUnmap={() => TilesPages = undefined}>
<Gtk.FlowBox orientation={Gtk.Orientation.HORIZONTAL} rowSpacing={6}
columnSpacing={6} minChildrenPerLine={2} activateOnSingleClick
maxChildrenPerLine={2} hexpand homogeneous>
{tileList.map(t => t())}
</Gtk.FlowBox>
<Pages class={"tile-pages"} $={(self) => TilesPages = self} />
</Gtk.Box> as Gtk.Box;
}
@@ -0,0 +1,183 @@
import { Gtk } from "ags/gtk4";
import { Page, PageButton } from "./Page";
import { tr } from "../../../i18n/intl";
import { Windows } from "../../../windows";
import { Notifications } from "../../../scripts/notifications";
import { execApp } from "../../../scripts/apps";
import { createBinding, createComputed, For, With } from "ags";
import AstalNotifd from "gi://AstalNotifd";
import AstalBluetooth from "gi://AstalBluetooth";
export const BluetoothPage = new Page({
id: "bluetooth",
title: tr("control_center.pages.bluetooth.title"),
spacing: 6,
description: tr("control_center.pages.bluetooth.description"),
headerButtons: [{
icon: createBinding(AstalBluetooth.get_default().adapter, "discovering")
.as(discovering => !discovering ?
"arrow-circular-top-right-symbolic"
: "media-playback-stop-symbolic"
),
tooltipText: createBinding(AstalBluetooth.get_default().adapter, "discovering")
.as((discovering) => !discovering ?
tr("control_center.pages.bluetooth.start_discovering")
: tr("control_center.pages.bluetooth.stop_discovering")),
actionClicked: () => {
if(AstalBluetooth.get_default().adapter.discovering) {
AstalBluetooth.get_default().adapter.stop_discovery();
return;
}
AstalBluetooth.get_default().adapter.start_discovery();
}
}],
actionClosed: () => AstalBluetooth.get_default().adapter?.discovering &&
AstalBluetooth.get_default().adapter.stop_discovery(),
bottomButtons: [{
title: tr("control_center.pages.more_settings"),
actionClicked: () => {
Windows.getDefault().close("control-center");
execApp("overskride", "[float; animation slide right]");
}
}],
content: () => [
<Gtk.Box class={"adapters"} visible={createBinding(AstalBluetooth.get_default(), "adapters")
.as(adptrs => adptrs.length > 1)
} spacing={2} orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"sub-header"} label={tr("control_center.pages.bluetooth.adapters")}
xalign={0} />
<With value={createBinding(AstalBluetooth.get_default(), "adapters").as(adpts =>
adpts.length > 1)}>
{(hasMoreAdapters: boolean) => hasMoreAdapters &&
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={2}>
<For each={createBinding(AstalBluetooth.get_default(), "adapters")}>
{(adapter: AstalBluetooth.Adapter) => {
const isSelected = createBinding(AstalBluetooth.get_default(), "adapter").as(a =>
a.address === adapter.address);
return <PageButton class={isSelected.as(is => is ? "selected" : "")}
title={adapter.alias ?? "Adapter"} icon={"bluetooth-active-symbolic"}
endWidget={
<Gtk.Image iconName={"object-select-symbolic"} visible={isSelected} />
}
/>;
}}
</For>
</Gtk.Box>
}
</With>
</Gtk.Box>,
<Gtk.Box class={"connections"} orientation={Gtk.Orientation.VERTICAL} hexpand={true}
spacing={2}>
<Gtk.Box class={"paired"} orientation={Gtk.Orientation.VERTICAL} spacing={4}
visible={createBinding(AstalBluetooth.get_default(), "devices").as(devs =>
devs.filter(dev => dev.paired || dev.connected || dev.trusted).length > 0)}>
<Gtk.Label class={"sub-header"} label={tr("devices")} xalign={0} />
<For each={createBinding(AstalBluetooth.get_default(), "devices").as(devs =>
devs.filter(dev => dev.paired || dev.connected || dev.trusted))}>
{(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />}
</For>
</Gtk.Box>
<Gtk.Box class={"discovered"} orientation={Gtk.Orientation.VERTICAL} spacing={4}
visible={createBinding(AstalBluetooth.get_default(), "devices").as(devs =>
devs.filter(dev => !dev.connected && !dev.paired && !dev.trusted).length > 0)}>
<Gtk.Label class={"sub-header"} label={tr("control_center.pages.bluetooth.new_devices")}
xalign={0} />
<For each={createBinding(AstalBluetooth.get_default(), "devices").as(devs =>
devs.filter(dev => !dev.connected && !dev.paired && !dev.trusted))}>
{(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />}
</For>
</Gtk.Box>
</Gtk.Box>
]
});
function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget {
return <PageButton class={createBinding(device, "connected").as(conn =>
conn ? "selected" : "")} title={
createBinding(device, "alias").as(alias => alias ?? "Unknown Device")}
icon={createBinding(device, "icon").as(ico => ico ?? "bluetooth-active-symbolic")}
description={
createBinding(device, "connecting").as(connecting =>
connecting ? `${tr("connecting")}...` : "")}
tooltipText={
createBinding(device, "connected").as(connected =>
!connected ? tr("connect") : "")
} actionClicked={() => {
if(device.connected) return;
let skipConnection: boolean = false;
if(!device.paired)
(async () => device.pair())().catch((err: Error) => {
skipConnection = true;
Notifications.getDefault().sendNotification({
appName: "bluetooth",
summary: "Device pairing error",
body: `Couldn't connect to ${device.alias ?? device.name}, an error occurred: ${err.message || err.stack}`,
urgency: AstalNotifd.Urgency.NORMAL
})
}).then(() => device.set_trusted(true));
if(!skipConnection)
(async () => device.connect_device(null))().catch((err: Error) =>
Notifications.getDefault().sendNotification({
appName: "bluetooth",
summary: "Device connection error",
body: `Couldn't connect to ${device.alias ?? device.name}, an error occurred: ${err.message || err.stack}`,
urgency: AstalNotifd.Urgency.NORMAL
})
);
}}
endWidget={<Gtk.Box visible={createComputed([
createBinding(device, "batteryPercentage"),
createBinding(device, "connected")
]).as(([batt, connected]) => connected && (batt > -1))
}>
<Gtk.Label halign={Gtk.Align.END} label={
createBinding(device, "batteryPercentage").as(batt =>
`${Math.floor(batt * 100)}%`)} />
<Gtk.Image iconName={
createBinding(device, "batteryPercentage").as(batt =>
`battery-level-${Math.floor(batt * 100)}-symbolic`)
} css={"font-size: 16px; margin-left: 6px;"} />
</Gtk.Box>} extraButtons={<With value={createComputed([
createBinding(device, "connected"),
createBinding(device, "trusted")
])}>
{([connected, trusted]: [boolean, boolean]) =>
<Gtk.Box visible={connected || trusted}>
{<Gtk.Button iconName={connected ?
"list-remove-symbolic"
: "user-trash-symbolic"} tooltipText={tr(connected ?
"disconnect"
: "control_center.pages.bluetooth.unpair_device"
)} onClicked={() => {
if(!connected) {
AstalBluetooth.get_default().adapter?.remove_device(device);
return;
}
device.disconnect_device(null);
}} />}
<Gtk.Button iconName={trusted ?
"shield-safe-symbolic"
: "shield-danger-symbolic"} tooltipText={tr(
`control_center.pages.bluetooth.${trusted ? "un" : ""}trust_device`
)} onClicked={() => device.set_trusted(!trusted)}
/>
</Gtk.Box>
}
</With>}
/> as Gtk.Widget;
}
@@ -0,0 +1,34 @@
import { Page, PageButton } from "./Page";
import { Wireplumber } from "../../../scripts/volume";
import { Gtk } from "ags/gtk4";
import { tr } from "../../../i18n/intl";
import { createBinding, For } from "ags";
import { lookupIcon } from "../../../scripts/apps";
import AstalWp from "gi://AstalWp?version=0.1";
export const PageMicrophone = new Page({
id: "microphone",
title: tr("control_center.pages.microphone.title"),
description: tr("control_center.pages.microphone.description"),
content: () => [
<Gtk.Label class={"sub-header"} label={tr("devices")} xalign={0} />,
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={4}>
<For each={createBinding(Wireplumber.getWireplumber().get_audio()!, "microphones")}>
{(source: AstalWp.Endpoint) => <PageButton class={
createBinding(source, "isDefault").as(isDefault => isDefault ? "selected" : "")
} icon={createBinding(source, "icon").as(ico => lookupIcon(ico) ?
ico : "audio-input-microphone-symbolic")} title={
createBinding(source, "description").as(desc => desc ?? "Microphone")
} actionClicked={() => !source.isDefault && source.set_is_default(true)}
endWidget={
<Gtk.Image iconName={"object-select-symbolic"} visible={
createBinding(source, "isDefault")} css={"font-size: 18px;"}
/>
}
/>}
</For>
</Gtk.Box>
]
});
+170
View File
@@ -0,0 +1,170 @@
import { Gtk } from "ags/gtk4";
import { Page, PageButton } from "./Page";
import { Windows } from "../../../windows";
import { tr } from "../../../i18n/intl";
import { execApp } from "../../../scripts/apps";
import { Notifications } from "../../../scripts/notifications";
import { AskPopup, AskPopupProps } from "../../AskPopup";
import { encoder, variableToBoolean } from "../../../scripts/utils";
import { createBinding, For, With } from "ags";
import GLib from "gi://GLib?version=2.0";
import NM from "gi://NM";
import AstalNetwork from "gi://AstalNetwork";
export const PageNetwork = new Page({
id: "network",
title: tr("control_center.pages.network.title"),
headerButtons: createBinding(AstalNetwork.get_default(), "primary").as(primary =>
primary === AstalNetwork.Primary.WIFI ? [{
icon: "arrow-circular-top-right-symbolic",
tooltipText: "Re-scan networks",
actionClicked: () => AstalNetwork.get_default().wifi.scan()
}] : []
),
bottomButtons: [{
title: tr("control_center.pages.more_settings"),
actionClicked: () => {
Windows.getDefault().close("control-center");
execApp("nm-connection-editor", "[animationstyle gnomed]");
}
}],
content: () => [
<Gtk.Box class={"devices"} hexpand orientation={Gtk.Orientation.VERTICAL}
visible={variableToBoolean(createBinding(AstalNetwork.get_default().client, "devices"))}
spacing={4}>
<Gtk.Label label={tr("devices")} xalign={0} class={"sub-header"} />
<For each={createBinding(AstalNetwork.get_default().client, "devices").as(devs =>
devs.filter(dev => dev.interface !== "lo" && dev.real /* filter local device */))}>
{(device: NM.Device) => <PageButton title={createBinding(device, "interface").as(iface =>
iface ?? tr("control_center.pages.network.interface"))} class={"device"}
icon={createBinding(device, "deviceType").as(type => type === NM.DeviceType.WIFI ?
"network-wireless-symbolic" : "network-wired-symbolic")} extraButtons={[
<Gtk.Button iconName={"view-more-symbolic"} onClicked={() => {
Windows.getDefault().close("control-center");
execApp(
`nm-connection-editor --edit ${device.activeConnection?.connection.get_uuid()}`,
"[animationstyle gnomed; float]"
);
}} />
]}
/>}
</For>
</Gtk.Box>,
<With value={createBinding(AstalNetwork.get_default(), "primary").as(primary =>
primary === AstalNetwork.Primary.WIFI)}>
{(isWifi: boolean) => isWifi && <Gtk.Box class={"wireless-aps"} hexpand={true}
orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"sub-header"} label={"Wi-Fi"} />
<For each={createBinding(AstalNetwork.get_default().wifi, "accessPoints")}>
{(ap: AstalNetwork.AccessPoint) => <PageButton class={
createBinding(AstalNetwork.get_default().wifi, "activeAccessPoint").as(activeAP =>
activeAP.ssid === ap.ssid ? "active" : "")
} title={createBinding(ap, "ssid").as(ssid => ssid ?? "No SSID")}
icon={createBinding(ap, "iconName")} endWidget={<Gtk.Image iconName={
createBinding(ap, "flags").as(flags =>
// @ts-ignore
flags & NM["80211ApFlags"].PRIVACY ?
"channel-secure-symbolic"
: "channel-insecure-symbolic")}
css={"font-size: 18px;"}
/>} extraButtons={[
<Gtk.Button iconName={"window-close-symbolic"} visible={
createBinding(AstalNetwork.get_default().wifi, "activeAccessPoint").as(activeAp =>
activeAp.ssid === ap.ssid)
} css={"font-size: 18px;"} onClicked={() => {
const active = AstalNetwork.get_default().wifi.activeAccessPoint;
if(active?.ssid === ap.ssid) {
AstalNetwork.get_default().wifi.deactivate_connection((_, res) => {
try {
AstalNetwork.get_default().wifi.deactivate_connection_finish(res);
} catch(e: any) {
e = e as Error;
console.error(
`Network: couldn't deactivate connection with access point(SSID: ${
ap.ssid}. Stderr: \n${e.message}\n${e.stack}`
);
}
})
}
}}/>
]} actionClicked={() => {
const uuid = NM.utils_uuid_generate();
const ssidBytes = GLib.Bytes.new(encoder.encode(ap.ssid));
const connection = NM.SimpleConnection.new();
const connSetting = NM.SettingConnection.new();
const wifiSetting = NM.SettingWireless.new();
const wifiSecuritySetting = NM.SettingWirelessSecurity.new();
const setting8021x = NM.Setting8021x.new();
// @ts-ignore yep, type-gen issues again
if(ap.rsnFlags !& NM["80211ApSecurityFlags"].KEY_MGMT_802_1X &&
// @ts-ignore
ap.wpaFlags !& NM["80211ApSecurityFlags"].KEY_MGMT_802_1X) {
return;
}
connSetting.uuid = uuid;
connection.add_setting(connSetting);
connection.add_setting(wifiSetting);
wifiSetting.ssid = ssidBytes;
wifiSecuritySetting.keyMgmt = "wpa-eap";
connection.add_setting(wifiSecuritySetting);
setting8021x.add_eap_method("ttls");
setting8021x.phase2Auth = "mschapv2";
connection.add_setting(setting8021x);
}}
/>}
</For>
</Gtk.Box>}
</With>
]
});
function activateWirelessConnection(connection: NM.RemoteConnection, ssid: string): void {
AstalNetwork.get_default().get_client().activate_connection_async(
connection, AstalNetwork.get_default().wifi.get_device(), null, null, (_, asyncRes) => {
const activeConnection = AstalNetwork.get_default().get_client().activate_connection_finish(asyncRes);
if(!activeConnection) {
Notifications.getDefault().sendNotification({
appName: "network",
summary: "Couldn't activate wireless connection",
body: `An error occurred while activating the wireless connection "${ssid}"`
});
return;
}
}
);
}
function notifyConnectionError(ssid: string): void {
Notifications.getDefault().sendNotification({
appName: "network",
summary: "Coudn't connect Wi-Fi",
body: `An error occurred while trying to connect to the "${ssid}" access point. \nMaybe the password is invalid?`
});
}
function saveToDisk(remoteConnection: NM.RemoteConnection, ssid: string): void {
AskPopup({
text: `Save password for connection "${ssid}"?`,
acceptText: "Yes",
onAccept: () => remoteConnection.commit_changes_async(true, null, (_, asyncRes) =>
!remoteConnection.commit_changes_finish(asyncRes) && Notifications.getDefault().sendNotification({
appName: "network",
summary: "Couldn't save Wi-Fi password",
body: `An error occurred while trying to write the password for "${ssid}" to disk`
}))
} as AskPopupProps);
}
@@ -0,0 +1,43 @@
import { Page } from "./Page";
import { NightLight } from "../../../scripts/nightlight";
import { tr } from "../../../i18n/intl";
import { Astal, Gtk } from "ags/gtk4";
import { addSliderMarksFromMinMax } from "../../../scripts/utils";
import { createBinding } from "ags";
export const PageNightLight = new Page({
id: "night-light",
title: tr("control_center.pages.night_light.title"),
description: tr("control_center.pages.night_light.description"),
content: () => [
<Gtk.Label class={"sub-header"} label={tr(
"control_center.pages.night_light.temperature"
)} xalign={0} />,
<Astal.Slider class={"temperature"} $={(self) => {
self.value = NightLight.getDefault().temperature;
addSliderMarksFromMinMax(self, 5, "{}K");
}} value={createBinding(NightLight.getDefault(), "temperature")}
tooltipText={createBinding(NightLight.getDefault(), "temperature").as(temp =>
`${temp}K`)} min={NightLight.getDefault().minTemperature}
max={NightLight.getDefault().maxTemperature}
onChangeValue={(_, type, value) => {
if(type != undefined && type !== null)
NightLight.getDefault().temperature = Math.floor(value)
}}
/>,
<Gtk.Label class={"sub-header"} label={tr(
"control_center.pages.night_light.gamma"
)} xalign={0} />,
<Astal.Slider class={"gamma"} $={(self) => {
self.value = NightLight.getDefault().gamma;
addSliderMarksFromMinMax(self, 5, "{}%");
}} value={createBinding(NightLight.getDefault(), "gamma")}
tooltipText={createBinding(NightLight.getDefault(), "gamma").as(gamma =>
`${gamma}%`)} max={NightLight.getDefault().maxGamma}
onChangeValue={(_, type, value) => {
if(type != undefined && type !== null)
NightLight.getDefault().gamma = Math.floor(value)
}}
/>
]
});
+170
View File
@@ -0,0 +1,170 @@
import { Gtk } from "ags/gtk4";
import { Separator } from "../../Separator";
import { Accessor, createRoot } from "ags";
import { transformWidget, variableToBoolean, WidgetNodeType } from "../../../scripts/utils";
import Pango from "gi://Pango?version=1.0";
export type PageProps = {
id: string;
title: string;
description?: string;
headerButtons?: Array<HeaderButton> | Accessor<Array<HeaderButton>>;
bottomButtons?: Array<BottomButton> | Accessor<Array<BottomButton>>;
orientation?: Gtk.Orientation | Accessor<Gtk.Orientation>;
spacing?: number | Accessor<number>;
content: () => WidgetNodeType;
actionClosed?: () => void;
};
export type BottomButton = {
title: string | Accessor<string>;
description?: string | Accessor<string>;
tooltipText?: string | Accessor<string>;
tooltipMarkup?: string | Accessor<string>;
actionClicked?: () => void;
};
export type HeaderButton = {
label?: string|Accessor<string>;
icon: string|Accessor<string>;
tooltipText?: string | Accessor<string>;
tooltipMarkup?: string | Accessor<string>;
actionClicked?: () => void;
};
export class Page {
#title: string;
#description?: string;
#orientation: Gtk.Orientation|Accessor<
Gtk.Orientation> = Gtk.Orientation.VERTICAL;
#spacing: number|Accessor<number> = 4;
#headerButtons?: Array<HeaderButton>|Accessor<Array<HeaderButton>>;
#bottomButtons?: Array<BottomButton>|Accessor<Array<BottomButton>>;
readonly #id?: string;
readonly #create: () => WidgetNodeType;
public get id() { return this.#id; }
public get title() { return this.#title; }
public get description() { return this.#description; }
public get headerButtons() { return this.#headerButtons; }
public get bottomButtons() { return this.#bottomButtons; }
public readonly actionClosed?: () => void;
constructor(props: PageProps) {
this.#id = props.id;
this.#title = props.title;
this.#description = props.description;
this.#create = props.content;
this.actionClosed = props.actionClosed;
if(props.orientation != null)
this.#orientation = props.orientation;
if(props.spacing != null)
this.#spacing = props.spacing;
if(props.headerButtons != null)
this.#headerButtons = props.headerButtons;
}
public create(): Gtk.Box {
return createRoot((dispose) =>
<Gtk.Box hexpand class={`page container ${this.#id ?? ""}`} cssName={"page"} name={"page"}
orientation={Gtk.Orientation.VERTICAL} onUnmap={() => dispose()}>
<Gtk.Box class={"header"} orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Box class={"top"} hexpand>
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} hexpand>
<Gtk.Label class={"title"} label={this.#title} xalign={0}
ellipsize={Pango.EllipsizeMode.END} />
<Gtk.Label class={"description"} label={this.#description}
xalign={0} ellipsize={Pango.EllipsizeMode.END}
visible={variableToBoolean(this.#description)} />
</Gtk.Box>
<Gtk.Box class={"button-row"} visible={variableToBoolean(this.#headerButtons)}
hexpand={false}>
{this.#headerButtons && transformWidget(this.#headerButtons, (button) =>
<Gtk.Button class={"header-button"} label={button.label}
iconName={button.icon} onClicked={() => button.actionClicked?.()}
tooltipText={button.tooltipText} tooltipMarkup={button.tooltipMarkup}
/>
)}
</Gtk.Box>
</Gtk.Box>
</Gtk.Box>
<Gtk.Box class={"content"} hexpand={false} orientation={this.#orientation}
spacing={this.#spacing}>
{this.#create()}
</Gtk.Box>
<Separator alpha={.2} spacing={6} orientation={Gtk.Orientation.VERTICAL}
visible={variableToBoolean(this.#bottomButtons)}
/>
<Gtk.Box class={"bottom-buttons"} orientation={Gtk.Orientation.VERTICAL}
visible={variableToBoolean(this.#bottomButtons)} spacing={2}>
{this.#bottomButtons && transformWidget(this.#bottomButtons, (button) =>
<Gtk.Button onClicked={() => button?.actionClicked?.()} tooltipText={button?.tooltipText}
tooltipMarkup={button?.tooltipMarkup}>
<Gtk.Label class={"title"} label={button?.title} xalign={0} />
<Gtk.Label class={"description"} label={button?.description}
xalign={0} visible={variableToBoolean(button?.description)} />
</Gtk.Button>
)}
</Gtk.Box>
</Gtk.Box> as Gtk.Box
);
}
public static getContent(pageWidget: Gtk.Box) {
return pageWidget.get_first_child()!.get_next_sibling()! as Gtk.Box;
}
}
export function PageButton({ onUnmap, ...props }: {
class?: string | Accessor<string>;
icon?: string | Accessor<string>;
title: string | Accessor<string>;
endWidget?: WidgetNodeType;
description?: string | Accessor<string>;
extraButtons?: Array<WidgetNodeType> | WidgetNodeType;
maxWidthChars?: number | Accessor<number>;
onUnmap?: (self: Gtk.Box) => void;
actionClicked?: (self: Gtk.Button) => void;
tooltipText?: string | Accessor<string>;
tooltipMarkup?: string | Accessor<string>;
}): Gtk.Box {
return <Gtk.Box onUnmap={(self) => onUnmap?.(self)} class={"page-button"}>
<Gtk.Button onClicked={props.actionClicked} class={props.class} hexpand
tooltipText={props.tooltipText} tooltipMarkup={props.tooltipMarkup}>
<Gtk.Box class={"container"} hexpand>
{props.icon && <Gtk.Image iconName={props.icon} visible={variableToBoolean(props.icon)}
css={"font-size: 20px; margin-right: 6px;"} />}
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} hexpand vexpand={false}>
<Gtk.Label class={"title"} xalign={0} tooltipText={props.title}
ellipsize={Pango.EllipsizeMode.END} label={props.title}
maxWidthChars={props.maxWidthChars ?? 28}
/>
<Gtk.Label class={"description"} xalign={0} visible={variableToBoolean(props.description)}
label={props.description} ellipsize={Pango.EllipsizeMode.END}
tooltipText={props.description} />
</Gtk.Box>
<Gtk.Box visible={variableToBoolean(props.endWidget)} halign={Gtk.Align.END}>
{props.endWidget && props.endWidget}
</Gtk.Box>
</Gtk.Box>
</Gtk.Button>
<Gtk.Box class={"extra-buttons"} visible={variableToBoolean(props.extraButtons)}>
{props.extraButtons}
</Gtk.Box>
</Gtk.Box> as Gtk.Box;
}
+96
View File
@@ -0,0 +1,96 @@
import { Page, PageButton } from "./Page";
import { Astal, Gtk } from "ags/gtk4";
import { getAppIcon, lookupIcon } from "../../../scripts/apps";
import { Wireplumber } from "../../../scripts/volume";
import { tr } from "../../../i18n/intl";
import { createBinding, For } from "ags";
import { variableToBoolean } from "../../../scripts/utils";
import AstalWp from "gi://AstalWp";
import GObject from "gi://GObject?version=2.0";
import Pango from "gi://Pango?version=1.0";
export const PageSound = new Page({
id: "sound",
title: tr("control_center.pages.sound.title"),
description: tr("control_center.pages.sound.description"),
content: () => [
<Gtk.Label class={"sub-header"} label={tr("devices")} xalign={0} />,
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={4}>
<For each={createBinding(Wireplumber.getWireplumber().audio!, "speakers")}>
{(sink: AstalWp.Endpoint) =>
<PageButton class={createBinding(sink, "isDefault").as(isDefault =>
isDefault ? "selected" : "")}
icon={createBinding(sink, "icon").as(ico =>
lookupIcon(ico) ? ico : "audio-card-symbolic")}
title={createBinding(sink, "description").as(desc =>
desc ?? "Speaker")}
actionClicked={() => !sink.isDefault && sink.set_is_default(true)}
endWidget={
<Gtk.Image iconName={"object-select-symbolic"}
visible={createBinding(sink, "isDefault")}
css={"font-size: 18px;"}
/>
}
/>}
</For>
</Gtk.Box>,
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
<Gtk.Label class={"sub-header"} label={tr("apps")} xalign={0}
visible={variableToBoolean(
createBinding(Wireplumber.getWireplumber().audio!, "streams")
)}
/>
<For each={createBinding(Wireplumber.getWireplumber().audio!, "streams")}>
{(stream: AstalWp.Stream) =>
<Gtk.Box hexpand $={(self) => {
const conns: Map<GObject.Object, Array<number>> = new Map();
const controllerMotion = Gtk.EventControllerMotion.new();
self.add_controller(controllerMotion);
conns.set(controllerMotion, [
controllerMotion.connect("enter", () => {
const revealer = self.get_last_child()!.get_first_child() as Gtk.Revealer;
revealer.set_reveal_child(true);
}),
controllerMotion.connect("leave", () => {
const revealer = self.get_last_child()!.get_first_child() as Gtk.Revealer;
revealer.set_reveal_child(false);
})
]);
conns.set(self, [
self.connect("destroy", () => conns.forEach((ids, obj) =>
ids.forEach(id => obj.disconnect(id))
))
]);
}}>
<Gtk.Image iconName={createBinding(stream, "name").as(name =>
getAppIcon(name.split(' ')[0]) ?? "application-x-executable-symbolic")}
css={"font-size: 18px; margin-right: 6px;"} />
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} hexpand={true}>
<Gtk.Revealer transitionDuration={180}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}>
<Gtk.Label label={createBinding(stream, "description").as(desc =>
desc ?? "Unnamed audio stream")}
ellipsize={Pango.EllipsizeMode.END}
tooltipText={createBinding(stream, "name")}
class={"name"} xalign={0}
/>
</Gtk.Revealer>
<Astal.Slider drawValue={false} value={createBinding(stream, "volume")}
onChangeValue={(_, __, value) => stream.set_volume(value)}
hexpand min={0} max={1.5}
/>
</Gtk.Box>
</Gtk.Box>
}
</For>
</Gtk.Box>
]
});
@@ -0,0 +1,28 @@
import { Tile } from "./Tile";
import AstalBluetooth from "gi://AstalBluetooth";
import { BluetoothPage } from "../pages/Bluetooth";
import { TilesPages } from "../Tiles";
import { createBinding, createComputed } from "ags";
export const TileBluetooth = () =>
<Tile title={"Bluetooth"} visible={
createBinding(AstalBluetooth.get_default(), "adapter").as(Boolean)
} description={createBinding(AstalBluetooth.get_default(), "isConnected").as((connected) => {
const connectedDev = AstalBluetooth.get_default().devices.filter(dev => dev.connected)?.[0];
return connected && connectedDev ? connectedDev.get_alias() : ""
})} onToggledOn={() => AstalBluetooth.get_default().adapter?.set_powered(true)}
onToggledOff={() => AstalBluetooth.get_default().adapter?.set_powered(false)}
onClickMore={() => TilesPages?.toggle(BluetoothPage)}
enableOnClickMore={true} iconSize={16}
toggleState={createBinding(AstalBluetooth.get_default(), "isPowered")}
icon={createComputed([
createBinding(AstalBluetooth.get_default(), "isPowered"),
createBinding(AstalBluetooth.get_default(), "isConnected")
],
(powered: boolean, isConnected: boolean) =>
powered ? ( isConnected ?
"bluetooth-active-symbolic"
: "bluetooth-symbolic"
) : "bluetooth-disabled-symbolic")}
/>;
@@ -0,0 +1,15 @@
import { Notifications } from "../../../scripts/notifications";
import { Tile } from "./Tile";
import { tr } from "../../../i18n/intl";
import { createBinding } from "ags";
export const TileDND = () =>
<Tile title={tr("control_center.tiles.dnd.title")}
description={createBinding(Notifications.getDefault().getNotifd(), "dontDisturb").as(
(dnd: boolean) => dnd ? tr("control_center.tiles.enabled") : tr("control_center.tiles.disabled"))}
onToggledOff={() => Notifications.getDefault().getNotifd().dontDisturb = false}
onToggledOn={() => Notifications.getDefault().getNotifd().dontDisturb = true}
icon={"minus-circle-filled-symbolic"}
iconSize={16}
toggleState={Notifications.getDefault().getNotifd().dontDisturb}
/>;
@@ -0,0 +1,85 @@
import { execAsync } from "ags/process";
import { Tile } from "./Tile";
import AstalNetwork from "gi://AstalNetwork";
import { PageNetwork } from "../pages/Network";
import { tr } from "../../../i18n/intl";
import { TilesPages } from "../Tiles";
import { Gtk } from "ags/gtk4";
import { createBinding, createComputed, With } from "ags";
export const TileNetwork = () => <Gtk.Box>
<With value={createComputed([
createBinding(AstalNetwork.get_default(), "primary"),
createBinding(AstalNetwork.get_default(), "wired"),
createBinding(AstalNetwork.get_default(), "wifi")
])}>
{([primary, wired, wifi]: [AstalNetwork.Primary, AstalNetwork.Wired, AstalNetwork.Wifi]) => {
if(primary === AstalNetwork.Primary.WIFI) {
return <Tile title={tr("control_center.tiles.network.wireless")}
description={createComputed([
createBinding(wifi, "ssid"), createBinding(wifi, "internet")
], (ssid, internet) => ssid ? ssid : (() => {
switch(internet) {
case AstalNetwork.Internet.CONNECTED:
return tr("connected");
case AstalNetwork.Internet.DISCONNECTED:
return tr("disconnected");
case AstalNetwork.Internet.CONNECTING:
return tr("connecting") + "...";
}
})()
)} onToggledOn={() => wifi.set_enabled(true)}
onToggledOff={() => wifi.set_enabled(false)}
onClickMore={() => TilesPages?.toggle(PageNetwork)}
icon={"network-wireless-signal-excellent-symbolic"}
toggleState={createBinding(wifi, "enabled")}
/>
} else if(primary === AstalNetwork.Primary.WIRED) {
return <Tile title={tr("control_center.tiles.network.wired")}
description={createBinding(wired, "internet").as((internet: AstalNetwork.Internet) => {
switch(internet) {
case AstalNetwork.Internet.CONNECTED:
return tr("connected");
case AstalNetwork.Internet.DISCONNECTED:
return tr("disconnected");
case AstalNetwork.Internet.CONNECTING:
return tr("connecting") + "...";
}
})}
onToggledOn={() => execAsync("nmcli n on")}
onToggledOff={() => execAsync("nmcli n off")}
onClickMore={() => TilesPages?.toggle(PageNetwork)}
icon={createBinding(wired, "internet").as((internet: AstalNetwork.Internet) => {
switch(internet) {
case AstalNetwork.Internet.CONNECTED:
return "network-wired-symbolic";
case AstalNetwork.Internet.DISCONNECTED:
return "network-wired-disconnected-symbolic";
}
return "network-wired-no-route-symbolic";
})}
iconSize={16}
toggleState={createBinding(wired, "internet").as((internet: AstalNetwork.Internet) =>
internet === AstalNetwork.Internet.CONNECTING
|| internet === AstalNetwork.Internet.CONNECTED
)}
/>
}
return <Tile
title={tr("control_center.tiles.network.network")}
description={tr("disconnected")}
onToggledOn={() => execAsync("nmcli n on")}
onToggledOff={() => execAsync("nmcli n off")}
onClickMore={() => TilesPages?.toggle(PageNetwork)}
icon={"network-wired-disconnected-symbolic"}
iconSize={16}
toggleState={createBinding(wired, "internet").as((internet: AstalNetwork.Internet) =>
internet === AstalNetwork.Internet.CONNECTING || internet === AstalNetwork.Internet.CONNECTED)}
/>
}}
</With>
</Gtk.Box>;
@@ -0,0 +1,25 @@
import { Tile } from "./Tile";
import { NightLight } from "../../../scripts/nightlight";
import { PageNightLight } from "../pages/NightLight";
import { tr } from "../../../i18n/intl";
import { TilesPages } from "../Tiles";
import { isInstalled } from "../../../scripts/utils";
import { createBinding, createComputed } from "ags";
export const TileNightLight = () =>
<Tile title={tr("control_center.tiles.night_light.title")}
icon={"weather-clear-night-symbolic"}
description={createComputed([
createBinding(NightLight.getDefault(), "temperature"),
createBinding(NightLight.getDefault(), "gamma")
], (temp, gamma) => `${temp === NightLight.getDefault().identityTemperature ?
tr("control_center.tiles.night_light.default_desc") : `${temp}K`} ${
gamma < NightLight.getDefault().maxGamma ? `(${gamma}%)` : ""}`
)}
visible={isInstalled("hyprsunset")}
onToggledOff={() => NightLight.getDefault().identity = true}
onToggledOn={() => NightLight.getDefault().identity = false}
enableOnClickMore={true}
onClickMore={() => TilesPages?.toggle(PageNightLight)}
toggleState={createBinding(NightLight.getDefault(), "identity").as(identity => !identity)}
/>
@@ -0,0 +1,31 @@
import { Tile } from "./Tile";
import { Recording } from "../../../scripts/recording";
import { tr } from "../../../i18n/intl";
import { isInstalled, time } from "../../../scripts/utils";
import { createBinding, createComputed } from "ags";
export const TileRecording = () =>
<Tile title={tr("control_center.tiles.recording.title")}
description={createComputed([
createBinding(Recording.getDefault(), "recording"),
time
], (recording, dateTime) => {
if(!recording || !Recording.getDefault().startedAt)
return tr("control_center.tiles.recording.disabled_desc") || "Start recording";
const startedAtSeconds = dateTime.to_unix() - Recording.getDefault().startedAt!;
if(startedAtSeconds <= 0) return "00:00";
const minutes = Math.floor(startedAtSeconds / 60);
const seconds = Math.floor(startedAtSeconds % 60);
return `${ minutes < 10 ? `0${minutes}` : minutes }:${ seconds < 10 ? `0${seconds}` : seconds }`;
})}
icon={"media-record-symbolic"}
visible={isInstalled("wf-recorder")}
onToggledOff={() => Recording.getDefault().stopRecording()}
onToggledOn={() => Recording.getDefault().startRecording()}
toggleState={createBinding(Recording.getDefault(), "recording")}
iconSize={16}
/>;
+198
View File
@@ -0,0 +1,198 @@
import { Gtk } from "ags/gtk4";
import { tr } from "../../../i18n/intl";
import { Accessor, createBinding, createComputed, createState, getScope, onCleanup } from "ags";
import { omitObjectKeys, variableToBoolean } from "../../../scripts/utils";
import GObject, { property, register, signal } from "ags/gobject";
import Pango from "gi://Pango?version=1.0";
export { Tile };
@register({ GTypeName: "Tile" })
class Tile extends Gtk.Box {
@signal(Boolean) toggled(_state: boolean) {}
@signal() enabled() {}
@signal() disabled() {}
@signal() clicked() {}
@property(String)
public icon: string;
@property(String)
public title: string;
@property(String)
public description: string = "";
@property(Boolean)
public enableOnClicked: boolean = true;
@property(Boolean)
public state: boolean = false;
declare $signals: Gtk.Box.SignalSignatures & {
"toggled": (_state: boolean) => void;
"enabled": () => void;
"disabled": () => void;
"clicked": () => void;
};
public enable(): void {
if(this.state) return;
this.emit("toggled", true);
this.emit("enabled");
this.state = true;
}
public disable(): void {
if(!this.state) return;
this.emit("toggled", false);
this.emit("disabled");
this.state = false;
}
constructor(props: Omit<Gtk.Box.ConstructorProps, "orientation"> & {
icon: string;
title: string;
description?: string;
state?: boolean;
enableOnClicked?: boolean;
}) {
super(omitObjectKeys(props, [
"icon",
"title",
"description",
"state",
"enableOnClicked"
]));
this.icon = props.icon;
this.title = props.title;
if(props.description != null)
this.description = props.description;
if(props.state != null)
this.state = props.state;
if(props.enableOnClicked != null)
this.enableOnClicked = props.enableOnClicked;
const connections = new Map<GObject.Object, number>();
const gestureClick = Gtk.GestureClick.new();
this.add_controller(gestureClick);
connections.set(gestureClick, gestureClick.connect("released", () => {
this.emit("clicked");
if(this.enableOnClicked && !this.state)
this.enable();
return true;
}));
this.prepend(
<Gtk.Box hexpand={false} vexpand>
<Gtk.Image iconName={createBinding(this, "icon")} />
</Gtk.Box> as Gtk.Box
);
this.append(
<Gtk.Box class={"content"} orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"title"} label={createBinding(this, "title")} />
<Gtk.Label class={"description"} label={createBinding(this, "description")} />
</Gtk.Box> as Gtk.Box
);
getScope()?.onCleanup(() => connections.forEach((id, obj) => obj.disconnect(id)));
}
emit<Signal extends keyof typeof this.$signals>(
signal: Signal,
...args: Parameters<(typeof this.$signals)[Signal]>
): void {
super.emit(signal, ...args);
}
connect<Signal extends keyof typeof this.$signals>(
signal: Signal,
callback: (typeof this.$signals)[Signal]
): number {
return super.connect(signal, callback);
}
}
export function TileFun(props: TileProps): Gtk.Widget {
const subs: Array<() => void> = [];
const [toggled, setToggled] = createState(((props.toggleState instanceof Accessor) ?
props.toggleState.get()
: props.toggleState) ?? false);
(props.toggleState instanceof Accessor) && subs.push(
props.toggleState.subscribe(() =>
setToggled((props.toggleState as Accessor<boolean>).get() ?? false))
);
onCleanup(() => subs.forEach(s => s()));
return <Gtk.Box hexpand visible={props.visible} onUnmap={props.onUnmap}
canFocus focusable={false} class={
(props.class instanceof Accessor) ?
createComputed([props.class, toggled], (clss, isToggled) =>
`tile ${clss} ${isToggled ? "toggled" : ""} ${
props.onClickMore ? "has-more" : ""
}`
)
: toggled.as(isToggled =>
`tile ${props.class ? props.class : ""} ${isToggled ? "toggled" : ""} ${
props.onClickMore ? "has-more" : ""
}`
)
}>
<Gtk.Button class={"toggle-button"} onClicked={() => {
if(toggled.get()) {
setToggled(false);
props.onToggledOff?.();
return;
}
setToggled(true);
props.onToggledOn?.();
}}>
<Gtk.Box class={"content"} hexpand={true} vexpand={true}>
{props.icon && <Gtk.Image class={"icon"} iconName={props.icon} css={
(props.iconSize instanceof Accessor) ?
props.iconSize.as(size => `font-size: ${size}px;`)
: (props.iconSize ?
`font-size: ${props.iconSize ?? 16}px;`
: undefined)
} />}
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} class={"text"} vexpand={true} hexpand={true}
valign={Gtk.Align.CENTER}>
<Gtk.Label class={"title"} xalign={0} halign={Gtk.Align.START} ellipsize={Pango.EllipsizeMode.END}
label={props.title} />
{props.description && <Gtk.Label class={"description"} ellipsize={Pango.EllipsizeMode.END}
visible={variableToBoolean(props.description)} xalign={0} label={
(props.description instanceof Accessor) ?
props.description.as(str => str ?? "")
: (props.description ?? "")
} halign={Gtk.Align.START}
/>}
</Gtk.Box>
</Gtk.Box>
</Gtk.Button>
<Gtk.Button class={"more icon"} iconName={"go-next-symbolic"} widthRequest={32}
visible={Boolean(props.onClickMore)} halign={Gtk.Align.END} onClicked={() => {
((props.enableOnClickMore instanceof Accessor) ?
props.enableOnClickMore.get()
: props.enableOnClickMore) && props.onToggledOn?.();
props.onClickMore?.();
}} tooltipText={tr("control_center.tiles.more")} />
</Gtk.Box> as Gtk.Widget;
}
+85
View File
@@ -0,0 +1,85 @@
import { Astal, Gdk, Gtk } from "ags/gtk4";
import { execApp, getAppIcon, getApps, getAstalApps } from "../scripts/apps";
import { getPopupWindowContainer, PopupWindow } from "../widget/PopupWindow";
import AstalApps from "gi://AstalApps";
import Pango from "gi://Pango?version=1.0";
import { createState, For } from "ags";
import { escapeUnintendedMarkup } from "../scripts/utils";
const ignoredKeys = [
Gdk.KEY_Right,
Gdk.KEY_Down,
Gdk.KEY_Up,
Gdk.KEY_Shift_L,
Gdk.KEY_Shift_R,
Gdk.KEY_Shift_Lock,
Gdk.KEY_Left,
Gdk.KEY_Return,
Gdk.KEY_space
];
export const AppsWindow = (mon: number) => {
const [results, setResults] = createState(getApps() as Array<AstalApps.Application>);
return <PopupWindow namespace="apps-window" layer={Astal.Layer.OVERLAY}
exclusivity={Astal.Exclusivity.IGNORE} monitor={mon} marginTop={64}
class={"apps-window"} orientation={Gtk.Orientation.VERTICAL}
cssBackgroundWindow="background: rgba(0, 0, 0, .2);"
actionKeyPressed={(self, key) => {
const entry = getPopupWindowContainer(self).get_first_child()!
.get_first_child()!.get_first_child()! as Gtk.SearchEntry;
for(const ignoredKey of ignoredKeys)
if(key === ignoredKey) return
entry.grab_focus();
}}>
<Gtk.Box hexpand={false} halign={Gtk.Align.CENTER}>
<Gtk.SearchEntry hexpand={false} onSearchChanged={(self) => {
setResults(getAstalApps().fuzzy_query(self.text.trim()));
}} onStopSearch={(self) => (self.get_root() as Astal.Window)?.close()} />
</Gtk.Box>
<Gtk.ScrolledWindow vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
hscrollbarPolicy={Gtk.PolicyType.NEVER} overlayScrolling
propagateNaturalHeight={false} hexpand vexpand>
<Gtk.Box hexpand={false} vexpand={false}>
<Gtk.FlowBox rowSpacing={60} columnSpacing={60} activateOnSingleClick
minChildrenPerLine={1} homogeneous onChildActivated={(_, child) =>
child.get_child()!.activate() // pass activation to button
}>
<For each={results}>
{(app) =>
<Gtk.Button heightRequest={150} tooltipMarkup={`${
escapeUnintendedMarkup(app.name)}${app.description ?
`\n<span foreground="#7f7f7f">${
escapeUnintendedMarkup(app.description)
}</span>`
: ""}`
} onActivate={(self) => {
execApp(app);
(self.get_root() as Astal.Window)?.close();
}} onClicked={(self) => {
execApp(app);
(self.get_root() as Astal.Window)?.close();
}}>
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} valign={Gtk.Align.CENTER}
hexpand={false} vexpand={false}>
<Gtk.Image iconName={getAppIcon(app) ?? "application-x-executable"}
iconSize={Gtk.IconSize.LARGE} vexpand={false} class={"app-icon"} />
<Gtk.Label ellipsize={Pango.EllipsizeMode.END} label={app.name}
valign={Gtk.Align.END} maxWidthChars={30} class={"app-name"} />
</Gtk.Box>
</Gtk.Button>
}
</For>
</Gtk.FlowBox>
</Gtk.Box>
</Gtk.ScrolledWindow>
</PopupWindow>
}
+43
View File
@@ -0,0 +1,43 @@
import { Astal, Gtk } from "ags/gtk4";
import { Tray } from "../widget/bar/Tray";
import { Workspaces } from "../widget/bar/Workspaces";
import { FocusedClient } from "../widget/bar/FocusedClient";
import { Media } from "../widget/bar/Media";
import { Apps } from "../widget/bar/Apps";
import { Clock } from "../widget/bar/Clock";
import { Status } from "../widget/bar/Status";
export const Bar = (mon: number) => {
const widgetSpacing = 4;
return <Astal.Window namespace={"top-bar"} layer={Astal.Layer.TOP}
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT}
exclusivity={Astal.Exclusivity.EXCLUSIVE} heightRequest={46} monitor={mon}
visible={true} canFocus={false}>
<Gtk.Box class={"bar-container"}>
<Gtk.CenterBox class={"bar-centerbox"} hexpand>
<Gtk.Box class={"widgets-left"} homogeneous={false}
halign={Gtk.Align.START} spacing={widgetSpacing}
$type="start">
<Apps />
<Workspaces />
<FocusedClient />
</Gtk.Box>
<Gtk.Box class={"widgets-center"} homogeneous={false}
spacing={widgetSpacing} halign={Gtk.Align.CENTER}
$type="center">
<Clock />
<Media />
</Gtk.Box>
<Gtk.Box class={"widgets-right"} homogeneous={false}
spacing={widgetSpacing} halign={Gtk.Align.END}
$type="end">
<Tray />
<Status />
</Gtk.Box>
</Gtk.CenterBox>
</Gtk.Box>
</Astal.Window>
}
+34
View File
@@ -0,0 +1,34 @@
import { Astal, Gtk } from "ags/gtk4";
import { Separator } from "../widget/Separator";
import { PopupWindow } from "../widget/PopupWindow";
import { BigMedia } from "../widget/center-window/BigMedia";
import { time } from "../scripts/utils";
import { player } from "../widget/bar/Media";
export const CenterWindow = (mon: number) =>
<PopupWindow namespace={"center-window"} marginTop={10} monitor={mon}
halign={Gtk.Align.CENTER} valign={Gtk.Align.START}>
<Gtk.Box class={"center-window-container"} spacing={6}>
<Gtk.Box class={"left"} orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Box class={"datetime"} orientation={Gtk.Orientation.VERTICAL}
halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER}
vexpand={true}>
<Gtk.Label class={"time"} label={time(t => t.format("%H:%M")!)} />
<Gtk.Label class={"date"} label={time(d => d.format("%A, %B %d")!)} />
</Gtk.Box>
<Gtk.Box class={"calendar-box"} hexpand={true} valign={Gtk.Align.START}>
<Gtk.Calendar showHeading={true} showDayNames={true}
showWeekNumbers={false}
/>
</Gtk.Box>
</Gtk.Box>
<Separator orientation={Gtk.Orientation.HORIZONTAL} cssColor="gray"
margin={5} spacing={8} alpha={.3} visible={player(pl => pl.available)}
/>
<BigMedia />
</Gtk.Box>
</PopupWindow> as Astal.Window;
+25
View File
@@ -0,0 +1,25 @@
import { Astal, Gtk } from "ags/gtk4";
import { PopupWindow } from "../widget/PopupWindow";
import { QuickActions } from "../widget/control-center/QuickActions";
import { NotifHistory } from "../widget/control-center/NotifHistory";
import { Tiles } from "../widget/control-center/Tiles";
import { Sliders } from "../widget/control-center/Sliders";
export const ControlCenter = (mon: number) =>
<PopupWindow namespace={"control-center"} class={"control-center"}
halign={Gtk.Align.END} valign={Gtk.Align.START} layer={Astal.Layer.OVERLAY}
marginTop={10} marginRight={10} marginBottom={10} monitor={mon}
widthRequest={395}>
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={16} vexpand={false}>
<Gtk.Box class={"control-center-container"} vexpand={false}
orientation={Gtk.Orientation.VERTICAL} spacing={12}>
<QuickActions />
<Tiles />
<Sliders />
</Gtk.Box>
<NotifHistory />
</Gtk.Box>
</PopupWindow> as Astal.Window;
+42
View File
@@ -0,0 +1,42 @@
import { Astal, Gtk } from "ags/gtk4";
import { createBinding, For } from "ags";
import { Notifications } from "../scripts/notifications";
import { NotificationWidget } from "../widget/Notification";
import AstalNotifd from "gi://AstalNotifd?version=0.1";
import Adw from "gi://Adw?version=1";
const size = 450;
export const FloatingNotifications = (mon: number) =>
<Astal.Window namespace={"floating-notifications"} monitor={mon} layer={Astal.Layer.OVERLAY}
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT} exclusivity={Astal.Exclusivity.NORMAL}
resizable={false} widthRequest={450}>
<Gtk.Box class={"floating-notifications-container"} spacing={12}
orientation={Gtk.Orientation.VERTICAL}>
<For each={createBinding(Notifications.getDefault(), "notifications")}>
{(notif: AstalNotifd.Notification) =>
<Adw.Clamp maximumSize={size}>
<Gtk.Box class={"float-notification"} widthRequest={size} vexpand={false}>
{/*
Why is holdOnHover disabled: the shell for some reason crashes
when removing the notification on hover-lost 💔
*/}
<NotificationWidget notification={notif} showTime={false}
actionClose={() => Notifications.getDefault().removeNotification(notif)}
holdOnHover={false} actionClicked={() => {
const viewAction = notif.actions.filter(action =>
action.label.toLowerCase() === "view")?.[0];
viewAction && notif.invoke(viewAction.id);
}}
/>
</Gtk.Box>
</Adw.Clamp>
}
</For>
</Gtk.Box>
</Astal.Window> as Astal.Window;
+128
View File
@@ -0,0 +1,128 @@
import { Astal, Gdk, Gtk } from "ags/gtk4";
import { execAsync } from "ags/process";
import { generalConfig } from "../app";
import { AskPopup } from "../widget/AskPopup";
import { Notifications } from "../scripts/notifications";
import { NightLight } from "../scripts/nightlight";
import { time } from "../scripts/utils";
import GObject from "ags/gobject";
import AstalNotifd from "gi://AstalNotifd";
import Gio from "gi://Gio?version=2.0";
const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor;
export const LogoutMenu = (mon: number) =>
<Astal.Window namespace={"logout-menu"} anchor={TOP | LEFT | RIGHT | BOTTOM}
layer={Astal.Layer.OVERLAY} exclusivity={Astal.Exclusivity.IGNORE}
keymode={Astal.Keymode.EXCLUSIVE} monitor={mon} $={(self) => {
const conns: Map<GObject.Object, number> = new Map();
const controllerKey = Gtk.EventControllerKey.new();
self.add_controller(controllerKey);
conns.set(controllerKey, controllerKey.connect("key-released", (_, keyval) => {
if(keyval === Gdk.KEY_Escape)
self.close();
}));
conns.set(self, self.connect("close-request", () => conns.forEach((id, obj) =>
obj.disconnect(id))));
}}>
<Gtk.Box class={"logout-menu-container"} orientation={Gtk.Orientation.VERTICAL}
$={(self) => {
const conns: Map<GObject.Object, number> = new Map();
const gestureClick = Gtk.GestureClick.new();
self.add_controller(gestureClick);
gestureClick.set_button(0);
conns.set(gestureClick, gestureClick.connect("released", (gesture) => {
if(gesture.get_current_button() === Gdk.BUTTON_PRIMARY) {
(self.get_root() as Astal.Window|null)?.close();
return true;
}
}));
conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) =>
obj.disconnect(id))));
}}>
<Gtk.Box class={"top"} hexpand vexpand={false}
orientation={Gtk.Orientation.VERTICAL} valign={Gtk.Align.START}>
<Gtk.Label class={"time"} label={time(t => t.format("%H:%M")!)} />
<Gtk.Label class={"date"} label={time(d => d.format("%A, %B %d %Y")!)} />
</Gtk.Box>
<Gtk.Box class={"button-row"} homogeneous heightRequest={360} valign={Gtk.Align.CENTER}
vexpand>
<Gtk.Button class={"poweroff"} iconName={"system-shutdown-symbolic"}
onClicked={() => AskPopup({
title: "Power Off",
text: "Are you sure you want to power off? Unsaved work will be lost.",
onAccept: () => {
generalConfig.getProperty("night_light.save_on_shutdown", "boolean") &&
NightLight.getDefault().saveData();
execAsync("systemctl poweroff");
}
})}
/>
<Gtk.Button class={"reboot"} iconName={"arrow-circular-top-right-symbolic"}
onClicked={() => AskPopup({
title: "Reboot",
text: "Are you sure you want to Reboot? Unsaved work will be lost.",
onAccept: () => {
generalConfig.getProperty("night_light.save_on_shutdown", "boolean") &&
NightLight.getDefault().saveData();
execAsync("systemctl reboot");
}
})}
/>
<Gtk.Button class={"suspend"} iconName={"weather-clear-night-symbolic"}
onClicked={() => AskPopup({
title: "Suspend",
text: "Are you sure you want to Suspend?",
onAccept: () => execAsync("systemctl suspend")
})}
/>
<Gtk.Button class={"logout"} iconName={"system-log-out-symbolic"}
onClicked={() => AskPopup({
title: "Log out",
text: "Are you sure you want to log out? Your session will be ended.",
onAccept: () => {
generalConfig.getProperty("night_light.save_on_shutdown", "boolean") &&
NightLight.getDefault().saveData();
execAsync(`hyprctl dispatch exit`).catch((err: Gio.IOErrorEnum) =>
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Couldn't exit Hyprland",
body: `An error occurred and colorshell couldn't exit Hyprland. Stderr: \n${
err.message ? `${err.message}\n` : ""}${err.stack}`,
urgency: AstalNotifd.Urgency.NORMAL,
actions: [{
text: "Report Issue on colorshell",
onAction: () => execAsync(
`xdg-open https://github.com/retrozinndev/colorshell/issues/new`
).catch((err: Gio.IOErrorEnum) =>
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Couldn't open link",
body: `Do you have \`xdg-utils\` installed? Stderr: \n${
err.message ? `${err.message}\n` : ""}${err.stack}`
})
)
}]
})
)
}
})}
/>
</Gtk.Box>
</Gtk.Box>
</Astal.Window> as Astal.Window;
+64
View File
@@ -0,0 +1,64 @@
import { Astal, Gtk } from "ags/gtk4";
import { createBinding, createState } from "ags";
import { Wireplumber } from "../scripts/volume";
import { Windows } from "../windows";
import { Time, timeout } from "ags/time";
import Pango from "gi://Pango?version=1.0";
export enum OSDModes {
SINK,
BRIGHTNESS,
NONE
}
const [osdMode, setOSDMode] = createState(OSDModes.NONE);
let osdTimer: (Time|undefined), osdTimeout = 3500;
export const OSD = (mon: number) => {
if(osdMode.get() === OSDModes.NONE)
setOSDMode(OSDModes.SINK);
return <Astal.Window namespace={"osd"} class={"osd-window"} layer={Astal.Layer.OVERLAY}
anchor={Astal.WindowAnchor.BOTTOM} focusable={false} marginBottom={80} monitor={mon}>
<Gtk.Box class={"osd"}>
<Gtk.Image class={"icon"} iconName={createBinding(Wireplumber.getDefault().getDefaultSink(),
"volumeIcon").as(icon => !Wireplumber.getDefault().isMutedSink() &&
Wireplumber.getDefault().getSinkVolume() > 0 ? icon : "audio-volume-muted-symbolic")}
/>
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} class={"volume"} vexpand={true} hexpand={true}>
<Gtk.Label class={"device"} label={createBinding(Wireplumber.getDefault().getDefaultSink(),
"description").as(description => description ?? "Speaker")}
ellipsize={Pango.EllipsizeMode.END}
/>
<Gtk.LevelBar class={"levelbar"} value={createBinding(
Wireplumber.getDefault().getDefaultSink(), "volume")}
maxValue={Wireplumber.getDefault().getMaxSinkVolume() / 100}
/>
</Gtk.Box>
</Gtk.Box>
</Astal.Window>
}
export function triggerOSD() {
if(Windows.getDefault().isOpen("control-center")) return;
Windows.getDefault().open("osd");
if(!osdTimer) {
osdTimer = timeout(osdTimeout, () => {
osdTimer = undefined;
Windows.getDefault().close("osd");
});
return;
}
osdTimer.cancel();
osdTimer = timeout(osdTimeout, () => {
Windows.getDefault().close("osd");
osdTimer = undefined;
});
}
+321
View File
@@ -0,0 +1,321 @@
import { Astal } from "ags/gtk4";
import { Bar } from "./window/Bar";
import { variableToBoolean } from "./scripts/utils";
import { OSD } from "./window/OSD";
import { ControlCenter } from "./window/ControlCenter";
import { FloatingNotifications } from "./window/FloatingNotifications";
import { CenterWindow } from "./window/CenterWindow";
import { LogoutMenu } from "./window/LogoutMenu";
import { AppsWindow } from "./window/AppsWindow";
import { Scope } from "/usr/share/ags/js/gnim/src/jsx/scope";
import { Shell } from "./app";
import GObject, { getter, register, signal } from "ags/gobject";
import AstalHyprland from "gi://AstalHyprland";
export { Windows };
export type WindowInstance = { instance?: Astal.Window, connections: Array<number> };
export type WindowData = {
create: () => (Astal.Window | Array<Astal.Window>);
instance?: WindowInstance | Array<WindowInstance>;
status?: "open" | "closed";
};
/**
* Windowing System
* Possible actions: getting window states, close, open, toggle windows and
* registering windows.
* Also contains util functions to create dynamic windows, opening the window only on focused
* monitor, or all available monitors!
*/
@register()
class Windows extends GObject.Object {
private static instance: (Windows | null);
#windows: Record<string, WindowData> = {
"bar": { create: this.createWindowForMonitors(Bar) },
"osd": { create: this.createWindowForFocusedMonitor(OSD), },
"control-center": { create: this.createWindowForFocusedMonitor(ControlCenter), },
"center-window": { create: this.createWindowForFocusedMonitor(CenterWindow), },
"logout-menu": { create: this.createWindowForFocusedMonitor(LogoutMenu), },
"floating-notifications": { create: this.createWindowForFocusedMonitor(FloatingNotifications), },
"apps-window": { create: this.createWindowForFocusedMonitor(AppsWindow) }
};
@signal(String) windowOpen(_name: string) {}
@signal(String) windowClosed(_name: string) {}
@getter(Object)
get windows(): object { return this.#windows; }
@getter(Array)
get openWindows(): Array<string> {
return Object.keys(this.#windows).filter((key) =>
this.#windows[key].status === "open");
}
constructor() {
super();
// Listen to monitor events
const hyprConnections = [
AstalHyprland.get_default().connect("monitor-added", () =>
this.reopen()),
AstalHyprland.get_default().connect("monitor-removed", () =>
AstalHyprland.get_default().get_monitors().length > 0 &&
this.reopen())
];
Shell.getDefault().scope.run(() => {
// open windows with the "open" status on startup
Object.keys(this.#windows).filter((key) =>
this.#windows[key].status === "open"
).forEach(name => {
this.open(name, true);
console.log(`Windows: opening window \`${name}\` on startup`);
});
});
Shell.getDefault().scope.onCleanup(() => {
hyprConnections.forEach(id =>
GObject.signal_handler_is_connected(AstalHyprland.get_default(), id) &&
AstalHyprland.get_default().disconnect(id)
);
this.openWindows.forEach(name => this.disconnectWindow(name));
});
}
private disconnectWindow(name: string) {
if(!variableToBoolean(this.#windows[name]?.instance) || !this.#windows[name]) {
console.error(`Windows: couldn't disconnect window's connections: either the window \`${name
}\` doesn't exist in the windows list or it has no valid instance to disconnect signals from(not open)`);
return;
}
const window = this.#windows[name].instance!;
if(Array.isArray(window)) {
window.forEach(win => {
this._disconnectAllFromInstance(win.instance!, win.connections!)
win.connections = [];
});
return;
}
this._disconnectAllFromInstance(window.instance!, window.connections!);
window.connections = [];
}
private _disconnectAllFromInstance(instance: GObject.Object, connections: Array<number>): void {
connections.forEach(id =>
GObject.signal_handler_is_connected(instance, id) &&
instance.disconnect(id));
}
private hasConnections(name: string): boolean {
if(!this.openWindows.includes(name))
return false;
const window = this.#windows[name].instance;
if(!window) return false;
if(Array.isArray(window)) {
for(const win of window) {
if(win.connections?.length > 0)
return true;
}
return false;
}
return window.connections?.length > 0;
}
private connectWindow(name: string) {
if(this.hasConnections(name)) {
console.log(`Windows: skipped connecting window: \`${name}\`. Already connected`);
return;
}
if(!this.openWindows.includes(name)) {
console.log(`Windows: \`${name}\` is not open, will not connect`);
return;
}
const window = this.#windows[name as keyof typeof this.windows];
if(!window || !window.instance) {
console.error(`Windows: Either \`${name}\` does not exist in the window list or it doesn't have a valid instance. Please add the window before trying to manage it here`);
return;
}
if(Array.isArray(window.instance)) {
window.instance.forEach(inst => inst.connections = [
inst.instance!.connect("close-request", () => {
this.disconnectWindow(name);
delete window.instance;
window.status = "closed";
this.notify("open-windows");
})
]);
return;
}
window.instance.connections = [
window.instance.instance!.connect("close-request", () => {
this.disconnectWindow(name);
delete window.instance;
window.status = "closed";
this.notify("open-windows");
})
];
}
public static getDefault(): Windows {
if(!this.instance)
this.instance = new Windows();
return this.instance;
}
/**
* Creates a window instance for every monitor connected
* @param create generates the window. use provided monitor number in the returned window
* @returns a function that when called, returns Array<Astal.Window>
* @throws Error if there are no monitors connected
*/
public createWindowForMonitors(create: (mon: number, scope: Scope) => GObject.Object|Astal.Window): (() => Array<Astal.Window>) {
const monitors = AstalHyprland.get_default().get_monitors();
if(monitors.length < 1)
throw new Error("Couldn't create window for monitors", {
cause: "No monitors connected on Hyprland"
});
// create a scope for every window generator function and dispose on ::close-request
return () => monitors.map(mon => {
const scope = new Scope(null);
return scope.run(() => {
const instance = create(mon.id, scope) as Astal.Window;
const connection: number = instance.connect("close-request", () =>
scope.dispose());
scope.onCleanup(() => instance.disconnect(connection));
return instance;
})
})
}
/**
* Creates a window instance for focused monitor only
* @param create generates the window. use provided monitor number in the returned window
* @returns a function that when called, returns a Astal.Window instance
* @throws Error if no focused monitor is found
*/
public createWindowForFocusedMonitor(create: (mon: number, scope: Scope) => GObject.Object|Astal.Window): (() => Astal.Window) {
const focusedMonitor = this.getFocusedMonitorId();
if(focusedMonitor == null)
throw new Error("Couldn't create window for focused monitor", {
cause: `No focused monitor found (${typeof focusedMonitor})`
});
return () => {
const scope = new Scope(null);
return scope.run(() => {
const instance = create(focusedMonitor, scope) as Astal.Window;
const connection: number = instance.connect("close-request", () =>
scope.dispose());
scope.onCleanup(() => instance.disconnect(connection));
return instance;
});
}
}
public addWindow(name: string, create: () => Astal.Window|Array<Astal.Window>): void {
this.#windows[name] = { create };
}
public hasWindow(name: string): boolean {
return Boolean(this.windows?.[name as keyof typeof this.windows]);
}
public getWindows(): Array<(() => (Astal.Window | Array<Astal.Window>))> {
return Object.values(this.windows);
}
public getFocusedMonitorId(): (number|null) {
return AstalHyprland.get_default().get_monitors().filter(mon => mon.focused)?.[0]?.id ?? null;
}
public isOpen(name: string): boolean {
return this.openWindows.includes(name);
}
public open(name: string, ignoreOpenStatus: boolean = false): void {
if(this.isOpen(name) && !ignoreOpenStatus) return;
const window = this.#windows[name];
if(!window) {
console.error(`Windows: cannot open a window (\`${name}\`) that is not registered/doesn't exist.`);
return;
}
this.#windows[name].status = "open";
const windowInstance = window.create();
if(Array.isArray(windowInstance)) {
window.instance = windowInstance.map(wi => {
wi.show();
return { instance: wi, connections: [] };
});
} else {
window.instance = { instance: windowInstance, connections: [] };
windowInstance.show();
}
this.connectWindow(name);
this.emit("window-open", name);
this.notify("open-windows");
}
public close(name: string): void {
if(!this.isOpen(name)) return;
this.disconnectWindow(name);
const window = this.#windows[name];
if(Array.isArray(window.instance))
window.instance.map(inst => inst.instance!.close());
else
window.instance!.instance!.close();
this.#windows[name].status = "closed";
this.emit("window-closed", name);
this.notify("open-windows");
}
public toggle(name: string): void {
this.isOpen(name) ? this.close(name) : this.open(name);
}
public closeAll(): void {
this.openWindows.forEach(name => this.close(name));
}
public reopen(): void {
const openWins = [ ...this.openWindows ];
this.closeAll();
openWins.forEach(name => this.open(name));
}
}