feat: make AGS colorshell configuration fully declarative

- Add complete colorshell v2.0.3 configuration to home/ags-config/
- Disable runner plugin and NightLight tile (incompatible with NixOS)
- Customize SCSS with full opacity (no transparency)
- Add dark pale blue color scheme in home/pywal-colors/
- Configure Papirus-Dark icon theme via home-manager
- Make ~/.config/ags/ immutable and managed by Nix store
- Auto-deploy pywal colors to ~/.cache/wal/colors.json

All AGS configuration is now reproducible and version controlled.
This commit is contained in:
2025-11-04 21:36:38 +00:00
parent 593735370a
commit b2ae32a078
240 changed files with 1024921 additions and 3 deletions

View File

@@ -0,0 +1,38 @@
import { Tile } from "./Tile";
import { BluetoothPage } from "../pages/Bluetooth";
import { TilesPages } from "../tiles";
import { createBinding, createComputed } from "ags";
import { Bluetooth } from "../../../../modules/bluetooth";
import AstalBluetooth from "gi://AstalBluetooth";
export const TileBluetooth = () =>
<Tile title={"Bluetooth"} visible={createBinding(Bluetooth.getDefault(), "isAvailable")}
description={createComputed([
createBinding(Bluetooth.getDefault(), "adapter"),
createBinding(AstalBluetooth.get_default(), "devices")
], (adapter, devices) => {
const lastConnectedDevice = devices.filter(d => d.connected)[devices.length - 1];
if(!adapter || !lastConnectedDevice)
return "";
return lastConnectedDevice.alias;
})}
onEnabled={() => Bluetooth.getDefault().adapter?.set_powered(true)}
onDisabled={() => Bluetooth.getDefault().adapter?.set_powered(false)}
onClicked={() => TilesPages?.toggle(BluetoothPage)}
hasArrow
state={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")
}
/>;

View File

@@ -0,0 +1,14 @@
import { Notifications } from "../../../../modules/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"))}
onDisabled={() => Notifications.getDefault().getNotifd().dontDisturb = false}
onEnabled={() => Notifications.getDefault().getNotifd().dontDisturb = true}
icon={"minus-circle-filled-symbolic"}
state={Notifications.getDefault().getNotifd().dontDisturb}
/>;

View File

