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,85 @@
import { Astal, Gdk, Gtk } from "ags/gtk4";
import { execApp, getAppIcon, getApps, getAstalApps } from "../../modules/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 "../../modules/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>
}

View File

@@ -0,0 +1,44 @@
import { Astal, Gtk } from "ags/gtk4";
import { Tray } from "./widgets/Tray";
import { Workspaces } from "./widgets/Workspaces";
import { FocusedClient } from "./widgets/FocusedClient";
import { Apps } from "./widgets/Apps";
import { Clock } from "./widgets/Clock";
import { Status } from "./widgets/Status";
import { Media } from "./widgets/Media";
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}
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>
}

View File

@@ -0,0 +1,13 @@
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")}
/>;

View File

@@ -0,0 +1,16 @@
import { Gtk } from "ags/gtk4";
import { Windows } from "../../../windows";
import { createBinding } from "ags";
import { time } from "../../../modules/utils";
import { generalConfig } from "../../../config";
export const Clock = () =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((wins) =>
`clock ${wins.includes("center-window") ? "open" : ""}`)}
onClicked={() => Windows.getDefault().toggle("center-window")}
label={time((dt) => dt.format(
generalConfig.getProperty("clock.date_format", "string"))
?? "An error occurred"
)}
/>;

View File

@@ -0,0 +1,42 @@
import { CompositorHyprland } from "../../../modules/compositors/hyprland";
import { Gtk } from "ags/gtk4";
import { createBinding, With } from "ags";
import { variableToBoolean } from "../../../modules/utils";
import { getAppIcon, getSymbolicIcon } from "../../../modules/apps";
import Pango from "gi://Pango?version=1.0";
const hyprland = new CompositorHyprland;
export const FocusedClient = () => {
const focusedClient = createBinding(hyprland, "focusedClient");
return <Gtk.Box class={"focused-client"} visible={variableToBoolean(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>;
}

View File

@@ -0,0 +1,130 @@
import { createBinding, With } from "ags";
import { Gtk } from "ags/gtk4";
import { Separator } from "../../../widget/Separator";
import { Windows } from "../../../windows";
import { Clipboard } from "../../../modules/clipboard";
import { getPlayerIconFromBusName, secureBaseBinding, variableToBoolean } from "../../../modules/utils";
import { tr } from "../../../i18n/intl";
import { default as Player } from "../../../modules/media";
import AstalMpris from "gi://AstalMpris";
import Pango from "gi://Pango?version=1.0";
export const Media = () =>
<Gtk.Box class={"media"} visible={createBinding(Player.getDefault(), "player").as(p => p.available)}>
<Gtk.EventControllerScroll $={(self) => {
self.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
}} onScroll={(_, __, dy) => {
if(AstalMpris.get_default().players.length === 1 &&
Player.getDefault().player.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.getDefault().player.busName)
continue;
if(dy > 0 && players[i-1]) {
Player.getDefault().player = players[i-1];
break;
}
if(dy < 0 && players[i+1]) {
Player.getDefault().player = players[i+1];
break;
}
}
return true;
}}
/>
<Gtk.GestureClick onReleased={() => Windows.getDefault().toggle("center-window")} />
<Gtk.EventControllerMotion onEnter={(self) => {
const revealer = self.get_widget()!.get_last_child() as Gtk.Revealer;
revealer.set_reveal_child(true);
}} onLeave={(self) => {
const revealer = self.get_widget()!.get_last_child() as Gtk.Revealer;
revealer.set_reveal_child(false);
}}
/>
<Gtk.Box spacing={4} visible={createBinding(Player.getDefault(), "player")
.as(p => p.available)}>
<With value={createBinding(Player.getDefault(), "player")
.as(p => p.available)}>
{(available: boolean) => available && <Gtk.Box>
<Gtk.Image class={"player-icon"} iconName={
secureBaseBinding<AstalMpris.Player>(createBinding(
Player.getDefault(), "player"
), "busName", "org.MediaPlayer2.folder-music-symbolic").as(
getPlayerIconFromBusName
)}
/>
<Gtk.Label class={"title"} label={secureBaseBinding<AstalMpris.Player>(createBinding(
Player.getDefault(), "player"
), "title", "").as(title => title ?? tr("media.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={secureBaseBinding<AstalMpris.Player>(createBinding(
Player.getDefault(), "player"
), "artist", "").as(artist => artist ?? tr("media.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={createBinding(Player.getDefault(), "player")
.as(p => p.available)}>
{(available: boolean) => available && <Gtk.Box class={"buttons"} spacing={4}>
<Gtk.Box class={"extra button-row"}>
<Gtk.Button class={"link"} iconName={"edit-paste-symbolic"}
visible={variableToBoolean(Player.accessMediaUrl(Player.getDefault().player))}
tooltipText={tr("copy_to_clipboard")} onClicked={() => {
const url = Player.getMediaUrl(Player.getDefault().player);
url && Clipboard.getDefault().copyAsync(url);
}}
/>
</Gtk.Box>
<Gtk.Box class={"media-controls button-row"}>
<Gtk.Button class={"previous"} iconName={"media-skip-backward-symbolic"}
tooltipText={tr("media.previous")} onClicked={() =>
Player.getDefault().player.canGoPrevious &&
Player.getDefault().player.previous()
}
/>
<Gtk.Button class={"play-pause"} iconName={secureBaseBinding<AstalMpris.Player>(
createBinding(Player.getDefault(), "player"),
"playbackStatus",
AstalMpris.PlaybackStatus.PAUSED
).as(status => status === AstalMpris.PlaybackStatus.PAUSED ?
"media-playback-start-symbolic"
: "media-playback-pause-symbolic"
)}
tooltipText={secureBaseBinding<AstalMpris.Player>(
createBinding(Player.getDefault(), "player"),
"playbackStatus",
AstalMpris.PlaybackStatus.PAUSED
).as(status => status === AstalMpris.PlaybackStatus.PAUSED ?
tr("media.play") : tr("media.pause")
)} onClicked={() => Player.getDefault().player.play_pause()}
/>
<Gtk.Button class={"next"} iconName={"media-skip-forward-symbolic"}
tooltipText={tr("media.next")} onClicked={() => Player.getDefault().player.canGoNext &&
Player.getDefault().player.next()}
/>
</Gtk.Box>
</Gtk.Box>}
</With>
</Gtk.Revealer>
</Gtk.Box> as Gtk.Box;

View File

@@ -0,0 +1,218 @@
import { Gtk } from "ags/gtk4";
import { Wireplumber } from "../../../modules/volume";
import { Battery } from "../../../modules/battery";
import { Notifications } from "../../../modules/notifications";
import { Windows } from "../../../windows";
import { Recording } from "../../../modules/recording";
import { Accessor, createBinding, createComputed, With } from "ags";
import { variableToBoolean } from "../../../modules/utils";
import { Bluetooth } from "../../../modules/bluetooth";
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={() => { console.log("Status clicked!"); Windows.getDefault().toggle("control-center"); }}
>
<Gtk.Box>
<Gtk.Box class={"volume-indicators"} spacing={5}>
<BatteryStatus
visible={Battery.getDefault().bindHasBattery()}
class="battery"
icon={Battery.getDefault().bindIcon()}
percentage={Battery.getDefault().bindPercentage()}
></BatteryStatus>
<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={createBinding(Recording.getDefault(), "recordingTime")}
/>
</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 BatteryStatus(props: {
visible?: Accessor<boolean>;
class?: string;
percentage?: Accessor<string>;
icon?: string | Accessor<string>;
}) {
return (
<Gtk.Box visible={props.visible} spacing={2} class={props.class}>
{props.icon && <Gtk.Image iconName={props.icon} />}
<Gtk.Label class={"level"} label={props.percentage} />
</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(Bluetooth.getDefault(), "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>
);
}

View File

@@ -0,0 +1,60 @@
import { createBinding, createComputed, For, With } from "ags";
import { Gdk, Gtk } from "ags/gtk4";
import { variableToBoolean } from "../../../modules/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);
// Set popover parent to this box
popover.set_parent(self);
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} />
</Gtk.Box>;
}}
</With>
</Gtk.Box>}
</For>
</Gtk.Box>
}

