✨ 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
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
@girs/
|
||||
build/
|
||||
|
||||
pnpm-lock.yaml
|
||||
@@ -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 ]);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
overrides:
|
||||
ags: link:../../../../usr/share/ags/js
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}`
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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()
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 "<";
|
||||
case ">": return ">";
|
||||
case "&": return "&";
|
||||
case "\"": return """;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
})();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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")}
|
||||
/>;
|
||||
@@ -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"
|
||||
)}
|
||||
/>;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
]
|
||||
});
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
]
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||