@@ -0,0 +1,168 @@
import { Tile } from "./Tile";
import { execAsync } from "ags/process";
import { PageNetwork } from "../pages/Network";
import { tr } from "../../../../i18n/intl";
import { TilesPages } from "../tiles";
import { Accessor, createBinding, createComputed } from "ags";
import { secureBaseBinding } from "../../../../modules/utils";
import AstalNetwork from "gi://AstalNetwork";
import { Notifications } from "../../../../modules/notifications";
const { WIFI, WIRED } = AstalNetwork.Primary,
{ CONNECTED, CONNECTING, DISCONNECTED } = AstalNetwork.Internet;
const wiredInternet = secureBaseBinding<AstalNetwork.Wired>(
createBinding(AstalNetwork.get_default(), "wired"),
"internet",
AstalNetwork.Internet.DISCONNECTED
) as Accessor<AstalNetwork.Internet>;
const wifiInternet = secureBaseBinding<AstalNetwork.Wifi>(
createBinding(AstalNetwork.get_default(), "wifi"),
"internet",
AstalNetwork.Internet.DISCONNECTED
) as Accessor<AstalNetwork.Internet>;
const wifiSSID = secureBaseBinding<AstalNetwork.Wifi>(
createBinding(AstalNetwork.get_default(), "wifi"),
"ssid",
"Unknown"
) as Accessor<string>;
const wifiIcon = secureBaseBinding<AstalNetwork.Wifi>(
createBinding(AstalNetwork.get_default(), "wifi"),
"iconName",
"network-wireless-symbolic"
);
const wiredIcon = secureBaseBinding<AstalNetwork.Wired>(
createBinding(AstalNetwork.get_default(), "wired"),
"iconName",
"network-wired-symbolic"
);
const primary = createBinding(AstalNetwork.get_default(), "primary");
export const TileNetwork = () =>
<Tile hasArrow title={createComputed([
primary,
wifiInternet,
wifiSSID
], (primary, wiInternet, wiSSID) => {
switch(primary) {
case WIFI:
if(wiInternet === CONNECTED)
return wiSSID;
return tr("control_center.tiles.network.wireless");
case WIRED:
return tr("control_center.tiles.network.wired");
}
return tr("control_center.tiles.network.network");
})}
onClicked={() => TilesPages?.toggle(PageNetwork)}
icon={createComputed([
primary,
wifiIcon,
wiredIcon
], (primary, wifiIcon, wiredIcon) => {
switch(primary) {
case WIFI:
return wifiIcon;
case WIRED:
return wiredIcon;
}
return "network-wired-no-route-symbolic";
})}
state={createComputed([
primary,
secureBaseBinding<AstalNetwork.Wifi>(
createBinding(AstalNetwork.get_default(), "wifi"),
"enabled",
false
),
wiredInternet.as(internet => internet === CONNECTED || internet === CONNECTING)
], (primary, wifiEnabled, wiredEnabled) => {
switch(primary) {
case WIFI:
return wifiEnabled;
case WIRED:
return wiredEnabled;
}
return false;
})}
description={createComputed([
primary,
wifiInternet,
wiredInternet
], (primary, wifiInternet, wiredInternet) => {
switch(primary) {
case WIFI:
return internetToTranslatedString(wifiInternet);
case WIRED:
return internetToTranslatedString(wiredInternet);
}
return tr("disconnected");
})}
onToggled={(self, state) => {
const wifi = AstalNetwork.get_default().wifi,
wired = AstalNetwork.get_default().wired;
switch(AstalNetwork.get_default().primary) {
case WIFI:
wifi.set_enabled(state);
return;
case WIRED:
setNetworking(state);
return;
}
if(wired && wired.internet === DISCONNECTED) {
setNetworking(true);
return;
} else if(wifi && !wifi.enabled) {
wifi.set_enabled(true);
return;
}
// disable if no device available
self.state = false;
}}
/>;
function internetToTranslatedString(internet: AstalNetwork.Internet): string {
switch(internet) {
case AstalNetwork.Internet.CONNECTED:
return tr("connected");
case AstalNetwork.Internet.CONNECTING:
return tr("connecting") + "...";
}
return tr("disconnected");
}
function setNetworking(state: boolean): void {
(!state ?
execAsync("nmcli n off")
: execAsync("nmcli n on")
).catch(e => {
Notifications.getDefault().sendNotification({
appName: "network",
summary: "Couldn't turn off network",
body: `Turning off networking with nmcli failed${
e?.message !== undefined ? `: ${e?.message}` : ""}`
});
});
}

View File

@@ -0,0 +1,28 @@
import { Tile } from "./Tile";
import { NightLight } from "../../../../modules/nightlight";
import { PageNightLight } from "../pages/NightLight";
import { tr } from "../../../../i18n/intl";
import { TilesPages } from "../tiles";
import { isInstalled } from "../../../../modules/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(), "identity"),
createBinding(NightLight.getDefault(), "temperature"),
createBinding(NightLight.getDefault(), "gamma")
], (identity, temp, gamma) => !identity ?
`${temp === NightLight.getDefault().identityTemperature ?
tr("control_center.tiles.night_light.default_desc") : `${temp}K`
} ${gamma < NightLight.getDefault().maxGamma ? `(${gamma}%)` : ""}`
: tr("control_center.tiles.disabled")
)}
hasArrow visible={isInstalled("hyprsunset")}
onDisabled={() => NightLight.getDefault().identity = true}
onEnabled={() => NightLight.getDefault().identity = false}
onClicked={() => TilesPages?.toggle(PageNightLight)}
state={createBinding(NightLight.getDefault(), "identity").as(identity => !identity)}
/>

View File

@@ -0,0 +1,24 @@
import { Tile } from "./Tile";
import { Recording } from "../../../../modules/recording";
import { tr } from "../../../../i18n/intl";
import { isInstalled } from "../../../../modules/utils";
import { createBinding, createComputed } from "ags";
export const TileRecording = () =>
<Tile title={tr("control_center.tiles.recording.title")}
description={createComputed([
createBinding(Recording.getDefault(), "recording"),
createBinding(Recording.getDefault(), "recordingTime")
], (recording, time) => {
if(!recording || !Recording.getDefault().startedAt)
return tr("control_center.tiles.recording.disabled_desc") || "Start recording";
return time;
})}
icon={"media-record-symbolic"}
visible={isInstalled("wf-recorder")}
onDisabled={() => Recording.getDefault().stopRecording()}
onEnabled={() => Recording.getDefault().startRecording()}
state={createBinding(Recording.getDefault(), "recording")}
/>;