View File

@@ -0,0 +1,144 @@
import { Gtk } from "ags/gtk4";
import { getAppIcon, getSymbolicIcon } from "../../../modules/apps";
import { Separator } from "../../../widget/Separator";
import { generalConfig } from "../../../config";
import { createBinding, createComputed, createState, For, With } from "ags";
import { variableToBoolean } from "../../../modules/utils";
import AstalHyprland from "gi://AstalHyprland";
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)),
focusedWorkspace = createBinding(AstalHyprland.get_default(), "focusedWorkspace");
return <Gtk.Box class={"workspaces-row"} visible={createComputed([
workspaces.as(wss => wss.length <= 1),
generalConfig.bindProperty("workspaces.hide_if_single", "boolean")
], (hideable, enabled) => enabled && hideable ? false : true
)}>
<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}>
<Gtk.EventControllerScroll $={(self) => self.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)}
onScroll={(_, __, dy) => {
dy > 0 ?
AstalHyprland.get_default().dispatch("workspace", "e-1")
: AstalHyprland.get_default().dispatch("workspace", "e+1");
return true;
}}
/>
<Gtk.EventControllerMotion onEnter={() => setShowNumbers(true)}
onLeave={() => setShowNumbers(false)}
/>
<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={() => focusedWorkspace.get()?.id !== ws.id && ws.focus()}>
<With value={createBinding(ws, "lastClient")}>
{(lastClient: AstalHyprland.Client) =>
<Gtk.Box class={"last-client"} hexpand>
<Gtk.Revealer transitionDuration={280} revealChild={showId}
transitionType={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>
}

View File

@@ -0,0 +1,45 @@
import { Gdk, Gtk } from "ags/gtk4";
import { Separator } from "../../widget/Separator";
import { PopupWindow } from "../../widget/PopupWindow";
import { BigMedia } from "./widgets/BigMedia";
import { time, variableToBoolean } from "../../modules/utils";
import { createBinding } from "ags";
import Media from "../../modules/media";
import AstalMpris from "gi://AstalMpris";
export const CenterWindow = (mon: number) =>
<PopupWindow namespace={"center-window"} marginTop={10} monitor={mon}
halign={Gtk.Align.CENTER} valign={Gtk.Align.START}
actionKeyPressed={(_, keyval) => {
if(keyval === Gdk.KEY_space) {
Media.getDefault().player.available &&
Media.getDefault().player.play_pause();
return true;
}
}}>
<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>
<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={variableToBoolean(
createBinding(AstalMpris.get_default(), "players")
)}
/>
<BigMedia />
</Gtk.Box>
</PopupWindow>;

View File

@@ -0,0 +1,235 @@
import { createBinding, For } from "ags";
import { register } from "ags/gobject";
import { Astal, Gtk } from "ags/gtk4";
import { Clipboard } from "../../../modules/clipboard";
import { pathToURI, variableToBoolean } from "../../../modules/utils";
import { tr } from "../../../i18n/intl";
import Media from "../../../modules/media";
import AstalMpris from "gi://AstalMpris";
import Pango from "gi://Pango?version=1.0";
import Adw from "gi://Adw?version=1";
import GLib from "gi://GLib?version=2.0";
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 && Media.getDefault().player.busName !== page.player.busName)
Media.getDefault().player = page.player;
}}>
<For each={availablePlayers.as(players => players.sort(pl =>
pl.busName === Media.getDefault().player.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={270}
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;
#copyClickTimeout?: GLib.Source;
#dragTimer?: GLib.Source;
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, "coverArt").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 ?? tr("media.no_title"))
} label={
createBinding(player, "title").as(title => title ?? tr("media.no_title"))
} ellipsize={Pango.EllipsizeMode.END} maxWidthChars={25}
/>
<Gtk.Label class={"artist"} tooltipText={
createBinding(player, "artist").as(artist => artist ?? tr("media.no_artist"))
} label={
createBinding(player, "artist").as(artist => artist ?? tr("media.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 == null) return;
if(!this.#dragTimer) {
this.#dragTimer = setTimeout(() =>
player.position = Math.floor(value)
, 200);
return;
}
this.#dragTimer?.destroy();
this.#dragTimer = setTimeout(() =>
player.position = Math.floor(value)
, 200);
}}
/>
</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 spacing={4} $type="center">
<Gtk.Box class={"extra button-row"}>
<Gtk.Button class={"link"}
tooltipText={tr("copy_to_clipboard")}
visible={variableToBoolean(Media.accessMediaUrl(player))}
onClicked={(self) => {
const url = Media.accessMediaUrl(player).get();
// a widget that supports adding multiple icons and allows switching
// through them would be pretty nice!! (i'll probably do this later)
url &&
Clipboard.getDefault().copyAsync(url).then(() => {
if(this.#copyClickTimeout && !this.#copyClickTimeout.is_destroyed())
this.#copyClickTimeout.destroy();
(self.get_child() as Gtk.Stack).set_visible_child_name("done-icon");
this.#copyClickTimeout = setTimeout(() => {
(self.get_child() as Gtk.Stack).set_visible_child_name("copy-icon");
this.#copyClickTimeout!.destroy();
this.#copyClickTimeout = undefined;
}, 1100);
}).catch(() => {
if(this.#copyClickTimeout && !this.#copyClickTimeout.is_destroyed())
this.#copyClickTimeout.destroy();
(self.get_child() as Gtk.Stack).set_visible_child_name("error-icon");
this.#copyClickTimeout = setTimeout(() => {
(self.get_child() as Gtk.Stack).set_visible_child_name("copy-icon");
this.#copyClickTimeout!.destroy();
this.#copyClickTimeout = undefined;
}, 900);
});
}}>
<Gtk.Stack transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={340}>
<Gtk.StackPage name={"copy-icon"} child={
<Gtk.Image iconName={"edit-paste-symbolic"} /> as Gtk.Widget
} />
<Gtk.StackPage name={"done-icon"} child={
<Gtk.Image iconName={"object-select-symbolic"} /> as Gtk.Widget
} />
<Gtk.StackPage name={"error-icon"} child={
<Gtk.Image iconName={"window-close-symbolic"} /> as Gtk.Widget
} />
</Gtk.Stack>
</Gtk.Button>
</Gtk.Box>
<Gtk.Box class={"media-controls button-row"}>
<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 ?
tr("media.shuffle")
: tr("media.follow_order"))} onClicked={() => player.shuffle()}
/>
<Gtk.Button class={"previous"} iconName={"media-skip-backward-symbolic"}
tooltipText={tr("media.previous")} onClicked={() => player.canGoPrevious && player.previous()}
/>
<Gtk.Button class={"play-pause"} tooltipText={
createBinding(player, "playbackStatus").as(status =>
status === AstalMpris.PlaybackStatus.PLAYING ? tr("media.pause") : tr("media.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={tr("media.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 tr("media.song_loop");
if(status === AstalMpris.Loop.PLAYLIST)
return tr("media.loop");
return tr("media.no_loop");
})} onClicked={() => player.loop()}
/>
</Gtk.Box>
</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;
}
}

