Initial commit: Omeron modular Hyprland setup framework
- Modular installer with gum-based TUI - Fresh-install detection with auto GPU driver selection - Preflight module for system detection (Intel/AMD/NVIDIA) - Core modules: packages, dotfiles, services, SDDM - Optional software installer (Obsidian, Neovim, VS Code, etc.) - Homelab config module with dynamic AGS integration - Two complete themes: Forest Neon and Rose Night - 19 Hyprland control scripts + 4 AGS widgets - Idempotent dotfile deployment with automatic backup - YAML-based configuration, extensible module system - Full logging to ~/.local/share/omeron/
This commit is contained in:
312
dotfiles/hypr/ags/homelab.css
Normal file
312
dotfiles/hypr/ags/homelab.css
Normal file
@@ -0,0 +1,312 @@
|
||||
* {
|
||||
all: unset;
|
||||
font-family: "JetBrainsMono Nerd Font", "Noto Sans", sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.homelab-window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.shell {
|
||||
border: 1px solid rgba(205, 214, 244, 0.16);
|
||||
border-radius: 16px;
|
||||
background: rgba(20, 20, 30, 0.97);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
min-width: 520px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
min-width: 218px;
|
||||
padding: 14px 10px;
|
||||
border-right: 1px solid rgba(205, 214, 244, 0.10);
|
||||
background: rgba(14, 16, 24, 0.72);
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 8px 8px 14px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
color: #00ff9c;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand-subtitle,
|
||||
.subtitle,
|
||||
.row-subtitle {
|
||||
color: #a6adc8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
min-height: 34px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 8px;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.nav-button:hover,
|
||||
.nav-button:focus {
|
||||
background: rgba(0, 255, 156, 0.14);
|
||||
}
|
||||
|
||||
.nav-button.active {
|
||||
background: rgba(0, 255, 156, 0.22);
|
||||
color: #00ff9c;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.page-scroll {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 750;
|
||||
color: #00ff9c;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
entry {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(205, 214, 244, 0.14);
|
||||
border-radius: 10px;
|
||||
background: rgba(40, 40, 55, 0.82);
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
entry:focus {
|
||||
border-color: rgba(0, 255, 156, 0.78);
|
||||
}
|
||||
|
||||
.grid,
|
||||
.live-strip,
|
||||
.service-grid {
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.chart-card,
|
||||
.service-card,
|
||||
.table-row {
|
||||
border: 1px solid rgba(205, 214, 244, 0.10);
|
||||
border-radius: 8px;
|
||||
background: rgba(40, 40, 55, 0.62);
|
||||
}
|
||||
|
||||
.card {
|
||||
min-width: 220px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.wide-card {
|
||||
min-width: 0;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
min-width: 0;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
min-width: 260px;
|
||||
min-height: 98px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.chart-card.cpu {
|
||||
border-color: rgba(0, 255, 156, 0.22);
|
||||
}
|
||||
|
||||
.chart-card.memory {
|
||||
border-color: rgba(137, 180, 250, 0.26);
|
||||
}
|
||||
|
||||
.chart-card.load {
|
||||
border-color: rgba(249, 226, 175, 0.24);
|
||||
}
|
||||
|
||||
.chart-card.docker,
|
||||
.chart-card.network {
|
||||
border-color: rgba(116, 199, 236, 0.24);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: #00cc88;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.compact,
|
||||
.row-subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.percent-label,
|
||||
.chart-value {
|
||||
color: #f9e2af;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.sparkline {
|
||||
color: #00ff9c;
|
||||
font-size: 25px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.meter {
|
||||
min-height: 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(205, 214, 244, 0.10);
|
||||
}
|
||||
|
||||
.meter-fill {
|
||||
min-height: 8px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(to right, #00ff9c, #89b4fa);
|
||||
}
|
||||
|
||||
.alerts {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.alert,
|
||||
.pill {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(205, 214, 244, 0.10);
|
||||
color: #cdd6f4;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.alert.ok,
|
||||
.pill.ok {
|
||||
background: rgba(0, 255, 156, 0.16);
|
||||
color: #00ff9c;
|
||||
}
|
||||
|
||||
.alert.warn,
|
||||
.pill.warn {
|
||||
background: rgba(249, 226, 175, 0.16);
|
||||
color: #f9e2af;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
min-height: 72px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.container-main {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.container-stats {
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
color: #cdd6f4;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
min-width: 250px;
|
||||
min-height: 112px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.button,
|
||||
.mini-button,
|
||||
.icon-button {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(40, 40, 55, 0.82);
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.mini-button {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.button:focus,
|
||||
.mini-button:hover,
|
||||
.mini-button:focus,
|
||||
.icon-button:hover,
|
||||
.icon-button:focus {
|
||||
background: rgba(0, 255, 156, 0.22);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: rgba(0, 255, 156, 0.24);
|
||||
color: #00ff9c;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f38ba8;
|
||||
}
|
||||
1156
dotfiles/hypr/ags/homelab.tsx
Normal file
1156
dotfiles/hypr/ags/homelab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
180
dotfiles/hypr/ags/package-manager.css
Normal file
180
dotfiles/hypr/ags/package-manager.css
Normal file
@@ -0,0 +1,180 @@
|
||||
* {
|
||||
all: unset;
|
||||
font-family: "JetBrainsMono Nerd Font", "Noto Sans", sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.package-window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.package-panel {
|
||||
min-width: 940px;
|
||||
min-height: 620px;
|
||||
border: 1px solid rgba(205, 214, 244, 0.16);
|
||||
border-radius: 16px;
|
||||
background: rgba(20, 20, 30, 0.97);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||
color: #cdd6f4;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #00ff9c;
|
||||
font-size: 21px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.muted,
|
||||
.package-meta {
|
||||
color: #a6adc8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
entry {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(205, 214, 244, 0.14);
|
||||
border-radius: 10px;
|
||||
background: rgba(40, 40, 55, 0.82);
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
entry:focus {
|
||||
border-color: rgba(0, 255, 156, 0.78);
|
||||
}
|
||||
|
||||
.button,
|
||||
.tool-button,
|
||||
.icon-button {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(40, 40, 55, 0.82);
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.button:focus,
|
||||
.tool-button:hover,
|
||||
.tool-button:focus,
|
||||
.icon-button:hover,
|
||||
.icon-button:focus {
|
||||
background: rgba(0, 255, 156, 0.22);
|
||||
}
|
||||
|
||||
.primary,
|
||||
.tool-button.active {
|
||||
background: rgba(0, 255, 156, 0.24);
|
||||
color: #00ff9c;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
min-height: 30px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(205, 214, 244, 0.10);
|
||||
border-radius: 8px;
|
||||
background: rgba(40, 40, 55, 0.48);
|
||||
}
|
||||
|
||||
.operation-panel {
|
||||
min-height: 220px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(205, 214, 244, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 10, 16, 0.58);
|
||||
}
|
||||
|
||||
.operation-header,
|
||||
.operation-input-row {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.operation-title {
|
||||
color: #00ff9c;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.operation-scroll {
|
||||
min-height: 142px;
|
||||
border: 1px solid rgba(205, 214, 244, 0.10);
|
||||
border-radius: 8px;
|
||||
background: rgba(4, 4, 8, 0.72);
|
||||
}
|
||||
|
||||
.operation-output {
|
||||
padding: 10px;
|
||||
color: #cdd6f4;
|
||||
font-family: "JetBrainsMono Nerd Font", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.results-scroll {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.package-row {
|
||||
min-height: 76px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(205, 214, 244, 0.10);
|
||||
border-radius: 8px;
|
||||
background: rgba(40, 40, 55, 0.62);
|
||||
}
|
||||
|
||||
.package-row:hover {
|
||||
border-color: rgba(0, 255, 156, 0.35);
|
||||
background: rgba(40, 40, 55, 0.82);
|
||||
}
|
||||
|
||||
.package-name {
|
||||
color: #cdd6f4;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.package-desc {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.repo-pill,
|
||||
.installed-pill {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: rgba(116, 199, 236, 0.16);
|
||||
color: #89dceb;
|
||||
}
|
||||
|
||||
.installed-pill {
|
||||
background: rgba(0, 255, 156, 0.16);
|
||||
color: #00ff9c;
|
||||
}
|
||||
|
||||
.empty {
|
||||
min-height: 180px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f38ba8;
|
||||
}
|
||||
456
dotfiles/hypr/ags/package-manager.tsx
Normal file
456
dotfiles/hypr/ags/package-manager.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import app from "ags/gtk4/app";
|
||||
import { Astal, Gtk } from "ags/gtk4";
|
||||
import { execAsync, subprocess, Process } from "ags/process";
|
||||
import GLib from "gi://GLib";
|
||||
import css from "./package-manager.css";
|
||||
|
||||
const WINDOW_MARGIN_TOP = 48;
|
||||
const ESC_KEYVAL = 65307;
|
||||
const RUNNER = `${GLib.get_home_dir()}/.config/hypr/Scripts/ags-package-runner.py`;
|
||||
|
||||
type Helper = "pacman" | "paru";
|
||||
|
||||
type PackageResult = {
|
||||
repo: string;
|
||||
name: string;
|
||||
version: string;
|
||||
installed: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
let helper: Helper = "paru";
|
||||
let query = "";
|
||||
let results: PackageResult[] = [];
|
||||
let busy = false;
|
||||
let statusMessage = "Suchbegriff eingeben und Enter druecken.";
|
||||
let errorMessage = "";
|
||||
let activeProcess: Process | null = null;
|
||||
let operationTitle = "";
|
||||
let operationOutput = "";
|
||||
let operationInput = "";
|
||||
let secretInput = false;
|
||||
let lastOutput = "";
|
||||
|
||||
function shQuote(value: string) {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function notify(message: string) {
|
||||
execAsync(["notify-send", "Pakete", message]).catch(console.error);
|
||||
}
|
||||
|
||||
function runShell(command: string) {
|
||||
return execAsync(["bash", "-lc", command]);
|
||||
}
|
||||
|
||||
function setBusy(value: boolean) {
|
||||
busy = value;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
function appendOperationOutput(data: string) {
|
||||
operationOutput = `${operationOutput}${data}`.slice(-30000);
|
||||
lastOutput = data;
|
||||
secretInput = /(\[sudo\].*password|passwort|password).*:/i.test(data);
|
||||
rebuild();
|
||||
}
|
||||
|
||||
function commandLabel(command: string[]) {
|
||||
return command.map(shQuote).join(" ");
|
||||
}
|
||||
|
||||
function helperAvailable(nextHelper: Helper) {
|
||||
return runShell(`command -v ${nextHelper} >/dev/null 2>&1`);
|
||||
}
|
||||
|
||||
function parseSearch(output: string) {
|
||||
const parsed: PackageResult[] = [];
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const header = lines[index];
|
||||
const match = header.match(/^([^/\s]+)\/([^\s]+)\s+([^\s]+)(?:\s+\[(installed[^\]]*)\])?/);
|
||||
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const descriptionLines: string[] = [];
|
||||
let next = index + 1;
|
||||
while (next < lines.length && /^\s+/.test(lines[next])) {
|
||||
descriptionLines.push(lines[next].trim());
|
||||
next += 1;
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
repo: match[1],
|
||||
name: match[2],
|
||||
version: match[3],
|
||||
installed: Boolean(match[4]),
|
||||
description: descriptionLines.join(" ") || "Keine Beschreibung.",
|
||||
});
|
||||
|
||||
index = next - 1;
|
||||
}
|
||||
|
||||
return parsed.slice(0, 80);
|
||||
}
|
||||
|
||||
function searchPackages() {
|
||||
const term = query.trim();
|
||||
if (!term) {
|
||||
results = [];
|
||||
statusMessage = "Suchbegriff fehlt.";
|
||||
rebuild();
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
errorMessage = "";
|
||||
statusMessage = `Suche mit ${helper} nach "${term}"...`;
|
||||
|
||||
helperAvailable(helper)
|
||||
.then(() => runShell(`${helper} -Ss ${shQuote(term)} 2>/dev/null || true`))
|
||||
.then(output => {
|
||||
results = parseSearch(output);
|
||||
statusMessage = results.length
|
||||
? `${results.length} Treffer fuer "${term}" mit ${helper}.`
|
||||
: `Keine Treffer fuer "${term}".`;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
results = [];
|
||||
errorMessage = `${helper} ist nicht verfuegbar oder die Suche ist fehlgeschlagen.`;
|
||||
statusMessage = "Suche fehlgeschlagen.";
|
||||
})
|
||||
.finally(() => {
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
function startOperation(title: string, command: string[]) {
|
||||
if (activeProcess) {
|
||||
statusMessage = "Es laeuft bereits ein Paketprozess.";
|
||||
rebuild();
|
||||
return;
|
||||
}
|
||||
|
||||
operationTitle = title;
|
||||
operationOutput = `$ ${commandLabel(command)}\n\n`;
|
||||
operationInput = "";
|
||||
errorMessage = "";
|
||||
statusMessage = `${title} laeuft in AGS.`;
|
||||
busy = true;
|
||||
|
||||
const process = subprocess(
|
||||
[RUNNER, "--", ...command],
|
||||
line => {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === "out") {
|
||||
appendOperationOutput(event.data || "");
|
||||
} else if (event.type === "exit") {
|
||||
const suffix = event.signaled ? `Signal ${event.code}` : `Exit ${event.code}`;
|
||||
appendOperationOutput(`\n[${suffix}]\n`);
|
||||
}
|
||||
} catch {
|
||||
appendOperationOutput(`${line}\n`);
|
||||
}
|
||||
},
|
||||
line => appendOperationOutput(`${line}\n`),
|
||||
);
|
||||
|
||||
activeProcess = process;
|
||||
process.connect("exit", (_, code: number, signaled: boolean) => {
|
||||
activeProcess = null;
|
||||
busy = false;
|
||||
secretInput = false;
|
||||
statusMessage = signaled
|
||||
? `${title} wurde beendet.`
|
||||
: code === 0
|
||||
? `${title} abgeschlossen.`
|
||||
: `${title} fehlgeschlagen (Exit ${code}).`;
|
||||
notify(statusMessage);
|
||||
rebuild();
|
||||
});
|
||||
|
||||
rebuild();
|
||||
}
|
||||
|
||||
function sendOperationInput(value = operationInput, allowEmpty = false) {
|
||||
if (!activeProcess || (!allowEmpty && value.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeProcess.writeAsync(`${JSON.stringify({ type: "input", data: `${value}\n` })}\n`).catch(error => {
|
||||
console.error(error);
|
||||
errorMessage = "Eingabe konnte nicht gesendet werden.";
|
||||
rebuild();
|
||||
});
|
||||
operationInput = "";
|
||||
secretInput = false;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
function yesAnswer() {
|
||||
return /[\[(][YyJj]\/[Nn][\])]/.test(lastOutput) ? "" : "y";
|
||||
}
|
||||
|
||||
function sendYesAnswer() {
|
||||
const answer = yesAnswer();
|
||||
appendOperationOutput(answer ? `\n> ${answer}\n` : "\n> Enter\n");
|
||||
sendOperationInput(answer, true);
|
||||
}
|
||||
|
||||
function sendNoAnswer() {
|
||||
appendOperationOutput("\n> n\n");
|
||||
sendOperationInput("n");
|
||||
}
|
||||
|
||||
function cancelOperation() {
|
||||
if (!activeProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeProcess.writeAsync(`${JSON.stringify({ type: "signal", signal: 15 })}\n`).catch(console.error);
|
||||
statusMessage = "Abbruch angefordert.";
|
||||
rebuild();
|
||||
}
|
||||
|
||||
function installPackage(pkg: PackageResult) {
|
||||
const command = helper === "pacman"
|
||||
? ["sudo", "pacman", "-S", pkg.name]
|
||||
: ["paru", "-S", pkg.name];
|
||||
|
||||
notify(`Installation gestartet: ${pkg.name}`);
|
||||
startOperation(`Installation: ${pkg.name}`, command);
|
||||
}
|
||||
|
||||
function updateSystem() {
|
||||
const command = helper === "pacman"
|
||||
? ["sudo", "pacman", "-Syu"]
|
||||
: ["paru", "-Syu"];
|
||||
|
||||
notify(`Update gestartet mit ${helper}.`);
|
||||
startOperation(`Systemupdate mit ${helper}`, command);
|
||||
}
|
||||
|
||||
function HelperButton({ id, label }: { id: Helper; label: string }) {
|
||||
return (
|
||||
<button
|
||||
class={helper === id ? "tool-button active" : "tool-button"}
|
||||
onClicked={() => {
|
||||
helper = id;
|
||||
statusMessage = `Aktiver Helper: ${helper}`;
|
||||
rebuild();
|
||||
}}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageRow({ pkg }: { pkg: PackageResult }) {
|
||||
return (
|
||||
<box class="package-row" spacing={10}>
|
||||
<box orientation={Gtk.Orientation.VERTICAL} spacing={4} hexpand>
|
||||
<box spacing={8}>
|
||||
<label class="package-name" xalign={0} label={pkg.name} />
|
||||
<label class="repo-pill" label={pkg.repo} />
|
||||
{pkg.installed ? <label class="installed-pill" label="installed" /> : <box />}
|
||||
</box>
|
||||
<label class="package-meta" xalign={0} label={pkg.version} />
|
||||
<label class="package-desc" xalign={0} wrap label={pkg.description} />
|
||||
</box>
|
||||
<button class="button primary" sensitive={!busy} onClicked={() => installPackage(pkg)} label="Installieren" />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationPanel() {
|
||||
if (!operationTitle && !operationOutput) {
|
||||
return <box />;
|
||||
}
|
||||
|
||||
return (
|
||||
<box class="operation-panel" orientation={Gtk.Orientation.VERTICAL} spacing={8}>
|
||||
<box class="operation-header" spacing={8}>
|
||||
<label class="operation-title" xalign={0} hexpand label={operationTitle || "Paketprozess"} />
|
||||
<button class="button danger" sensitive={Boolean(activeProcess)} onClicked={cancelOperation} label="Abbrechen" />
|
||||
</box>
|
||||
<scrolledwindow
|
||||
class="operation-scroll"
|
||||
hexpand
|
||||
vexpand
|
||||
hscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
|
||||
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
|
||||
>
|
||||
<label class="operation-output" xalign={0} yalign={0} wrap selectable label={operationOutput || "Warte auf Ausgabe..."} />
|
||||
</scrolledwindow>
|
||||
<box class="operation-input-row" spacing={8}>
|
||||
<entry
|
||||
hexpand
|
||||
sensitive={Boolean(activeProcess)}
|
||||
visibility={!secretInput}
|
||||
text={operationInput}
|
||||
placeholderText={secretInput ? "Passwort eingeben..." : "Antwort eingeben..."}
|
||||
onChanged={entry => {
|
||||
operationInput = entry.get_text();
|
||||
}}
|
||||
onActivate={entry => {
|
||||
operationInput = entry.get_text();
|
||||
sendOperationInput();
|
||||
}}
|
||||
/>
|
||||
<button class="button" sensitive={Boolean(activeProcess)} onClicked={sendYesAnswer} label="Ja" />
|
||||
<button class="button" sensitive={Boolean(activeProcess)} onClicked={sendNoAnswer} label="Nein" />
|
||||
<button class="button primary" sensitive={Boolean(activeProcess)} onClicked={() => sendOperationInput()} label="Senden" />
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageManagerWindow() {
|
||||
return (
|
||||
<window
|
||||
name="package-manager"
|
||||
namespace="package-manager"
|
||||
class="package-window"
|
||||
visible
|
||||
keymode={Astal.Keymode.EXCLUSIVE}
|
||||
anchor={Astal.WindowAnchor.TOP}
|
||||
application={app}
|
||||
>
|
||||
<Gtk.EventControllerKey onKeyPressed={(_, keyval) => {
|
||||
if (keyval === ESC_KEYVAL) {
|
||||
app.quit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}} />
|
||||
<box class="package-panel" orientation={Gtk.Orientation.VERTICAL} spacing={14} marginTop={WINDOW_MARGIN_TOP}>
|
||||
<box class="header">
|
||||
<box orientation={Gtk.Orientation.VERTICAL} hexpand>
|
||||
<label class="title" xalign={0} label="Paket Installation / Updates" />
|
||||
<label class="subtitle" xalign={0} label="Pacman und AUR Suche, Installation direkt in AGS." />
|
||||
</box>
|
||||
<button class="icon-button close" tooltipText="Schliessen" onClicked={() => app.quit()}>
|
||||
<label label="" />
|
||||
</button>
|
||||
</box>
|
||||
<box class="toolbar" spacing={8}>
|
||||
<entry
|
||||
hexpand
|
||||
placeholderText="Paket suchen, z.B. firefox, docker, obsidian..."
|
||||
onChanged={entry => {
|
||||
query = entry.get_text();
|
||||
}}
|
||||
onActivate={entry => {
|
||||
query = entry.get_text();
|
||||
searchPackages();
|
||||
}}
|
||||
/>
|
||||
<button class="button primary" sensitive={!busy} onClicked={searchPackages} label={busy ? "Suche..." : "Suchen"} />
|
||||
</box>
|
||||
<box class="toolbar" spacing={8}>
|
||||
<HelperButton id="paru" label="paru" />
|
||||
<HelperButton id="pacman" label="pacman" />
|
||||
<button class="button" sensitive={!busy} onClicked={updateSystem} label="System updaten" />
|
||||
</box>
|
||||
<box class="status-strip">
|
||||
<label class={errorMessage ? "error" : "muted"} xalign={0} hexpand label={errorMessage || statusMessage} />
|
||||
</box>
|
||||
<OperationPanel />
|
||||
<scrolledwindow
|
||||
class="results-scroll"
|
||||
hexpand
|
||||
vexpand
|
||||
hscrollbarPolicy={Gtk.PolicyType.NEVER}
|
||||
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
|
||||
>
|
||||
{results.length
|
||||
? (
|
||||
<box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
|
||||
{results.map(pkg => <PackageRow pkg={pkg} />)}
|
||||
</box>
|
||||
)
|
||||
: (
|
||||
<box class="empty">
|
||||
<label hexpand xalign={0.5} label="Noch keine Suchergebnisse." />
|
||||
</box>
|
||||
)}
|
||||
</scrolledwindow>
|
||||
</box>
|
||||
</window>
|
||||
);
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
const win = app.get_window("package-manager");
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
win.set_child(
|
||||
<box class="package-panel" orientation={Gtk.Orientation.VERTICAL} spacing={14} marginTop={WINDOW_MARGIN_TOP}>
|
||||
<box class="header">
|
||||
<box orientation={Gtk.Orientation.VERTICAL} hexpand>
|
||||
<label class="title" xalign={0} label="Paket Installation / Updates" />
|
||||
<label class="subtitle" xalign={0} label="Pacman und AUR Suche, Installation direkt in AGS." />
|
||||
</box>
|
||||
<button class="icon-button close" tooltipText="Schliessen" onClicked={() => app.quit()}>
|
||||
<label label="" />
|
||||
</button>
|
||||
</box>
|
||||
<box class="toolbar" spacing={8}>
|
||||
<entry
|
||||
hexpand
|
||||
text={query}
|
||||
placeholderText="Paket suchen, z.B. firefox, docker, obsidian..."
|
||||
onChanged={entry => {
|
||||
query = entry.get_text();
|
||||
}}
|
||||
onActivate={entry => {
|
||||
query = entry.get_text();
|
||||
searchPackages();
|
||||
}}
|
||||
/>
|
||||
<button class="button primary" sensitive={!busy} onClicked={searchPackages} label={busy ? "Suche..." : "Suchen"} />
|
||||
</box>
|
||||
<box class="toolbar" spacing={8}>
|
||||
<HelperButton id="paru" label="paru" />
|
||||
<HelperButton id="pacman" label="pacman" />
|
||||
<button class="button" sensitive={!busy} onClicked={updateSystem} label="System updaten" />
|
||||
</box>
|
||||
<box class="status-strip">
|
||||
<label class={errorMessage ? "error" : "muted"} xalign={0} hexpand label={errorMessage || statusMessage} />
|
||||
</box>
|
||||
<OperationPanel />
|
||||
<scrolledwindow
|
||||
class="results-scroll"
|
||||
hexpand
|
||||
vexpand
|
||||
hscrollbarPolicy={Gtk.PolicyType.NEVER}
|
||||
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
|
||||
>
|
||||
{results.length
|
||||
? (
|
||||
<box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
|
||||
{results.map(pkg => <PackageRow pkg={pkg} />)}
|
||||
</box>
|
||||
)
|
||||
: (
|
||||
<box class="empty">
|
||||
<label hexpand xalign={0.5} label="Noch keine Suchergebnisse." />
|
||||
</box>
|
||||
)}
|
||||
</scrolledwindow>
|
||||
</box> as Gtk.Widget
|
||||
);
|
||||
}
|
||||
|
||||
app.start({
|
||||
css,
|
||||
instanceName: "package-manager",
|
||||
main() {
|
||||
PackageManagerWindow();
|
||||
},
|
||||
});
|
||||
115
dotfiles/hypr/ags/switcher.css
Normal file
115
dotfiles/hypr/ags/switcher.css
Normal file
@@ -0,0 +1,115 @@
|
||||
* {
|
||||
all: unset;
|
||||
font-family: "JetBrainsMono Nerd Font", "Noto Sans", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.switcher-window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.switcher {
|
||||
min-width: 680px;
|
||||
padding: 18px;
|
||||
border: 1px solid alpha(@ags_fg, 0.18);
|
||||
border-radius: 16px;
|
||||
background: @ags_bg;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||
color: @ags_fg;
|
||||
}
|
||||
|
||||
.header {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
min-height: 130px;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 10px;
|
||||
border: 1px solid alpha(@ags_fg, 0.10);
|
||||
border-radius: 10px;
|
||||
background: alpha(@ags_panel, 0.54);
|
||||
}
|
||||
|
||||
.item-row {
|
||||
min-height: 78px;
|
||||
}
|
||||
|
||||
.item:hover,
|
||||
.item:focus {
|
||||
border-color: alpha(@ags_accent, 0.72);
|
||||
background: alpha(@ags_panel, 0.82);
|
||||
}
|
||||
|
||||
.item.active {
|
||||
border-color: alpha(@ags_accent_2, 0.85);
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-width: 96px;
|
||||
min-height: 56px;
|
||||
border-radius: 8px;
|
||||
background-color: alpha(@ags_panel, 0.80);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
color: @ags_accent_2;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.item-subtitle {
|
||||
margin-top: 4px;
|
||||
color: @ags_muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
min-width: 86px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
border-radius: 8px;
|
||||
color: @ags_fg;
|
||||
background: alpha(@ags_panel, 0.62);
|
||||
}
|
||||
|
||||
.preview-button {
|
||||
min-width: 44px;
|
||||
min-height: 78px;
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.icon-button:focus {
|
||||
background: alpha(@ags_accent, 0.28);
|
||||
}
|
||||
|
||||
.close {
|
||||
color: @ags_accent;
|
||||
}
|
||||
|
||||
.empty {
|
||||
min-height: 150px;
|
||||
color: @ags_muted;
|
||||
}
|
||||
321
dotfiles/hypr/ags/switcher.tsx
Normal file
321
dotfiles/hypr/ags/switcher.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import app from "ags/gtk4/app";
|
||||
import { Astal, Gtk } from "ags/gtk4";
|
||||
import { readFile } from "ags/file";
|
||||
import { execAsync } from "ags/process";
|
||||
import css from "./switcher.css";
|
||||
import GLib from "gi://GLib";
|
||||
|
||||
type SwitcherItem = {
|
||||
type: "theme" | "wallpaper";
|
||||
path: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
wallpaper: string;
|
||||
active?: boolean;
|
||||
accent?: string;
|
||||
accent2?: string;
|
||||
muted?: string;
|
||||
};
|
||||
|
||||
const HYPR_DIR = GLib.getenv("HYPR_DIR") || "/home/pascal/.config/hypr";
|
||||
const SCRIPT_DIR = `${HYPR_DIR}/Scripts`;
|
||||
const THEME_DIR = GLib.getenv("HYPR_SWITCHER_THEME_DIR") || `${HYPR_DIR}/Themes`;
|
||||
const WALLPAPER_DIR = GLib.getenv("HYPR_SWITCHER_WALLPAPER_DIR")
|
||||
|| GLib.getenv("WALLPAPER_DIR")
|
||||
|| `${GLib.get_home_dir()}/Bilder/Wallpaper`;
|
||||
const CURRENT_WALLPAPER = `${HYPR_DIR}/current-wallpaper`;
|
||||
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif"];
|
||||
|
||||
type UiTheme = {
|
||||
accent: string;
|
||||
accent2: string;
|
||||
background: string;
|
||||
backgroundSoft: string;
|
||||
foreground: string;
|
||||
muted: string;
|
||||
panelHex: string;
|
||||
};
|
||||
|
||||
function notify(message: string) {
|
||||
execAsync(["notify-send", "AGS Switcher", message]).catch(console.error);
|
||||
}
|
||||
|
||||
function fileExists(path: string) {
|
||||
return GLib.file_test(path, GLib.FileTest.EXISTS);
|
||||
}
|
||||
|
||||
function readText(path: string) {
|
||||
try {
|
||||
return readFile(path);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function listFiles(dir: string, predicate: (path: string, name: string) => boolean) {
|
||||
try {
|
||||
const directory = GLib.Dir.open(dir, 0);
|
||||
const files: string[] = [];
|
||||
let name = directory.read_name();
|
||||
|
||||
while (name !== null) {
|
||||
const path = `${dir}/${name}`;
|
||||
if (predicate(path, name)) {
|
||||
files.push(path);
|
||||
}
|
||||
name = directory.read_name();
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function shellValue(contents: string, key: string) {
|
||||
const regex = new RegExp(`^${key}=(["']?)(.*?)\\1$`, "m");
|
||||
return contents.match(regex)?.[2] || "";
|
||||
}
|
||||
|
||||
function activeTheme(): UiTheme {
|
||||
const activeWallpaper = currentWallpaper();
|
||||
const fallback = {
|
||||
accent: "#00ff9c",
|
||||
accent2: "#00cc88",
|
||||
background: "rgba(20, 20, 30, 0.95)",
|
||||
backgroundSoft: "rgba(40, 40, 55, 0.8)",
|
||||
foreground: "#cdd6f4",
|
||||
muted: "#cccccc",
|
||||
panelHex: "#282837",
|
||||
};
|
||||
|
||||
const themeFile = listFiles(THEME_DIR, (_path, name) => name.endsWith(".theme"))
|
||||
.find(path => shellValue(readText(path), "WALLPAPER") === activeWallpaper);
|
||||
|
||||
if (!themeFile) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const contents = readText(themeFile);
|
||||
return {
|
||||
accent: shellValue(contents, "ACCENT") || fallback.accent,
|
||||
accent2: shellValue(contents, "ACCENT_2") || fallback.accent2,
|
||||
background: shellValue(contents, "BACKGROUND") || fallback.background,
|
||||
backgroundSoft: shellValue(contents, "BACKGROUND_SOFT") || fallback.backgroundSoft,
|
||||
foreground: shellValue(contents, "FOREGROUND") || fallback.foreground,
|
||||
muted: shellValue(contents, "MUTED") || fallback.muted,
|
||||
panelHex: shellValue(contents, "PANEL_HEX") || fallback.panelHex,
|
||||
};
|
||||
}
|
||||
|
||||
function themeCss(theme: UiTheme) {
|
||||
return [
|
||||
`@define-color ags_accent ${theme.accent};`,
|
||||
`@define-color ags_accent_2 ${theme.accent2};`,
|
||||
`@define-color ags_bg ${theme.background};`,
|
||||
`@define-color ags_bg_soft ${theme.backgroundSoft};`,
|
||||
`@define-color ags_fg ${theme.foreground};`,
|
||||
`@define-color ags_muted ${theme.muted};`,
|
||||
`@define-color ags_panel ${theme.panelHex};`,
|
||||
css,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function basename(path: string) {
|
||||
return GLib.path_get_basename(path);
|
||||
}
|
||||
|
||||
function currentWallpaper() {
|
||||
return readText(CURRENT_WALLPAPER).trim();
|
||||
}
|
||||
|
||||
function loadThemes(): SwitcherItem[] {
|
||||
return listFiles(THEME_DIR, (_path, name) => name.endsWith(".theme"))
|
||||
.map(path => {
|
||||
const contents = readText(path);
|
||||
return {
|
||||
type: "theme",
|
||||
path,
|
||||
name: shellValue(contents, "NAME") || basename(path).replace(/\.theme$/, ""),
|
||||
icon: shellValue(contents, "ICON") || "",
|
||||
wallpaper: shellValue(contents, "WALLPAPER"),
|
||||
accent: shellValue(contents, "ACCENT") || "#f38ba8",
|
||||
accent2: shellValue(contents, "ACCENT_2") || "#cba6f7",
|
||||
muted: shellValue(contents, "MUTED") || "#cdd6f4",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function loadWallpapers(): SwitcherItem[] {
|
||||
const active = currentWallpaper();
|
||||
|
||||
return listFiles(WALLPAPER_DIR, (_path, name) =>
|
||||
IMAGE_EXTENSIONS.some(ext => name.toLowerCase().endsWith(ext)),
|
||||
).map(path => ({
|
||||
type: "wallpaper",
|
||||
path,
|
||||
name: basename(path),
|
||||
wallpaper: path,
|
||||
active: path === active,
|
||||
}));
|
||||
}
|
||||
|
||||
function applyItem(item: SwitcherItem) {
|
||||
const command = item.type === "theme"
|
||||
? [`${SCRIPT_DIR}/theme-menu.sh`, "--apply", item.path]
|
||||
: [`${SCRIPT_DIR}/wallpaper-menu.sh`, "--apply", item.path];
|
||||
|
||||
execAsync(command)
|
||||
.then(() => app.quit())
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
notify(`${item.name} konnte nicht angewendet werden.`);
|
||||
});
|
||||
}
|
||||
|
||||
function previewWallpaper(item: SwitcherItem) {
|
||||
execAsync([`${SCRIPT_DIR}/wallpaper-menu.sh`, "--preview", item.path]).catch(error => {
|
||||
console.error(error);
|
||||
notify(`${item.name} konnte nicht geoeffnet werden.`);
|
||||
});
|
||||
}
|
||||
|
||||
function Preview({ item }: { item: SwitcherItem }) {
|
||||
const hasImage = item.wallpaper && fileExists(item.wallpaper);
|
||||
|
||||
return (
|
||||
<box class={hasImage ? "preview" : "preview preview-empty"}>
|
||||
{hasImage
|
||||
? <image class="preview-image" file={item.wallpaper} pixelSize={92} />
|
||||
: <label label="" />}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function Swatches({ item }: { item: SwitcherItem }) {
|
||||
return (
|
||||
<box class="swatches" spacing={6}>
|
||||
{[item.accent, item.accent2, item.muted].map(color => (
|
||||
<box class="swatch" css={`background: ${color};`} />
|
||||
))}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemButton({ item }: { item: SwitcherItem }) {
|
||||
const content = (
|
||||
<box spacing={14}>
|
||||
<Preview item={item} />
|
||||
<box
|
||||
orientation={Gtk.Orientation.VERTICAL}
|
||||
hexpand
|
||||
valign={Gtk.Align.CENTER}
|
||||
>
|
||||
<label
|
||||
class="item-title"
|
||||
xalign={0}
|
||||
ellipsize={3}
|
||||
label={`${item.icon || ""} ${item.name}`}
|
||||
/>
|
||||
<label
|
||||
class="item-subtitle"
|
||||
xalign={0}
|
||||
ellipsize={3}
|
||||
label={item.type === "theme"
|
||||
? basename(item.path)
|
||||
: item.active ? "Aktuelles Wallpaper" : item.path}
|
||||
/>
|
||||
</box>
|
||||
{item.type === "theme" ? <Swatches item={item} /> : <box />}
|
||||
</box>
|
||||
);
|
||||
|
||||
const applyButton = (
|
||||
<button
|
||||
class={`item ${item.active ? "active" : ""}`}
|
||||
hexpand
|
||||
onClicked={() => applyItem(item)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (item.type === "theme") {
|
||||
return applyButton;
|
||||
}
|
||||
|
||||
return (
|
||||
<box class="item-row" spacing={8}>
|
||||
{applyButton}
|
||||
<button
|
||||
class="icon-button preview-button"
|
||||
tooltipText="Vorschau"
|
||||
onClicked={() => previewWallpaper(item)}
|
||||
>
|
||||
<label label="" />
|
||||
</button>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitcherWindow(mode: string) {
|
||||
const isTheme = mode === "theme" || mode === "themes";
|
||||
const items = isTheme ? loadThemes() : loadWallpapers();
|
||||
const title = isTheme ? "Theme wechseln" : "Wallpaper wechseln";
|
||||
const empty = isTheme ? `Keine Themes in ${THEME_DIR}` : `Keine Bilder in ${WALLPAPER_DIR}`;
|
||||
|
||||
return (
|
||||
<window
|
||||
name="ags-hypr-switcher"
|
||||
namespace="ags-hypr-switcher"
|
||||
class="switcher-window"
|
||||
visible
|
||||
keymode={Astal.Keymode.EXCLUSIVE}
|
||||
anchor={Astal.WindowAnchor.TOP}
|
||||
application={app}
|
||||
>
|
||||
<box
|
||||
class="switcher"
|
||||
orientation={Gtk.Orientation.VERTICAL}
|
||||
spacing={16}
|
||||
marginTop={70}
|
||||
>
|
||||
<box class="header">
|
||||
<label class="title" hexpand xalign={0} label={title} />
|
||||
<button
|
||||
class="icon-button close"
|
||||
tooltipText="Schliessen"
|
||||
onClicked={() => app.quit()}
|
||||
>
|
||||
<label label="" />
|
||||
</button>
|
||||
</box>
|
||||
{items.length > 0
|
||||
? (
|
||||
<scrolledwindow
|
||||
class="list-scroll"
|
||||
hscrollbarPolicy={Gtk.PolicyType.NEVER}
|
||||
maxContentHeight={600}
|
||||
>
|
||||
<box orientation={Gtk.Orientation.VERTICAL} spacing={10}>
|
||||
{items.map(item => <ItemButton item={item} />)}
|
||||
</box>
|
||||
</scrolledwindow>
|
||||
)
|
||||
: (
|
||||
<box class="empty">
|
||||
<label label={empty} />
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
</window>
|
||||
);
|
||||
}
|
||||
|
||||
app.start({
|
||||
css: themeCss(activeTheme()),
|
||||
instanceName: "hypr-switcher",
|
||||
main(mode = "wallpaper") {
|
||||
SwitcherWindow(mode);
|
||||
},
|
||||
});
|
||||
138
dotfiles/hypr/ags/widget-panel.css
Normal file
138
dotfiles/hypr/ags/widget-panel.css
Normal file
@@ -0,0 +1,138 @@
|
||||
* {
|
||||
all: unset;
|
||||
font-family: "JetBrainsMono Nerd Font", "Noto Sans", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.widget-window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel-scroll {
|
||||
border: 1px solid alpha(@ags_fg, 0.16);
|
||||
border-radius: 16px;
|
||||
background: @ags_bg;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
color: @ags_fg;
|
||||
}
|
||||
|
||||
.header {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.card-subtitle {
|
||||
color: @ags_muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: 8px;
|
||||
color: @ags_fg;
|
||||
background: alpha(@ags_panel, 0.64);
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.icon-button:focus {
|
||||
background: alpha(@ags_accent, 0.28);
|
||||
}
|
||||
|
||||
.close {
|
||||
color: @ags_accent;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 14px;
|
||||
border: 1px solid alpha(@ags_fg, 0.10);
|
||||
border-radius: 12px;
|
||||
background: alpha(@ags_panel, 0.62);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 45px;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
color: @ags_fg;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: @ags_accent;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.progress {
|
||||
min-height: 8px;
|
||||
border-radius: 999px;
|
||||
background: alpha(@ags_bg_soft, 0.70);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
min-height: 8px;
|
||||
border-radius: 999px;
|
||||
background: @ags_accent;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
color: @ags_muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sparkline {
|
||||
color: @ags_accent_2;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.weekday,
|
||||
.day {
|
||||
min-width: 34px;
|
||||
min-height: 30px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
color: @ags_muted;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.day {
|
||||
background: alpha(@ags_bg_soft, 0.42);
|
||||
}
|
||||
|
||||
.day.today {
|
||||
color: @ags_bg;
|
||||
background: @ags_accent;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.day.muted {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.weather-main {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
color: @ags_accent_2;
|
||||
}
|
||||
435
dotfiles/hypr/ags/widget-panel.tsx
Normal file
435
dotfiles/hypr/ags/widget-panel.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import app from "ags/gtk4/app";
|
||||
import { Astal, Gtk } from "ags/gtk4";
|
||||
import { readFile } from "ags/file";
|
||||
import { execAsync } from "ags/process";
|
||||
import { createRoot } from "gnim";
|
||||
import css from "./widget-panel.css";
|
||||
import GLib from "gi://GLib";
|
||||
|
||||
const HYPR_DIR = GLib.getenv("HYPR_DIR") || `${GLib.get_home_dir()}/.config/hypr`;
|
||||
const THEME_DIR = `${HYPR_DIR}/Themes`;
|
||||
const CURRENT_WALLPAPER = `${HYPR_DIR}/current-wallpaper`;
|
||||
const REFRESH_SECONDS = 2;
|
||||
const WEATHER_SECONDS = 20 * 60;
|
||||
const HISTORY_LIMIT = 22;
|
||||
const ESC_KEYVAL = 65307;
|
||||
const START_HIDDEN = GLib.getenv("WIDGET_PANEL_START_HIDDEN") === "1";
|
||||
|
||||
type UiTheme = {
|
||||
accent: string;
|
||||
accent2: string;
|
||||
background: string;
|
||||
backgroundSoft: string;
|
||||
foreground: string;
|
||||
muted: string;
|
||||
panelHex: string;
|
||||
};
|
||||
|
||||
type SystemSnapshot = {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
disk: number;
|
||||
temp: string;
|
||||
uptime: string;
|
||||
};
|
||||
|
||||
let lastCpuTotal = 0;
|
||||
let lastCpuIdle = 0;
|
||||
let system: SystemSnapshot = {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
disk: 0,
|
||||
temp: "n/a",
|
||||
uptime: "n/a",
|
||||
};
|
||||
let cpuHistory: number[] = [];
|
||||
let weather = "Wetter wird geladen...";
|
||||
let weatherUpdated = 0;
|
||||
let timerStarted = false;
|
||||
let disposeRebuild: (() => void) | null = null;
|
||||
let panelWindow: Gtk.Window | null = null;
|
||||
|
||||
function readText(path: string) {
|
||||
try {
|
||||
return readFile(path);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function listFiles(dir: string, predicate: (path: string, name: string) => boolean) {
|
||||
try {
|
||||
const directory = GLib.Dir.open(dir, 0);
|
||||
const files: string[] = [];
|
||||
let name = directory.read_name();
|
||||
|
||||
while (name !== null) {
|
||||
const path = `${dir}/${name}`;
|
||||
if (predicate(path, name)) {
|
||||
files.push(path);
|
||||
}
|
||||
name = directory.read_name();
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function shellValue(contents: string, key: string) {
|
||||
const regex = new RegExp(`^${key}=(["']?)(.*?)\\1$`, "m");
|
||||
return contents.match(regex)?.[2] || "";
|
||||
}
|
||||
|
||||
function currentWallpaper() {
|
||||
return readText(CURRENT_WALLPAPER).trim();
|
||||
}
|
||||
|
||||
function activeTheme(): UiTheme {
|
||||
const fallback = {
|
||||
accent: "#f38ba8",
|
||||
accent2: "#cba6f7",
|
||||
background: "rgba(24, 20, 31, 0.96)",
|
||||
backgroundSoft: "rgba(49, 50, 68, 0.82)",
|
||||
foreground: "#f5e0dc",
|
||||
muted: "#cdd6f4",
|
||||
panelHex: "#313244",
|
||||
};
|
||||
|
||||
const activeWallpaper = currentWallpaper();
|
||||
const themeFile = listFiles(THEME_DIR, (_path, name) => name.endsWith(".theme"))
|
||||
.find(path => shellValue(readText(path), "WALLPAPER") === activeWallpaper);
|
||||
|
||||
if (!themeFile) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const contents = readText(themeFile);
|
||||
return {
|
||||
accent: shellValue(contents, "ACCENT") || fallback.accent,
|
||||
accent2: shellValue(contents, "ACCENT_2") || fallback.accent2,
|
||||
background: shellValue(contents, "BACKGROUND") || fallback.background,
|
||||
backgroundSoft: shellValue(contents, "BACKGROUND_SOFT") || fallback.backgroundSoft,
|
||||
foreground: shellValue(contents, "FOREGROUND") || fallback.foreground,
|
||||
muted: shellValue(contents, "MUTED") || fallback.muted,
|
||||
panelHex: shellValue(contents, "PANEL_HEX") || fallback.panelHex,
|
||||
};
|
||||
}
|
||||
|
||||
function themeCss(theme: UiTheme) {
|
||||
return [
|
||||
`@define-color ags_accent ${theme.accent};`,
|
||||
`@define-color ags_accent_2 ${theme.accent2};`,
|
||||
`@define-color ags_bg ${theme.background};`,
|
||||
`@define-color ags_bg_soft ${theme.backgroundSoft};`,
|
||||
`@define-color ags_fg ${theme.foreground};`,
|
||||
`@define-color ags_muted ${theme.muted};`,
|
||||
`@define-color ags_panel ${theme.panelHex};`,
|
||||
css,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function clamp(value: number, min = 0, max = 100) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function percentLabel(value: number) {
|
||||
return `${Math.round(clamp(value))}%`;
|
||||
}
|
||||
|
||||
function updateSystem() {
|
||||
const cpuLine = readText("/proc/stat").split("\n")[0] || "";
|
||||
const cpuValues = cpuLine.trim().split(/\s+/).slice(1).map(value => Number(value) || 0);
|
||||
const idle = (cpuValues[3] || 0) + (cpuValues[4] || 0);
|
||||
const total = cpuValues.reduce((sum, value) => sum + value, 0);
|
||||
const totalDelta = total - lastCpuTotal;
|
||||
const idleDelta = idle - lastCpuIdle;
|
||||
|
||||
if (lastCpuTotal > 0 && totalDelta > 0) {
|
||||
system.cpu = clamp(((totalDelta - idleDelta) / totalDelta) * 100);
|
||||
}
|
||||
|
||||
lastCpuTotal = total;
|
||||
lastCpuIdle = idle;
|
||||
|
||||
const meminfo = readText("/proc/meminfo");
|
||||
const memTotal = Number(meminfo.match(/^MemTotal:\s+(\d+)/m)?.[1] || 0);
|
||||
const memAvailable = Number(meminfo.match(/^MemAvailable:\s+(\d+)/m)?.[1] || 0);
|
||||
if (memTotal > 0) {
|
||||
system.memory = clamp(((memTotal - memAvailable) / memTotal) * 100);
|
||||
}
|
||||
|
||||
const uptimeSeconds = Number(readText("/proc/uptime").split(" ")[0] || 0);
|
||||
const hours = Math.floor(uptimeSeconds / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
system.uptime = `${hours}h ${minutes}m`;
|
||||
|
||||
const temps = listFiles("/sys/class/thermal", (_path, name) => name.startsWith("thermal_zone"))
|
||||
.map(path => Number(readText(`${path}/temp`).trim()) / 1000)
|
||||
.filter(value => Number.isFinite(value) && value > 0);
|
||||
system.temp = temps.length ? `${Math.round(Math.max(...temps))} C` : "n/a";
|
||||
|
||||
execAsync(["bash", "-lc", "df -P / | awk 'NR==2 {gsub(/%/, \"\", $5); print $5}'"])
|
||||
.then(output => {
|
||||
system.disk = clamp(Number(output.trim()) || system.disk);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
cpuHistory = [...cpuHistory, system.cpu].slice(-HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
function updateWeather(force = false) {
|
||||
if (!force && Date.now() - weatherUpdated < WEATHER_SECONDS * 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
weatherUpdated = Date.now();
|
||||
execAsync(["bash", "-lc", "curl -fsS --max-time 5 'https://wttr.in/?format=%l:+%c+%t+%w' 2>/dev/null || true"])
|
||||
.then(output => {
|
||||
weather = output.trim() || "Wetter nicht erreichbar";
|
||||
})
|
||||
.catch(() => {
|
||||
weather = "Wetter nicht erreichbar";
|
||||
});
|
||||
}
|
||||
|
||||
function sparkline(values: number[]) {
|
||||
const blocks = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
||||
if (!values.length) {
|
||||
return "·".repeat(HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
return values
|
||||
.map(value => blocks[Math.round((clamp(value) / 100) * (blocks.length - 1))])
|
||||
.join("");
|
||||
}
|
||||
|
||||
function monthDays() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const first = new Date(year, month, 1);
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const startOffset = (first.getDay() + 6) % 7;
|
||||
const cells: { day: string; today: boolean; muted: boolean }[] = [];
|
||||
|
||||
for (let i = 0; i < startOffset; i += 1) {
|
||||
cells.push({ day: "", today: false, muted: true });
|
||||
}
|
||||
|
||||
for (let day = 1; day <= last.getDate(); day += 1) {
|
||||
cells.push({ day: String(day), today: day === now.getDate(), muted: false });
|
||||
}
|
||||
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push({ day: "", today: false, muted: true });
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function monthTitle() {
|
||||
return new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(new Date());
|
||||
}
|
||||
|
||||
function Metric({ icon, label, value }: { icon: string; label: string; value: number }) {
|
||||
return (
|
||||
<box class="metric" orientation={Gtk.Orientation.VERTICAL} spacing={8}>
|
||||
<box>
|
||||
<label class="metric-name" hexpand xalign={0} label={`${icon} ${label}`} />
|
||||
<label class="metric-value" label={percentLabel(value)} />
|
||||
</box>
|
||||
<box class="progress">
|
||||
<box class="progress-fill" css={`min-width: ${Math.round(clamp(value) * 2.3)}px;`} />
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemMonitor() {
|
||||
return (
|
||||
<box class="card" orientation={Gtk.Orientation.VERTICAL} spacing={12}>
|
||||
<label class="card-title" xalign={0} label="System Monitoring" />
|
||||
<Metric icon="" label="CPU" value={system.cpu} />
|
||||
<Metric icon="" label="RAM" value={system.memory} />
|
||||
<Metric icon="" label="Disk /" value={system.disk} />
|
||||
<box class="meta-row">
|
||||
<label hexpand xalign={0} label={` ${system.temp}`} />
|
||||
<label xalign={1} label={` ${system.uptime}`} />
|
||||
</box>
|
||||
<label class="sparkline" xalign={0} label={sparkline(cpuHistory)} />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function Calendar() {
|
||||
const cells = monthDays();
|
||||
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
return (
|
||||
<box class="card" orientation={Gtk.Orientation.VERTICAL} spacing={10}>
|
||||
<label class="card-title" xalign={0} label={monthTitle()} />
|
||||
<box class="calendar-grid" orientation={Gtk.Orientation.VERTICAL} spacing={5}>
|
||||
<box spacing={5}>
|
||||
{weekdays.map(day => <label class="weekday" label={day} />)}
|
||||
</box>
|
||||
{Array.from({ length: Math.ceil(cells.length / 7) }, (_unused, row) => (
|
||||
<box spacing={5}>
|
||||
{cells.slice(row * 7, row * 7 + 7).map(cell => (
|
||||
<label
|
||||
class={`day ${cell.today ? "today" : ""} ${cell.muted ? "muted" : ""}`}
|
||||
label={cell.day}
|
||||
/>
|
||||
))}
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function Weather() {
|
||||
return (
|
||||
<box class="card weather" orientation={Gtk.Orientation.VERTICAL} spacing={10}>
|
||||
<box>
|
||||
<label class="card-title" hexpand xalign={0} label="Wetter" />
|
||||
<button class="icon-button" tooltipText="Aktualisieren" onClicked={() => updateWeather(true)}>
|
||||
<label label="" />
|
||||
</button>
|
||||
</box>
|
||||
<label class="weather-main" xalign={0} wrap label={weather} />
|
||||
<label class="card-subtitle" xalign={0} label="Quelle: wttr.in" />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
const win = panelWindow || app.get_window("widget-panel");
|
||||
win?.set_visible(false);
|
||||
}
|
||||
|
||||
function PanelContent() {
|
||||
return (
|
||||
<box class="panel" orientation={Gtk.Orientation.VERTICAL} spacing={14}>
|
||||
<box class="header">
|
||||
<box orientation={Gtk.Orientation.VERTICAL} hexpand>
|
||||
<label class="title" xalign={0} label="Widgetbereich" />
|
||||
<label class="subtitle" xalign={0} label="System, Kalender und Wetter" />
|
||||
</box>
|
||||
<button class="icon-button close" tooltipText="Schliessen" onClicked={hidePanel}>
|
||||
<label label="" />
|
||||
</button>
|
||||
</box>
|
||||
<SystemMonitor />
|
||||
<Calendar />
|
||||
<Weather />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function panelLayout() {
|
||||
const geometry = app.monitors[0]?.get_geometry();
|
||||
const screenWidth = geometry?.width || 1280;
|
||||
const screenHeight = geometry?.height || 720;
|
||||
|
||||
return {
|
||||
width: Math.round(clamp(screenWidth * 0.28, 360, 460)),
|
||||
height: Math.round(clamp(screenHeight - 96, 560, 900)),
|
||||
};
|
||||
}
|
||||
|
||||
function WidgetPanelWindow() {
|
||||
const layout = panelLayout();
|
||||
|
||||
return (
|
||||
<window
|
||||
name="widget-panel"
|
||||
namespace="widget-panel"
|
||||
class="widget-window"
|
||||
visible={!START_HIDDEN}
|
||||
keymode={Astal.Keymode.EXCLUSIVE}
|
||||
anchor={Astal.WindowAnchor.LEFT | Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM}
|
||||
application={app}
|
||||
>
|
||||
<Gtk.EventControllerKey onKeyPressed={(_, keyval) => {
|
||||
if (keyval === ESC_KEYVAL) {
|
||||
hidePanel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}} />
|
||||
<box marginStart={18} marginTop={48} marginBottom={48}>
|
||||
<scrolledwindow
|
||||
class="panel-scroll"
|
||||
widthRequest={layout.width}
|
||||
heightRequest={layout.height}
|
||||
hscrollbarPolicy={Gtk.PolicyType.NEVER}
|
||||
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
|
||||
>
|
||||
<PanelContent />
|
||||
</scrolledwindow>
|
||||
</box>
|
||||
</window>
|
||||
);
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
const win = app.get_window("widget-panel");
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
|
||||
disposeRebuild?.();
|
||||
const layout = panelLayout();
|
||||
createRoot(dispose => {
|
||||
disposeRebuild = dispose;
|
||||
win.set_child(
|
||||
<box marginStart={18} marginTop={48} marginBottom={48}>
|
||||
<scrolledwindow
|
||||
class="panel-scroll"
|
||||
widthRequest={layout.width}
|
||||
heightRequest={layout.height}
|
||||
hscrollbarPolicy={Gtk.PolicyType.NEVER}
|
||||
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
|
||||
>
|
||||
<PanelContent />
|
||||
</scrolledwindow>
|
||||
</box> as Gtk.Widget,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (timerStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
timerStarted = true;
|
||||
updateSystem();
|
||||
updateWeather(true);
|
||||
|
||||
GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, REFRESH_SECONDS, () => {
|
||||
updateSystem();
|
||||
updateWeather();
|
||||
rebuild();
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
});
|
||||
}
|
||||
|
||||
app.start({
|
||||
instanceName: "widget-panel",
|
||||
css: themeCss(activeTheme()),
|
||||
main() {
|
||||
panelWindow = WidgetPanelWindow() as Gtk.Window;
|
||||
app.add_window(panelWindow);
|
||||
if (!START_HIDDEN) {
|
||||
panelWindow.present();
|
||||
}
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
startTimer();
|
||||
rebuild();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user