View File

@@ -0,0 +1,150 @@
import { Gtk } from "ags/gtk4";
import { createBinding } from "ags";
import { omitObjectKeys, variableToBoolean } from "../../../../modules/utils";
import { property, register, signal } from "ags/gobject";
import Pango from "gi://Pango?version=1.0";
@register({ GTypeName: "Tile" })
export class Tile extends Gtk.Box {
@signal(Boolean) toggled(_state: boolean) {}
@signal() enabled() {}
@signal() disabled() {}
@signal() clicked() {
if(this.enableOnClicked)
this.enable();
}
@property(String)
public icon: string;
@property(String)
public title: string;
@property(String)
public description: string = "";
@property(Boolean)
public enableOnClicked: boolean = false;
@property(Boolean)
public state: boolean = false;
@property(Boolean)
public hasArrow: 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.state = true;
!this.has_css_class("enabled") &&
this.add_css_class("enabled");
this.emit("toggled", true);
this.emit("enabled");
}
public disable(): void {
if(!this.state) return;
this.state = false;
this.remove_css_class("enabled");
this.emit("toggled", false);
this.emit("disabled");
}
constructor(props: Partial<Omit<Gtk.Box.ConstructorProps, "orientation">> & {
icon: string;
title: string;
description?: string;
state?: boolean;
enableOnClicked?: boolean;
hasArrow?: boolean;
}) {
super(omitObjectKeys(props, [
"icon",
"title",
"description",
"state",
"enableOnClicked"
]));
this.add_css_class("tile");
this.add_controller(
<Gtk.GestureClick onReleased={(_, __, px, py) => {
// gets the icon part of the tile
const { x, y, width, height } = this.get_first_child()!.get_allocation();
if((px < x || px > x+width) || (py < y || y > py+height))
this.emit("clicked");
}} /> as Gtk.GestureClick
);
this.icon = props.icon;
this.title = props.title;
this.hexpand = true;
if(props.hasArrow !== undefined)
this.hasArrow = props.hasArrow;
if(props.description !== undefined)
this.description = props.description;
if(props.state !== undefined)
this.state = props.state;
if(props.enableOnClicked !== undefined)
this.enableOnClicked = props.enableOnClicked;
this.state &&
this.add_css_class("enabled"); // fix no highlight when enabled on init
this.prepend(
<Gtk.Box hexpand={false} vexpand class={"icon"}>
<Gtk.Image iconName={createBinding(this, "icon")} halign={Gtk.Align.CENTER} />
<Gtk.GestureClick onReleased={() => {
this.state ? this.disable() : this.enable();
}} />
</Gtk.Box> as Gtk.Box
);
this.append(
<Gtk.Box class={"content"} orientation={Gtk.Orientation.VERTICAL} vexpand
valign={Gtk.Align.CENTER} hexpand>
<Gtk.Label class={"title"} label={createBinding(this, "title")}
xalign={0} ellipsize={Pango.EllipsizeMode.END} hexpand={false}
maxWidthChars={10} />
<Gtk.Label class={"description"} label={createBinding(this, "description")}
xalign={0} ellipsize={Pango.EllipsizeMode.END} visible={
variableToBoolean(createBinding(this, "description"))
} maxWidthChars={12} hexpand={false}
/>
</Gtk.Box> as Gtk.Box
);
if(this.hasArrow)
this.append(
<Gtk.Image class={"arrow"} iconName={"go-next-symbolic"}
halign={Gtk.Align.END}
/> as Gtk.Image
);
}
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);
}
}

View File

@@ -0,0 +1,37 @@
import { Gtk } from "ags/gtk4";
import { TileNetwork } from "./Network";
import { TileBluetooth } from "./Bluetooth";
import { TileDND } from "./DoNotDisturb";
import { TileRecording } from "./Recording";
// import { TileNightLight } from "./NightLight";
import { Pages } from "../pages";
import { createRoot, getScope } from "ags";
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 createRoot((dispose) => {
getScope().onCleanup(() => TilesPages = undefined);
return <Gtk.Box class={"tiles-container"} orientation={Gtk.Orientation.VERTICAL}
onDestroy={() => dispose()}>
<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;
});
}