View File

@@ -0,0 +1,25 @@
import { Astal, Gtk } from "ags/gtk4";
import { PopupWindow } from "../../widget/PopupWindow";
import { QuickActions } from "./widgets/QuickActions";
import { NotifHistory } from "./widgets/NotifHistory";
import { Tiles } from "./widgets/tiles";
import { Sliders } from "./widgets/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;

View File

@@ -0,0 +1,50 @@
import { Gtk } from "ags/gtk4";
import { HistoryNotification, Notifications } from "../../../modules/notifications";
import { NotificationWidget } from "../../../widget/Notification";
import { tr } from "../../../i18n/intl";
import { createBinding, For } from "ags";
import AstalNotifd from "gi://AstalNotifd";
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;

View File

@@ -0,0 +1,201 @@
import { Gtk } from "ags/gtk4";
import { Separator } from "../../../widget/Separator";
import { Accessor, createBinding, createRoot, For, Node } from "ags";
import { gtype, property, register } from "ags/gobject";
import { variableToBoolean } from "../../../modules/utils";
import Pango from "gi://Pango?version=1.0";
import GObject from "gi://GObject?version=2.0";
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;
};
@register({ GTypeName: "Page" })
export class Page extends GObject.Object {
readonly #id: string;
readonly #create: () => Node;
public readonly actionClosed?: () => void;
public readonly actionOpen?: () => void;
public get id() { return this.#id; }
@property(String)
title: string;
@property(gtype<string|null>(String))
description: string|null = null;
@property(gtype<Gtk.Orientation>(Number))
orientation: Gtk.Orientation = Gtk.Orientation.VERTICAL;
@property(Number)
spacing: number = 4;
@property(Array<HeaderButton>)
headerButtons: Array<HeaderButton> = [];
@property(Array<BottomButton>)
bottomButtons: Array<BottomButton> = [];
constructor(props: {
id: string;
title: string;
description?: string;
headerButtons?: Array<HeaderButton>;
bottomButtons?: Array<BottomButton>;
orientation?: Gtk.Orientation;
spacing?: number;
content: () => Node;
actionOpen?: () => void;
actionClosed?: () => void;
}) {
super();
this.#id = props.id;
this.#create = props.content;
this.title = props.title;
this.actionClosed = props.actionClosed;
this.actionOpen = props.actionOpen;
if(props.orientation != null)
this.orientation = props.orientation;
if(props.description != null)
this.description = props.description;
if(props.spacing != null)
this.spacing = props.spacing;
if(props.headerButtons != null)
this.headerButtons = props.headerButtons;
if(props.bottomButtons != null)
this.bottomButtons = props.bottomButtons;
if(props.actionOpen != null)
this.actionOpen = props.actionOpen;
if(props.actionClosed != null)
this.actionClosed = props.actionClosed;
}
public create(): Gtk.Box {
return createRoot((dispose) =>
<Gtk.Box hexpand class={`page container ${this.#id ?? ""}`} cssName={"page"} name={"page"}
orientation={Gtk.Orientation.VERTICAL}
onDestroy={() => 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={createBinding(this, "title")} xalign={0}
ellipsize={Pango.EllipsizeMode.END} />
<Gtk.Label class={"description"} label={createBinding(this, "description").as(desc =>
desc ?? ""
)} xalign={0} ellipsize={Pango.EllipsizeMode.END}
visible={variableToBoolean(createBinding(this, "description"))} />
</Gtk.Box>
<Gtk.Box class={"button-row"} visible={variableToBoolean(
createBinding(this, "headerButtons")
)} hexpand={false}>
<For each={createBinding(this, "headerButtons")}>
{(button: HeaderButton) =>
<Gtk.Button class={"header-button"} label={button.label}
iconName={button.icon} onClicked={() => button.actionClicked?.()}
tooltipText={button.tooltipText} tooltipMarkup={button.tooltipMarkup}
/>
}
</For>
</Gtk.Box>
</Gtk.Box>
</Gtk.Box>
<Gtk.Box class={"content"} hexpand={false} orientation={createBinding(this, "orientation")}
spacing={createBinding(this, "spacing")}>
{this.#create()}
</Gtk.Box>
<Separator alpha={.2} spacing={6} orientation={Gtk.Orientation.VERTICAL}
visible={variableToBoolean(createBinding(this, "bottomButtons"))}
/>
<Gtk.Box class={"bottom-buttons"} orientation={Gtk.Orientation.VERTICAL}
visible={variableToBoolean(createBinding(this, "bottomButtons"))} spacing={2}>
<For each={createBinding(this, "bottomButtons")}>
{(button: BottomButton) =>
<PageButton actionClicked={() => button.actionClicked?.()}
tooltipText={button.tooltipText}
tooltipMarkup={button.tooltipMarkup}
title={button.title}
description={button.description}
/>
}
</For>
</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?: Node;
description?: string | Accessor<string>;
extraButtons?: Node;
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 as Node}
</Gtk.Box>
</Gtk.Box> as Gtk.Box;
}

View File

@@ -0,0 +1,91 @@
import { Gtk } from "ags/gtk4";
import { Windows } from "../../../windows";
import { Wallpaper } from "../../../modules/wallpaper";
import { execApp } from "../../../modules/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("file://${userFace.get_path()!}");`}
/>
}
<Gtk.Box orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Box class={"user-host"}>
<Gtk.Label class={"user"} xalign={0}
label={GLib.get_user_name()} />
<Gtk.Label class={"host"} xalign={0} yalign={.8}
label={`@${GLib.get_host_name()}`} />
</Gtk.Box>
<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;

View File

@@ -0,0 +1,76 @@
import { Astal, Gtk } from "ags/gtk4";
import { Wireplumber } from "../../../modules/volume";
import { Pages } from "./pages";
import { PageSound } from "./pages/Sound";
import { PageMicrophone } from "./pages/Microphone";
import { createBinding, With } from "ags";
import { Backlights } from "../../../modules/backlight";
import { PageBacklight } from "./pages/Backlight";
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>
<Gtk.Box visible={createBinding(Backlights.getDefault(), "available")}>
<With value={createBinding(Backlights.getDefault(), "default")}>
{(bklight: Backlights.Backlight|null) => bklight &&
<Gtk.Box class={"backlight"} spacing={3}>
<Gtk.Button onClicked={() => {
bklight.brightness = bklight.maxBrightness
}} iconName={"display-brightness-symbolic"}
/>
<Astal.Slider drawValue={false} hexpand value={createBinding(bklight, "brightness")}
max={bklight.maxBrightness}
onChangeValue={(_, __, value) => {
bklight.brightness = value
}}
/>
<Gtk.Button class={"more"} iconName={"go-next-symbolic"} onClicked={() =>
slidersPages?.toggle(PageBacklight)} />
</Gtk.Box>
}
</With>
</Gtk.Box>
<Pages $={(self) => slidersPages = self} />
</Gtk.Box>
}

View File

@@ -0,0 +1,84 @@
import { Astal, Gtk } from "ags/gtk4";
import { tr } from "../../../../i18n/intl";
import { Backlights } from "../../../../modules/backlight";
import { Page, PageButton } from "../Page";
import { createBinding, For, With } from "ags";
import { addSliderMarksFromMinMax } from "../../../../modules/utils";
import { userData } from "../../../../config";
export const PageBacklight = <Page
id={"backlight"}
title={tr("control_center.pages.backlight.title")}
description={tr("control_center.pages.backlight.description")}
actionOpen={() => {
const dataDefaultBacklight = userData.getProperty("control_center.default_backlight", "any");
if(typeof dataDefaultBacklight === "string" &&
Backlights.getDefault().default?.name !== dataDefaultBacklight) {
const bk = Backlights.getDefault().backlights.filter(b => b.name === dataDefaultBacklight)[0];
if(!bk) return;
Backlights.getDefault().setDefault(bk);
}
}}
content={() => (
<With value={createBinding(Backlights.getDefault(), "backlights")}>
{(bklights: Array<Backlights.Backlight>) => bklights.length > 0 &&
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={4}>
<Gtk.Box class={"list"} visible={createBinding(Backlights.getDefault(), "backlights")
.as((bklights) => bklights.length > 1)}>
<Gtk.Label label={"Default"} />
<For each={createBinding(Backlights.getDefault(), "backlights")}>
{(bk: Backlights.Backlight) =>
<PageButton class={createBinding(bk, "isDefault").as(is => is ? "highlight" : "")}
title={bk.name}
icon={"video-display-symbolic"}
actionClicked={() => {
if(Backlights.getDefault().default?.path !== bk.path) {
Backlights.getDefault().setDefault(bk);
// save data
userData.setProperty(
"control_center.default_backlight",
bk.name,
true
);
}
}}
endWidget={
<Gtk.Image iconName={"object-select-symbolic"}
visible={createBinding(bk, "isDefault")}
/>
}
/>
}
</For>
</Gtk.Box>
<Gtk.Box class={"sliders"} orientation={Gtk.Orientation.VERTICAL} spacing={6}>
{bklights.map((bklight, i) =>
<Gtk.Box class={"bklight"} orientation={Gtk.Orientation.VERTICAL}
spacing={4}>
<Gtk.Label class={"subheader"} label={`Backlight ${i+1} (${bklight.name})`}
xalign={0} />
<Astal.Slider $={(self) => addSliderMarksFromMinMax(self)}
min={0} max={bklight.maxBrightness}
value={createBinding(bklight, "brightness")}
onChangeValue={(_, __, value) => {
bklight.brightness = value
}}
/>
</Gtk.Box>
)}
</Gtk.Box>
</Gtk.Box>
}
</With>
)}
headerButtons={[{
icon: "arrow-circular-top-right",
tooltipText: tr("control_center.pages.backlight.refresh"),
actionClicked: () => Backlights.getDefault().scan()
}]}
/> as Page;

View File

@@ -0,0 +1,205 @@
import { Gtk } from "ags/gtk4";
import { Page, PageButton } from "../Page";
import { tr } from "../../../../i18n/intl";
import { Windows } from "../../../../windows";
import { Notifications } from "../../../../modules/notifications";
import { execApp } from "../../../../modules/apps";
import { createBinding, createComputed, For, With } from "ags";
import { variableToBoolean } from "../../../../modules/utils";
import { Bluetooth } from "../../../../modules/bluetooth";
import AstalNotifd from "gi://AstalNotifd";
import AstalBluetooth from "gi://AstalBluetooth";
import Adw from "gi://Adw?version=1";
import Gio from "gi://Gio?version=2.0";
export const BluetoothPage = <Page
id={"bluetooth"}
title={tr("control_center.pages.bluetooth.title")}
spacing={6}
description={tr("control_center.pages.bluetooth.description")}
headerButtons={createBinding(Bluetooth.getDefault(), "adapter").as(adapter => adapter ? [{
icon: createBinding(adapter, "discovering")
.as(discovering => !discovering ?
"arrow-circular-top-right-symbolic"
: "media-playback-stop-symbolic"
),
tooltipText: createBinding(adapter, "discovering")
.as((discovering) => !discovering ?
tr("control_center.pages.bluetooth.start_discovering")
: tr("control_center.pages.bluetooth.stop_discovering")),
actionClicked: () => {
if(adapter.discovering) {
adapter.stop_discovery();
return;
}
adapter.start_discovery();
}
}]: [])}
actionClosed={() => Bluetooth.getDefault().adapter?.discovering &&
Bluetooth.getDefault().adapter?.stop_discovery()}
bottomButtons={[{
title: tr("control_center.pages.more_settings"),
actionClicked: () => {
Windows.getDefault().close("control-center");
execApp("overskride", "[float; animation slide right]");
}
}]}
content={() => {
const adapters = createBinding(AstalBluetooth.get_default(), "adapters");
const devices = createBinding(AstalBluetooth.get_default(), "devices");
const knownDevices = devices.as(devs => devs.filter(dev =>
dev.trusted || dev.paired || dev.connected
).sort(dev => dev.connected ? 1 : 0));
const discoveredDevices = devices.as(devs => devs.filter(dev =>
!dev.trusted && !dev.paired && !dev.connected)
);
return [
<Gtk.Box class={"adapters"} visible={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={adapters.as(adpts => adpts.length > 1)}>
{(hasMoreAdapters: boolean) => hasMoreAdapters &&
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={2}>
<For each={adapters}>
{(adapter: AstalBluetooth.Adapter) => {
const isSelected = createBinding(Bluetooth.getDefault(), "adapter").as(a =>
adapter.address === a?.address);
return <PageButton class={isSelected.as(is => is ? "selected" : "")}
title={adapter.alias ?? "Adapter"} icon={"bluetooth-active-symbolic"}
description={createBinding(adapter, "address")}
actionClicked={() => {
if(adapter.address !== Bluetooth.getDefault().adapter?.address)
Bluetooth.getDefault().adapter = adapter;
}}
endWidget={
<Gtk.Image iconName={"object-select-symbolic"} visible={isSelected} />
}
/>;
}}
</For>
</Gtk.Box>
}
</With>
</Gtk.Box>,
<Gtk.Box class={"connections"} orientation={Gtk.Orientation.VERTICAL} hexpand
spacing={2}>
<Gtk.Box class={"paired"} orientation={Gtk.Orientation.VERTICAL} spacing={4}
visible={variableToBoolean(knownDevices)}>
<Gtk.Label class={"sub-header"} label={tr("devices")} xalign={0} />
<For each={knownDevices}>
{(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />}
</For>
</Gtk.Box>
<Gtk.Box class={"discovered"} orientation={Gtk.Orientation.VERTICAL} spacing={4}
visible={variableToBoolean(discoveredDevices)}>
<Gtk.Label class={"sub-header"} label={tr("control_center.pages.bluetooth.new_devices")}
xalign={0} />
<For each={discoveredDevices}>
{(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />}
</For>
</Gtk.Box>
</Gtk.Box>
];
}}
/> as Page;
function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget {
const pair = async () => {
if(device.paired) return;
device.pair();
device.set_trusted(true);
};
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")}
tooltipText={
createBinding(device, "connected").as(connected =>
!connected ? tr("connect") : "")
} actionClicked={() => {
if(device.connected) return;
pair().then(() => {
device.connect_device((_, res) => {
// get error
try { device.connect_device_finish(res); }
catch(e: any) {
Notifications.getDefault().sendNotification({
appName: "bluetooth",
summary: "Connection Error",
body: `An error occurred while attempting to connect to ${
device.alias ?? device.name}: ${(e as Gio.IOErrorEnum).message}`
});
}
});
}).catch((err: Gio.IOErrorEnum) =>
Notifications.getDefault().sendNotification({
appName: "bluetooth",
summary: "Pairing Error",
body: `Couldn't pair with ${device.alias ?? device.name}: ${err.message}`,
urgency: AstalNotifd.Urgency.NORMAL
})
);
}}
endWidget={<Gtk.Box spacing={6}>
<Adw.Spinner visible={createBinding(device, "connecting")} />
<Gtk.Box visible={createComputed([
createBinding(device, "batteryPercentage"),
createBinding(device, "connected")
]).as(([batt, connected]) => connected && (batt > -1))
} spacing={4}>
<Gtk.Label halign={Gtk.Align.END} label={
createBinding(device, "batteryPercentage").as(batt =>
`${Math.floor(batt * 100)}%`)
} visible={createBinding(device, "connected")}
/>
<Gtk.Image iconName={
createBinding(device, "batteryPercentage").as(batt =>
`battery-level-${Math.floor(batt * 100)}-symbolic`)
} css={"font-size: 16px; margin-left: 6px;"} />
</Gtk.Box>
</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) {
Bluetooth.getDefault().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;
}

View File

@@ -0,0 +1,34 @@
import { Page, PageButton } from "../Page";
import { Wireplumber } from "../../../../modules/volume";
import { Gtk } from "ags/gtk4";
import { tr } from "../../../../i18n/intl";
import { createBinding, For } from "ags";
import { lookupIcon } from "../../../../modules/apps";
import AstalWp from "gi://AstalWp?version=0.1";
export const PageMicrophone = <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>
]}
/> as Page;

View File

@@ -0,0 +1,170 @@
import { Gtk } from "ags/gtk4";
import { Page, PageButton } from "../Page";
import { Windows } from "../../../../windows";
import { tr } from "../../../../i18n/intl";
import { execApp } from "../../../../modules/apps";
import { Notifications } from "../../../../modules/notifications";
import { AskPopup, AskPopupProps } from "../../../../widget/AskPopup";
import { encoder, variableToBoolean } from "../../../../modules/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 = <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>
]}
/> as Page;
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);
}

View File

@@ -0,0 +1,44 @@
import { Page } from "../Page";
import { NightLight } from "../../../../modules/nightlight";
import { tr } from "../../../../i18n/intl";
import { Astal, Gtk } from "ags/gtk4";
import { addSliderMarksFromMinMax } from "../../../../modules/utils";
import { createBinding } from "ags";
export const PageNightLight = <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)
}}
/>
]}
/> as Page;

View File

@@ -0,0 +1,86 @@
import { Page, PageButton } from "../Page";
import { Astal, Gtk } from "ags/gtk4";
import { getAppIcon, lookupIcon } from "../../../../modules/apps";
import { Wireplumber } from "../../../../modules/volume";
import { tr } from "../../../../i18n/intl";
import { createBinding, For } from "ags";
import { createScopedConnection, variableToBoolean } from "../../../../modules/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 = <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 controllerMotion = Gtk.EventControllerMotion.new();
self.add_controller(controllerMotion);
createScopedConnection(controllerMotion, "enter", () => {
const revealer = self.get_last_child()!.get_first_child() as Gtk.Revealer;
revealer.set_reveal_child(true);
});
createScopedConnection(controllerMotion, "leave", () => {
const revealer = self.get_last_child()!.get_first_child() as Gtk.Revealer;
revealer.set_reveal_child(false);
});
}}>
<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>
]}
/> as Page;

View File

@@ -0,0 +1,100 @@
import GObject, { getter, gtype, register } from "ags/gobject";
import { Gtk } from "ags/gtk4";
import { Page } from "../Page";
import GLib from "gi://GLib?version=2.0";
@register({ GTypeName: "Pages" })
export class Pages extends Gtk.Box {
#timeouts: Array<[GLib.Source, (() => void)|undefined]> = [];
#page: Page|undefined;
#transDuration: number;
#transType: Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.SLIDE_DOWN;
@getter(Boolean)
get isOpen() { return Boolean(this.#page); }
@getter(gtype<Page|undefined>(Page))
get page() { return this.#page; }
constructor(props?: {
initialPage?: Page;
transitionDuration?: number;
}) {
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", () => {
GObject.signal_handler_is_connected(this, destroyId) &&
this.disconnect(destroyId);
this.#timeouts.forEach((tmout) => {
tmout[0].destroy();
(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([
setTimeout(() => {
this.remove(page);
onClosed?.();
}, page.transitionDuration),
onClosed
]);
}
}

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;
});
}

View File

@@ -0,0 +1,94 @@
import { Astal, Gtk } from "ags/gtk4";
import { createBinding, createComputed, For } from "ags";
import { Notifications } from "../../modules/notifications";
import { NotificationWidget } from "../../widget/Notification";
import { generalConfig } from "../../config";
import AstalNotifd from "gi://AstalNotifd";
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={createComputed([
generalConfig.bindProperty("notifications.position_h", "string"),
generalConfig.bindProperty("notifications.position_v", "string")
]).as(([posH, posV]) => {
const pos: Array<Astal.WindowAnchor> = [];
switch(posH) {
case "left":
pos.push(Astal.WindowAnchor.LEFT);
break;
case "center":
pos.push(Astal.WindowAnchor.LEFT);
pos.push(Astal.WindowAnchor.RIGHT);
break;
case "right":
pos.push(Astal.WindowAnchor.RIGHT);
break;
}
switch(posV) {
case "top":
pos.push(Astal.WindowAnchor.TOP);
break;
case "center":
pos.push(Astal.WindowAnchor.TOP);
pos.push(Astal.WindowAnchor.BOTTOM);
break;
case "bottom":
pos.push(Astal.WindowAnchor.BOTTOM);
break;
}
let finalPos: Astal.WindowAnchor;
pos.forEach(pos => finalPos = (finalPos !== undefined ?
finalPos | pos
: pos));
return finalPos!;
})} 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) =>
<Gtk.Stack transitionType={createComputed([
generalConfig.bindProperty("notifications.position_h", "string"),
generalConfig.bindProperty("notifications.position_v", "string")
]).as(([posH, posV]) => {
//TODO: support different animations depending on screen position
return Gtk.StackTransitionType.SLIDE_RIGHT
})} transitionDuration={300}>
<Gtk.StackPage name={"notification"} child={
<Adw.Clamp maximumSize={size}>
<Gtk.Box class={"float-notification"} widthRequest={size} vexpand={false}
valign={Gtk.Align.CENTER} halign={Gtk.Align.CENTER}>
<NotificationWidget notification={notif} showTime={false}
actionClose={() => Notifications.getDefault().removeNotification(notif)}
holdOnHover actionClicked={() => {
const viewAction = notif.actions.filter(a =>
a.id.toLowerCase() === "view" ||
a.label.toLowerCase() === "view"
)?.[0];
viewAction && notif.invoke(viewAction.id);
}}
/>
</Gtk.Box>
</Adw.Clamp> as Gtk.Widget
}>
</Gtk.StackPage>
</Gtk.Stack>
}
</For>
</Gtk.Box>
</Astal.Window> as Astal.Window;

View File

@@ -0,0 +1,128 @@
import { Astal, Gdk, Gtk } from "ags/gtk4";
import { execAsync } from "ags/process";
import { generalConfig } from "../../config";
import { AskPopup } from "../../widget/AskPopup";
import { Notifications } from "../../modules/notifications";
import { NightLight } from "../../modules/nightlight";
import { time } from "../../modules/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;

View File

@@ -0,0 +1,105 @@
import { Astal, Gtk } from "ags/gtk4";
import { createBinding, createState, With } from "ags";
import { Wireplumber } from "../../modules/volume";
import { Windows } from "../../windows";
import { Backlights } from "../../modules/backlight";
import { secureBaseBinding, variableToBoolean } from "../../modules/utils";
import Pango from "gi://Pango?version=1.0";
import GLib from "gi://GLib?version=2.0";
import AstalWp from "gi://AstalWp?version=0.1";
import OSDMode from "./modules/osdmode";
export const OSDModes = {
sink: new OSDMode({
available: createBinding(AstalWp.get_default(), "defaultSpeaker").as((sink) =>
Boolean(sink)),
icon: secureBaseBinding<AstalWp.Endpoint>(
createBinding(AstalWp.get_default(), "defaultSpeaker"),
"volumeIcon",
"audio-volume-high-symbolic"
),
value: secureBaseBinding<AstalWp.Endpoint>(
createBinding(AstalWp.get_default(), "defaultSpeaker"),
"volume",
.5
),
text: secureBaseBinding<AstalWp.Endpoint>(
createBinding(AstalWp.get_default(), "defaultSpeaker"),
"description",
"Unknown Speaker"
),
max: Wireplumber.getDefault().getMaxSinkVolume() / 100
}),
brightness: new OSDMode({
icon: "display-brightness-symbolic",
value: secureBaseBinding<Backlights.Backlight>(
createBinding(Backlights.getDefault(), "default"),
"brightness",
100
),
max: secureBaseBinding<Backlights.Backlight>(
createBinding(Backlights.getDefault(), "default"),
"maxBrightness",
100
),
text: secureBaseBinding<Backlights.Backlight>(
createBinding(Backlights.getDefault(), "default"),
"name",
"Unknown Backlight"
),
available: createBinding(Backlights.getDefault(), "available")
})
}
const [osdMode, setOSDMode] = createState(OSDModes.sink);
let osdTimer: (GLib.Source|undefined), osdTimeout = 3500;
export const OSD = (mon: number) =>
<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"}>
<With value={osdMode}>
{(mode: OSDMode) => {
if(!mode.available) return;
return <Gtk.Box>
<Gtk.Image class={"icon"} iconName={
createBinding(mode, "icon")
} />
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} class={"level"} vexpand hexpand>
<Gtk.Label class={"text"} label={createBinding(mode, "text").as(t => t ?? "")}
ellipsize={Pango.EllipsizeMode.END}
visible={variableToBoolean(createBinding(mode, "text"))}
/>
<Gtk.LevelBar value={createBinding(mode, "value")} hexpand
maxValue={createBinding(mode, "max")}
/>
</Gtk.Box>
</Gtk.Box>;
}}
</With>
</Gtk.Box>
</Astal.Window>;
export function triggerOSD(mode: OSDMode) {
setOSDMode(mode);
Windows.getDefault().open("osd");
if(!osdTimer) {
osdTimer = setTimeout(() => {
osdTimer = undefined;
Windows.getDefault().close("osd");
}, osdTimeout);
return;
}
osdTimer.destroy();
osdTimer = setTimeout(() => {
Windows.getDefault().close("osd");
osdTimer = undefined;
}, osdTimeout);
}

View File

@@ -0,0 +1,39 @@
import { Accessor } from "ags";
import { construct } from "../../../modules/utils";
import GObject, { gtype, property, register } from "ags/gobject";
@register({ GTypeName: "OSDMode" })
export default class OSDMode extends GObject.Object {
readonly #subs: Array<() => void> = [];
@property(String)
icon: string = "image-missing";
@property(Number)
value: number = 0;
@property(Number)
max: number = 100;
@property(gtype<string|null>(String))
text: string|null = null;
@property(Boolean)
available: boolean = true;
constructor(props: {
icon: string | Accessor<string>;
value: number | Accessor<number>;
max?: number | Accessor<number>;
text?: string | Accessor<string>;
available?: boolean | Accessor<boolean>;
}) {
super();
this.#subs = construct(this, props);
}
vfunc_dispose(): void {
this.#subs.forEach(s => s());
}
}