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

now it's more organized and I have more control over the shell behaviour
This commit is contained in:
retrozinndev
2025-08-06 15:25:21 -03:00
parent 5a6d5b47c6
commit d549ad9596
191 changed files with 529 additions and 1000 deletions
+38
View File
@@ -0,0 +1,38 @@
import { Accessor } from "ags";
import { tr } from "../i18n/intl";
import { CustomDialog } from "./CustomDialog";
import { Astal } from "ags/gtk4";
export type AskPopupProps = {
title?: string | Accessor<string>;
text: string | Accessor<string>;
cancelText?: string;
acceptText?: string;
onAccept?: () => void;
onCancel?: () => void;
};
/**
* A Popup Widget that asks yes or no to a defined promt.
* Runs onAccept() when user accepts, or else onDecline() when
* user doesn't accept / closes window.
* This window isn't usually registered in this shell windowing
* system.
*/
export function AskPopup(props: AskPopupProps): Astal.Window {
let accepted: boolean = false;
return <CustomDialog namespace={"ask-popup"} widthRequest={400} heightRequest={250}
title={props.title ?? tr("ask_popup.title")} text={props.text}
onFinish={() => !accepted && props.onCancel?.()} options={[
{ text: props.cancelText ?? tr("cancel") },
{
text: props.acceptText ?? tr("accept"),
onClick: () => {
accepted = true;
props.onAccept?.();
}
}
]} /> as Astal.Window;
}
+90
View File
@@ -0,0 +1,90 @@
import { Accessor } from "ags";
import { Astal, Gdk, Gtk } from "ags/gtk4";
import GObject from "gi://GObject?version=2.0";
const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor;
export type BackgroundWindowProps = {
/** GtkWindow Layer */
layer?: Astal.Layer | Accessor<Astal.Layer>;
/** Monitor number where the window should open */
monitor: number | Accessor<number>;
/** Custom stylesheet used in the window. default: `background: rgba(0, 0, 0, .2)` */
css?: string | Accessor<string>;
/* Function that is called when the user releases a key in the keyboard on the window
* The `Escape` key is not passed to this function */
actionKeyPressed?: (window: Astal.Window, keyval: number, keycode: number) => void;
/** Function that is called when the user triggers a mouse-click or escape action on the window */
actionFired?: (window: Astal.Window) => void;
/** Function that is called when the user clicks on the window with primary mouse button */
actionClickPrimary?: (window: Astal.Window) => void;
/** Function that is called when the user clicks on the window with secodary mouse button */
actionClickSecondary?: (window: Astal.Window) => void;
onCloseRequest?: (window: Astal.Window) => void;
keymode?: Astal.Keymode;
exclusivity?: Astal.Exclusivity;
/** attach this window as a background for another window
* background-window will close when the attached window triggers ::close-request) */
attach?: Astal.Window;
};
/** Creates a fullscreen GtkWindow that is used for making
* the user focus on the content after this window(e.g.: AskPopup,
* Authentication Window(futurely) or any PopupWindow)
*
* @param props Properties for background-window
*
* @returns The generated background-window
*/
export function BackgroundWindow(props: BackgroundWindowProps): Astal.Window {
const conns: Map<GObject.Object, number> = new Map();
return <Astal.Window namespace={"background-window"} monitor={props.monitor} visible
layer={props.layer ?? Astal.Layer.OVERLAY} keymode={props.keymode ?? Astal.Keymode.EXCLUSIVE}
onCloseRequest={props.onCloseRequest} exclusivity={props.exclusivity ?? Astal.Exclusivity.IGNORE}
anchor={TOP | LEFT | BOTTOM | RIGHT} css={props.css ?? "background: rgba(0, 0, 0, .2);"}
$={(self) => {
const gestureClick = Gtk.GestureClick.new(),
eventControllerKey = Gtk.EventControllerKey.new();
gestureClick.set_button(0);
self.add_controller(gestureClick);
self.add_controller(eventControllerKey);
conns.set(eventControllerKey, eventControllerKey.connect("key-released",
(_, keyval, keycode) => {
if(keyval === Gdk.KEY_Escape) {
props.actionFired?.(self);
return;
}
props.actionKeyPressed?.(self, keyval, keycode);
}
));
conns.set(gestureClick, gestureClick.connect("released", (gesture) => {
if(gesture.get_current_button() === Gdk.BUTTON_PRIMARY) {
props.actionClickPrimary?.(self);
return;
}
if(gesture.get_current_button() === Gdk.BUTTON_SECONDARY) {
props.actionClickSecondary?.(self);
return;
}
props.actionFired?.(self);
}));
props.attach &&
conns.set(props.attach, (props.attach as Gtk.Widget).connect("close-request", () =>
self.close()
));
conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) =>
obj.disconnect(id))));
}} /> as Astal.Window;
}
+76
View File
@@ -0,0 +1,76 @@
import { Astal, Gtk } from "ags/gtk4";
import { Windows } from "../windows";
import { PopupWindow } from "./PopupWindow";
import { Separator } from "./Separator";
import { tr } from "../i18n/intl";
import { Accessor } from "ags";
import { transformWidget, variableToBoolean, WidgetNodeType } from "../scripts/utils";
export type CustomDialogProps = {
namespace?: string | Accessor<string>;
className?: string | Accessor<string>;
cssBackground?: string;
title?: string | Accessor<string>;
text?: string | Accessor<string>;
heightRequest?: number | Accessor<number>;
widthRequest?: number | Accessor<number>;
childOrientation?: Gtk.Orientation | Accessor<Gtk.Orientation>;
children?: WidgetNodeType;
onFinish?: () => void;
options?: Array<CustomDialogOption> | Accessor<Array<CustomDialogOption>>;
optionsOrientation?: Gtk.Orientation | Accessor<Gtk.Orientation>;
};
export interface CustomDialogOption {
onClick?: () => void;
text: string | Accessor<string>;
closeOnClick?: boolean | Accessor<boolean>;
}
function CustomDialogOption({closeOnClick = true, ...props}: CustomDialogOption & {
dialog: Astal.Window;
}) {
return <Gtk.Button class="option" hexpand label={props.text}
onClicked={() => {
props.onClick?.();
closeOnClick &&
props.dialog?.close();
}}
/>
}
export function CustomDialog({ options = [{ text: tr("accept") }], ...props}: CustomDialogProps) {
return Windows.getDefault().createWindowForFocusedMonitor((mon) => {
const popup = <PopupWindow namespace={props.namespace ?? "custom-dialog"} monitor={mon}
cssBackgroundWindow={props.cssBackground ?? "background: rgba(0, 0, 0, .3);"}
exclusivity={Astal.Exclusivity.IGNORE} layer={Astal.Layer.OVERLAY}
halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} actionClosed={() => props.onFinish?.()}
widthRequest={props.widthRequest ?? 400} heightRequest={props.heightRequest ?? 220}>
<Gtk.Box class={props.className ?? "custom-dialog-container"}
orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"title"} visible={variableToBoolean(props.title)} label={props.title} />
<Gtk.Label class={"text"} visible={variableToBoolean(props.text)} label={props.text}
vexpand valign={Gtk.Align.START} />
<Gtk.Box class={"custom-children custom-child"} visible={variableToBoolean(props.children)}
orientation={props.childOrientation ?? Gtk.Orientation.VERTICAL}>
{transformWidget(props.children, (child) => child as JSX.Element)}
</Gtk.Box>
<Separator alpha={.2} visible={options && options.length > 0}
spacing={8} orientation={Gtk.Orientation.VERTICAL} />
</Gtk.Box>
</PopupWindow> as Astal.Window;
(popup.get_child()!.get_first_child()!.get_first_child() as Gtk.Box).append(
<Gtk.Box class={"options"} orientation={props.optionsOrientation ?? Gtk.Orientation.HORIZONTAL}
hexpand={true} heightRequest={38} homogeneous={true}>
{transformWidget(options, (props) => <CustomDialogOption {...props} dialog={popup} />)}
</Gtk.Box> as Gtk.Box
);
return popup;
})();
}
+61
View File
@@ -0,0 +1,61 @@
import { Accessor } from "ags";
import { tr } from "../i18n/intl";
import { CustomDialog } from "./CustomDialog";
import { Astal, Gtk } from "ags/gtk4";
export type EntryPopupProps = {
title: string | Accessor<string>;
text?: string | Accessor<string>;
cancelText?: string | Accessor<string>;
acceptText?: string | Accessor<string>;
closeOnAccept?: boolean;
entryPlaceholder?: string | Accessor<string>;
onAccept: (userInput: string) => void;
onCancel?: () => void;
onFinish?: () => void;
isPassword?: boolean | Accessor<string>;
};
export function EntryPopup(props: EntryPopupProps): Astal.Window {
props.closeOnAccept = props.closeOnAccept ?? true;
let entered: boolean = false;
function onActivate(entry: Gtk.Entry|Gtk.PasswordEntry) {
props.closeOnAccept && window.close();
entered = true;
props.onAccept(entry.text);
entry.text = "";
}
const entry = props.isPassword ?
<Gtk.PasswordEntry class={"password"} xalign={.5}
placeholderText={props.entryPlaceholder}
onActivate={onActivate}
/> as Gtk.PasswordEntry
: <Gtk.Entry xalign={.5} placeholderText={props.entryPlaceholder}
onActivate={onActivate} /> as Gtk.Entry;
const window = <CustomDialog namespace={"entry-popup"} widthRequest={420}
heightRequest={220} title={props.title} text={props.text}
options={[
{
text: props.cancelText ?? tr("cancel"),
onClick: props.onCancel
},
{
text: props.acceptText ?? tr("accept"),
closeOnClick: props.closeOnAccept,
onClick: () => {
entered = true;
props.onAccept(entry.text);
entry.text = "";
}
}
]} onFinish={() => {
!entered && props.onCancel?.()
props.onFinish?.();
}}
/> as Astal.Window;
return window;
}
+124
View File
@@ -0,0 +1,124 @@
import { Gdk, Gtk } from "ags/gtk4";
import { Separator } from "./Separator";
import { HistoryNotification, Notifications } from "../scripts/notifications";
import { getAppIcon, getSymbolicIcon } from "../scripts/apps";
import { onCleanup } from "ags";
import AstalNotifd from "gi://AstalNotifd";
import Pango from "gi://Pango?version=1.0";
import GLib from "gi://GLib?version=2.0";
import GObject from "gi://GObject?version=2.0";
import { escapeUnintendedMarkup, pathToURI } from "../scripts/utils";
function getNotificationImage(notif: AstalNotifd.Notification|HistoryNotification): (string|undefined) {
const img = notif.image || notif.appIcon;
if(!img || !img.includes('/'))
return undefined;
return pathToURI(img);
}
export function NotificationWidget({ notification, actionClicked, holdOnHover, showTime, actionClose }: {
notification: AstalNotifd.Notification|number|HistoryNotification;
actionClicked?: (notif: AstalNotifd.Notification|HistoryNotification) => void;
actionClose?: (notif: AstalNotifd.Notification|HistoryNotification) => void;
holdOnHover?: boolean;
showTime?: boolean; // It's showTime :speaking_head: :boom: :bangbang:
}): Gtk.Widget {
notification = (typeof notification === "number") ?
AstalNotifd.get_default().get_notification(notification)
: notification;
const conns: Map<GObject.Object, Array<number>> = new Map();
onCleanup(() =>
conns.forEach((ids, obj) => ids.forEach(id => obj.disconnect(id))));
return <Gtk.Box hexpand class={`notification ${
Notifications.getDefault().getUrgencyString(notification.urgency)
}`} orientation={Gtk.Orientation.VERTICAL} spacing={5}
$={(self) => {
const eventControllerMotion = Gtk.EventControllerMotion.new(),
gestureClick = Gtk.GestureClick.new();
self.add_controller(eventControllerMotion);
self.add_controller(gestureClick);
conns.set(eventControllerMotion, [
eventControllerMotion.connect("enter", () =>
holdOnHover && Notifications.getDefault().holdNotification(notification.id)),
eventControllerMotion.connect("leave", () =>
holdOnHover && notification && Notifications.getDefault().removeNotification(notification.id))
]);
conns.set(gestureClick, [
gestureClick.connect("released", (gesture) => {
gesture.get_current_button() === Gdk.BUTTON_PRIMARY &&
actionClicked?.(notification);
})
]);
}}>
<Gtk.Box class={"top"} hexpand>
<Gtk.Image class="app-icon" $={(self) => {
const icon = getSymbolicIcon(notification.appIcon ?? notification.appName) ??
getSymbolicIcon(notification.appName) ?? getAppIcon(notification.appName);
if(icon) {
self.set_from_icon_name(icon);
return;
}
self.set_visible(false);
}} />
<Gtk.Label class={"app-name"} halign={Gtk.Align.START} hexpand={true}
label={notification.appName || "Application"} />
<Gtk.Label class={"time"} visible={showTime} xalign={1}
label={GLib.DateTime.new_from_unix_local(notification.time).format("%H:%M") ?? ""} />
<Gtk.Button halign={Gtk.Align.END} iconName={"window-close-symbolic"}
class={"close"} onClicked={() => actionClose?.(notification)}/>
</Gtk.Box>
<Separator alpha={.1} orientation={Gtk.Orientation.VERTICAL} />
<Gtk.Box class={"content"}>
{getNotificationImage(notification) &&
<Gtk.Box class={"image"} hexpand={false} vexpand={false}
css={`background-image: url("${getNotificationImage(notification)}");`}
/>
}
<Gtk.Box class={"text"} orientation={Gtk.Orientation.VERTICAL}
vexpand={true}>
<Gtk.Label class={"summary"} useMarkup={true} hexpand xalign={0}
vexpand={false} ellipsize={Pango.EllipsizeMode.END} label={
escapeUnintendedMarkup(notification.summary)}
/>
<Gtk.Label class={"body"} useMarkup={true} xalign={0} wrap={true} hexpand
vexpand wrapMode={Pango.WrapMode.WORD_CHAR} valign={Gtk.Align.START} label={
escapeUnintendedMarkup(notification.body)}
/>
</Gtk.Box>
</Gtk.Box>
<Gtk.Box class={"actions button-row"} hexpand={true} visible={
(notification instanceof AstalNotifd.Notification) &&
(notification.actions.filter(action => action.label.toLowerCase() !== "view").length > 0)
}>
{
(notification instanceof AstalNotifd.Notification) &&
notification.actions.filter(a => a.label.toLowerCase() !== "view").map(action =>
<Gtk.Button class={"action"} label={action.label}
hexpand={true} onClicked={(_) => {
notification.invoke(action.id);
actionClose?.(notification);
}}
/>)
}
</Gtk.Box>
</Gtk.Box> as Gtk.Widget;
}
+161
View File
@@ -0,0 +1,161 @@
import { Astal, Gdk, Gtk } from "ags/gtk4";
import { BackgroundWindow } from "./BackgroundWindow";
import { Accessor, CCProps, createComputed, createRoot, getScope } from "ags";
import { omitObjectKeys, WidgetNodeType } from "../scripts/utils";
import GObject from "ags/gobject";
type PopupWindowSpecificProps = {
$?: (self: Astal.Window) => void;
children?: WidgetNodeType;
/** Stylesheet for the background of the popup-window */
cssBackgroundWindow?: string;
class?: string | Accessor<string>;
actionClosed?: (self: Astal.Window) => void|boolean;
orientation?: Gtk.Orientation | Accessor<Gtk.Orientation>;
actionClickedOutside?: (self: Astal.Window) => void;
actionKeyPressed?: (self: Astal.Window, keyval: number, keycode: number) => void;
};
export type PopupWindowProps = Pick<Partial<CCProps<Astal.Window, Astal.Window.ConstructorProps>>,
"monitor"
| "layer"
| "exclusivity"
| "marginLeft"
| "marginTop"
| "marginRight"
| "marginBottom"
| "cursor"
| "canFocus"
| "hasFocus"
| "tooltipMarkup"
| "tooltipText"
| "namespace"
| "visible"
| "widthRequest"
| "heightRequest"
| "halign"
| "valign"
| "anchor"
| "vexpand"
| "hexpand"> & PopupWindowSpecificProps;
const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor;
export function PopupWindow(props: PopupWindowProps): GObject.Object {
props.visible ??= true;
props.layer ??= Astal.Layer.OVERLAY;
props.actionClickedOutside ??= (self: Astal.Window) => self.close();
let clickedInside: boolean = false;
return <Astal.Window {...omitObjectKeys(props, [
"actionKeyPressed",
"actionClickedOutside",
"cssBackgroundWindow",
"anchor",
"halign",
"valign",
"namespace",
"marginTop",
"widthRequest",
"heightRequest",
"visible",
"marginLeft",
"marginRight",
"marginBottom",
"hexpand",
"vexpand",
"orientation",
"actionClosed",
"$"
])} namespace={props.namespace ?? "popup-window"} class={
(props.class instanceof Accessor) ?
((props.namespace instanceof Accessor) ?
createComputed([props.class, props.namespace], (clss, namespace) =>
`popup-window ${clss} ${namespace}`)
: props.class.as(clss => `popup-window ${clss} ${props.namespace ?? ""}`))
: `popup-window ${props.class ?? ""} ${props.namespace ?? ""}`
} keymode={Astal.Keymode.EXCLUSIVE} exclusivity={props.exclusivity ?? Astal.Exclusivity.NORMAL}
anchor={TOP | LEFT | BOTTOM | RIGHT} visible={false}
onCloseRequest={(self) => props.actionClosed?.(self)}
$={(self) => {
const scope = getScope();
const conns: Map<GObject.Object, number> = new Map();
const gestureClick = Gtk.GestureClick.new();
const keyController = Gtk.EventControllerKey.new();
self.add_controller(gestureClick);
self.add_controller(keyController);
props.cssBackgroundWindow && createRoot((dispose) =>
<BackgroundWindow monitor={props.monitor ?? 0}
layer={props.layer} css={props.cssBackgroundWindow}
keymode={Astal.Keymode.NONE} attach={self}
onCloseRequest={() => dispose()}
/>
);
props.visible && self.show();
conns.set(gestureClick, gestureClick.connect("released", () => {
if(clickedInside) {
clickedInside = false;
return;
}
props.actionClickedOutside!(self);
}));
conns.set(keyController, keyController.connect("key-pressed", (_, keyval, keycode) => {
if(keyval === Gdk.KEY_Escape) {
conns.forEach((id, obj) => {
obj.disconnect(id);
});
props.actionClickedOutside!(self);
return;
}
props.actionKeyPressed?.(self, keyval, keycode);
}));
scope.onCleanup(() => conns.forEach((id, obj) => obj.disconnect(id)));
props.$?.(self);
}}>
<Gtk.Box hexpand={false} vexpand={false}>
<Gtk.Box class={"popup-window-container"} halign={props.halign}
valign={props.valign} widthRequest={props.widthRequest}
hexpand={props.hexpand} vexpand={props.vexpand}
orientation={props.orientation}
heightRequest={props.heightRequest} css={`
margin-left: ${props.marginLeft ?? 0}px;
margin-right: ${props.marginRight ?? 0}px;
margin-top: ${props.marginTop ?? 0}px;
margin-bottom: ${props.marginBottom ?? 0}px;
`} $={(self) => {
const conns = new Map<GObject.Object, number>(),
gestureClick = Gtk.GestureClick.new();
gestureClick.set_button(0);
self.add_controller(gestureClick);
conns.set(gestureClick, gestureClick.connect("released", () =>
clickedInside = true
));
conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) =>
obj.disconnect(id))));
}}>
{props.children}
</Gtk.Box>
</Gtk.Box>
</Astal.Window> as Astal.Window;
}
export function getPopupWindowContainer(popupWindow: Astal.Window): Gtk.Box {
return popupWindow.get_child()!.get_first_child() as Gtk.Box;
}
+48
View File
@@ -0,0 +1,48 @@
import { Accessor } from "ags";
import { Gtk } from "ags/gtk4";
export interface SeparatorProps {
class?: string;
alpha?: number;
cssColor?: string;
orientation?: Gtk.Orientation;
size?: number;
spacing?: number;
margin?: number;
visible?: boolean | Accessor<boolean>;
}
export function Separator(props: SeparatorProps = {
orientation: Gtk.Orientation.HORIZONTAL
}) {
props.alpha = props.alpha ?
(props.alpha > 1 ?
props.alpha / 100
: props.alpha)
: 1;
props.orientation = props.orientation ?? Gtk.Orientation.HORIZONTAL;
return <Gtk.Box name={"separator"} vexpand={props.orientation === Gtk.Orientation.HORIZONTAL}
hexpand={props.orientation === Gtk.Orientation.VERTICAL}
class={`separator ${ props.orientation === Gtk.Orientation.VERTICAL ?
"vertical" : "horizontal" }`} visible={props.visible}
css={`.vertical { padding: ${props.spacing ?? 0}px ${props.margin ?? 7}px; }
.horizontal { padding: ${props.margin ?? 4}px ${props.spacing ?? 0}px; }`}>
<Gtk.Box class={`${props.orientation === Gtk.Orientation.VERTICAL ?
"vertical"
: "horizontal"} ${props.class ?? ""}`}
vexpand={props.orientation === Gtk.Orientation.HORIZONTAL}
hexpand={props.orientation === Gtk.Orientation.VERTICAL}
css={`* {
background: ${ props.cssColor ?? "lightgray" };
opacity: ${props.alpha};
}
.horizontal { min-width: ${ props.size ?? 1 }px; }
.vertical { min-height: ${ props.size ?? 1 }px; }`}
/>
</Gtk.Box>
}
+12
View File
@@ -0,0 +1,12 @@
import { Gtk } from "ags/gtk4";
import { Windows } from "../../windows";
import { createBinding } from "ags";
import { tr } from "../../i18n/intl";
export const Apps = () =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((openWindows) =>
`apps ${Object.hasOwn(openWindows, "apps-window") ? "open" : ""}`
)} iconName={"applications-other-symbolic"} halign={Gtk.Align.CENTER}
hexpand tooltipText={tr("apps")} onClicked={() =>
Windows.getDefault().open("apps-window")}
/>;
+21
View File
@@ -0,0 +1,21 @@
import { Gtk } from "ags/gtk4";
import { Windows } from "../../windows";
import { createBinding } from "ags";
import { time } from "../../scripts/utils";
import { generalConfig } from "../../app";
export const Clock = () =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((wins) =>
`clock ${wins.includes("center-window") ? "open" : ""}`)}
$={(self) => {
const conns: Array<number> = [
self.connect("clicked", (_) => Windows.getDefault().toggle("center-window")),
self.connect("destroy", (_) => conns.forEach(id => self.disconnect(id)))
];
}}
label={time((dt) => dt.format(
generalConfig.getProperty("clock.date_format", "string"))
?? "An error occurred"
)}
/>;
+47
View File
@@ -0,0 +1,47 @@
import { Gtk } from "ags/gtk4";
import { createBinding, With } from "ags";
import { variableToBoolean } from "../../scripts/utils";
import { getAppIcon, getSymbolicIcon } from "../../scripts/apps";
import Pango from "gi://Pango?version=1.0";
import AstalHyprland from "gi://AstalHyprland";
const hyprland = AstalHyprland.get_default();
// Fix empty focused-client on opening a window on an empty workspace
hyprland.connect("client-added", () => hyprland.notify("focused-client"));
export const FocusedClient = () => {
const focusedClient = createBinding(hyprland, "focusedClient");
return <Gtk.Box class={"focused-client"}
visible={variableToBoolean(createBinding(hyprland, "focusedClient"))}>
<With value={focusedClient}>
{(focusedClient) => focusedClient?.class && <Gtk.Box>
<Gtk.Image iconName={createBinding(focusedClient, "class").as((clss) =>
getSymbolicIcon(clss) ?? getAppIcon(clss) ??
getAppIcon(focusedClient.initialClass) ??
"application-x-executable-symbolic"
)} vexpand
/>
<Gtk.Box valign={Gtk.Align.CENTER} class={"text-content"}
orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"class"} xalign={0} maxWidthChars={55}
ellipsize={Pango.EllipsizeMode.END}
label={createBinding(focusedClient, "class")}
tooltipText={createBinding(focusedClient, "class")}
/>
<Gtk.Label class={"title"} xalign={0} maxWidthChars={50}
ellipsize={Pango.EllipsizeMode.END}
label={createBinding(focusedClient, "title")}
tooltipText={createBinding(focusedClient, "title")}
/>
</Gtk.Box>
</Gtk.Box>}
</With>
</Gtk.Box>;
}
+168
View File
@@ -0,0 +1,168 @@
import { Accessor, createBinding, createConnection, createState, onCleanup, With } from "ags";
import { Gtk } from "ags/gtk4";
import { Separator } from "../Separator";
import { Windows } from "../../windows";
import { Clipboard } from "../../scripts/clipboard";
import GObject from "ags/gobject";
import AstalMpris from "gi://AstalMpris";
import Pango from "gi://Pango?version=1.0";
import { decoder, getPlayerIconFromBusName, variableToBoolean } from "../../scripts/utils";
export const dummyPlayer = AstalMpris.Player.new("colorshellDummy");
export let [player, setPlayer] = createState(dummyPlayer);
export const Media = () => {
const connections: Map<GObject.Object, Array<number>|number> = new Map();
if(AstalMpris.get_default().players[0])
setPlayer(AstalMpris.get_default().players[0]);
onCleanup(() => connections.forEach((id, obj) =>
Array.isArray(id) ?
id.forEach(id => obj.disconnect(id))
: obj.disconnect(id)
));
connections.set(AstalMpris.get_default(), [
AstalMpris.get_default().connect("player-added", (_, player) =>
player.available && setPlayer(player)),
AstalMpris.get_default().connect("player-closed", (_, closedPlayer) => {
const players = AstalMpris.get_default().players.filter(pl => pl?.available &&
pl.busName !== closedPlayer.busName);
if(players.length > 0) {
setPlayer(players[0]);
return;
}
setPlayer(dummyPlayer);
})
]);
return <Gtk.Box class={"media"} visible={player((pl) => pl.available)}
$={(self) => {
const gestureClick = Gtk.GestureClick.new(),
controllerMotion = Gtk.EventControllerMotion.new(),
controllerScroll = Gtk.EventControllerScroll.new(
Gtk.EventControllerScrollFlags.VERTICAL);
self.add_controller(gestureClick);
self.add_controller(controllerMotion);
self.add_controller(controllerScroll);
connections.set(gestureClick, gestureClick.connect("released", () =>
Windows.getDefault().toggle("center-window")));
connections.set(controllerScroll,
controllerScroll.connect("scroll", (_, _dx, dy) => {
if(AstalMpris.get_default().players.length === 1 &&
player.get()?.busName === AstalMpris.get_default().players[0].busName)
return true;
const players = AstalMpris.get_default().players;
for(let i = 0; i < players.length; i++) {
const pl = players[i];
if(pl.busName !== player.get().busName)
continue;
if(dy > 0 && players[i-1]) {
setPlayer(players[i-1]);
break;
}
if(dy < 0 && players[i+1]) {
setPlayer(players[i+1]);
break;
}
}
return true;
})
);
connections.set(controllerMotion, [
controllerMotion.connect("enter", () => {
const revealer = self.get_last_child() as Gtk.Revealer;
revealer.set_reveal_child(true);
}),
controllerMotion.connect("leave", () => {
const revealer = self.get_last_child() as Gtk.Revealer;
revealer.set_reveal_child(false);
})
]);
connections.set(self, self.connect("destroy", () =>
connections.forEach((ids, obj) => Array.isArray(ids) ?
ids.forEach(id => obj.disconnect(id))
: obj.disconnect(ids))
));
}}>
<Gtk.Box spacing={4} visible={player(pl => pl.available)}>
<With value={player(pl => pl.available)}>
{(available: boolean) => available && <Gtk.Box>
<Gtk.Image class={"player-icon"} iconName={
createBinding(player.get(), "busName").as(getPlayerIconFromBusName)}
/>
<Gtk.Label class={"title"} label={createBinding(player.get(), "title").as(title =>
title ?? "No Title")} maxWidthChars={20} ellipsize={Pango.EllipsizeMode.END}
/>
<Separator orientation={Gtk.Orientation.HORIZONTAL} size={1} margin={5}
alpha={.3} spacing={6} />
<Gtk.Label class={"artist"} label={createBinding(player.get(), "artist").as(artist =>
artist ?? "No Artist")} maxWidthChars={18} ellipsize={Pango.EllipsizeMode.END}
/>
</Gtk.Box>}
</With>
</Gtk.Box>
<Gtk.Revealer transitionType={Gtk.RevealerTransitionType.SLIDE_RIGHT} transitionDuration={260}
revealChild={false}>
<With value={player(pl => pl.available)}>
{(available: boolean) => available && <Gtk.Box class={"media-controls button-row"}>
<Gtk.Button class={"link"} iconName={"edit-paste-symbolic"}
visible={variableToBoolean(getMediaUrl(player.get()))}
tooltipText={"Copy link to Clipboard"} onClicked={() => {
const url = getMediaUrl(player.get()).get();
url && Clipboard.getDefault().copyAsync(url);
}}
/>
<Gtk.Button class={"previous"} iconName={"media-skip-backward-symbolic"}
tooltipText={"Previous"} onClicked={() =>
player.get().canGoPrevious && player.get().previous()}
/>
<Gtk.Button class={"play-pause"} iconName={createBinding(player.get(), "playbackStatus").as(status =>
status === AstalMpris.PlaybackStatus.PAUSED ?
"media-playback-start-symbolic"
: "media-playback-pause-symbolic")}
tooltipText={
createBinding(player.get(), "playbackStatus").as(status =>
status === AstalMpris.PlaybackStatus.PAUSED ? "Play" : "Pause")
} onClicked={() => player.get().play_pause()}
/>
<Gtk.Button class={"next"} iconName={"media-skip-forward-symbolic"}
tooltipText={"Next"} onClicked={() => player.get().canGoNext &&
player.get().next()}
/>
</Gtk.Box>}
</With>
</Gtk.Revealer>
</Gtk.Box>
}
export function getMediaUrl(player: AstalMpris.Player): Accessor<string|undefined> {
return createConnection(player.get_meta("xesam:url"),
[player, "notify::metadata", () => player.get_meta("xesam:url")]
).as(url => {
const byteString = url?.get_data_as_bytes();
return byteString ?
decoder.decode(byteString.toArray())
: undefined;
})
}
+141
View File
@@ -0,0 +1,141 @@
import { Gtk } from "ags/gtk4";
import { Wireplumber } from "../../scripts/volume";
import { Notifications } from "../../scripts/notifications";
import { Windows } from "../../windows";
import { Recording } from "../../scripts/recording";
import { Accessor, createBinding, createComputed, With } from "ags";
import { time, variableToBoolean } from "../../scripts/utils";
import GObject from "ags/gobject";
import AstalBluetooth from "gi://AstalBluetooth";
import AstalNetwork from "gi://AstalNetwork";
import AstalWp from "gi://AstalWp";
export const Status = () =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((openWins) =>
openWins.includes("control-center") ? "open status" : "status")}
onClicked={() => Windows.getDefault().toggle("control-center")}>
<Gtk.Box>
<Gtk.Box class={"volume-indicators"} spacing={5}>
<VolumeStatus class="sink" endpoint={Wireplumber.getDefault().getDefaultSink()}
icon={createBinding(Wireplumber.getDefault().getDefaultSink(), "volumeIcon").as(icon =>
!Wireplumber.getDefault().isMutedSink() &&
Wireplumber.getDefault().getSinkVolume() > 0 ? icon
: "audio-volume-muted-symbolic")
} />
<VolumeStatus class="source" endpoint={Wireplumber.getDefault().getDefaultSource()}
icon={createBinding(Wireplumber.getDefault().getDefaultSource(), "volumeIcon").as(icon =>
!Wireplumber.getDefault().isMutedSource() &&
Wireplumber.getDefault().getSourceVolume() > 0 ? icon
: "microphone-sensitivity-muted-symbolic")
} />
</Gtk.Box>
<Gtk.Revealer revealChild={createBinding(Recording.getDefault(), "recording")}
transitionDuration={500} transitionType={Gtk.RevealerTransitionType.SLIDE_LEFT}>
<Gtk.Box>
<Gtk.Image class={"recording state"} iconName={"media-record-symbolic"}
css={"margin-right: 6px;"} />
<Gtk.Label class={"rec-time"} label={createComputed([
createBinding(Recording.getDefault(), "recording"),
time
], (recording, dateTime) => {
if(!recording || !Recording.getDefault().startedAt)
return "...";
const startedAtSeconds = dateTime.to_unix() - Recording.getDefault().startedAt!;
if(startedAtSeconds <= 0) return "00:00";
const minutes = Math.floor(startedAtSeconds / 60);
const seconds = Math.floor(startedAtSeconds % 60);
return `${ minutes < 10 ? `0${minutes}` : minutes }:${ seconds < 10 ? `0${seconds}` : seconds }`;
})}
/>
</Gtk.Box>
</Gtk.Revealer>
<StatusIcons />
</Gtk.Box>
</Gtk.Button> as Gtk.Button;
function VolumeStatus(props: { class?: string, endpoint: AstalWp.Endpoint, icon?: (string|Accessor<string>) }) {
return <Gtk.Box spacing={2} class={props.class} $={(self) => {
const conns: Map<GObject.Object, number> = new Map();
const controllerScroll = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.VERTICAL
| Gtk.EventControllerScrollFlags.KINETIC);
conns.set(controllerScroll, controllerScroll.connect("scroll", (_, _dx, dy) => {
console.log`Scrolled! dx: ${_dx}; dy: ${dy}`;
dy > 0 ?
Wireplumber.getDefault().decreaseEndpointVolume(props.endpoint, 5)
: Wireplumber.getDefault().increaseEndpointVolume(props.endpoint, 5);
return true;
}));
conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) =>
obj.disconnect(id))));
}}>
{props.icon && <Gtk.Image iconName={props.icon} />}
<Gtk.Label class={"volume"} label={createBinding(props.endpoint, "volume").as(vol =>
`${Math.floor(vol * 100)}%`)} />
</Gtk.Box> as Gtk.Box;
}
function StatusIcons() {
return <Gtk.Box class={"status-icons"} spacing={8}>
<Gtk.Image iconName={createComputed([
createBinding(AstalBluetooth.get_default(), "isPowered"),
createBinding(AstalBluetooth.get_default(), "isConnected")
], (powered, connected) => {
return powered ? (
connected ?
"bluetooth-active-symbolic"
: "bluetooth-symbolic"
) : "bluetooth-disabled-symbolic"
})} class={"bluetooth state"} visible={
createBinding(AstalBluetooth.get_default(), "adapter").as(Boolean)
}
/>
<Gtk.Box visible={createBinding(AstalNetwork.get_default(), "primary").as(primary =>
primary !== AstalNetwork.Primary.UNKNOWN)}>
<With value={createBinding(AstalNetwork.get_default(), "primary")}>
{(primary: AstalNetwork.Primary) => {
let device: AstalNetwork.Wifi|AstalNetwork.Wired;
switch(primary) {
case AstalNetwork.Primary.WIRED:
device = AstalNetwork.get_default().wired;
break;
case AstalNetwork.Primary.WIFI:
device = AstalNetwork.get_default().wifi;
break;
default:
return <Gtk.Image iconName={"network-no-route-symbolic"} />;
}
return <Gtk.Image iconName={createBinding(device, "iconName")} />;
}}
</With>
</Gtk.Box>
<Gtk.Box>
<Gtk.Image class={"bell state"} iconName={createBinding(
Notifications.getDefault().getNotifd(), "dontDisturb").as(dnd => dnd ?
"minus-circle-filled-symbolic"
: "preferences-system-notifications-symbolic")
}
/>
<Gtk.Image iconName={"circle-filled-symbolic"} class={"notification-count"}
visible={variableToBoolean(createBinding(Notifications.getDefault(), "history"))}
/>
</Gtk.Box>
</Gtk.Box>
}
+58
View File
@@ -0,0 +1,58 @@
import { createBinding, createComputed, For, With } from "ags";
import { Gdk, Gtk } from "ags/gtk4";
import { variableToBoolean } from "../../scripts/utils";
import GObject from "gi://GObject?version=2.0";
import AstalTray from "gi://AstalTray"
import Gio from "gi://Gio?version=2.0";
const astalTray = AstalTray.get_default();
export const Tray = () => {
const items = createBinding(astalTray, "items").as(items => items.filter(item => item?.gicon));
return <Gtk.Box class={"tray"} visible={variableToBoolean(items)} spacing={10}>
<For each={items}>
{(item: AstalTray.TrayItem) => <Gtk.Box class={"item"}>
<With value={createComputed([
createBinding(item, "actionGroup"),
createBinding(item, "menuModel")
])}>
{([actionGroup, menuModel]: [Gio.ActionGroup, Gio.MenuModel]) => {
const popover = Gtk.PopoverMenu.new_from_model(menuModel);
popover.insert_action_group("dbusmenu", actionGroup);
popover.hasArrow = false;
return <Gtk.Box class={"item"} tooltipMarkup={
createBinding(item, "tooltipMarkup")
} tooltipText={
createBinding(item, "tooltipText")
} $={(self) => {
const conns: Map<GObject.Object, number> = new Map();
const gestureClick = Gtk.GestureClick.new();
gestureClick.set_button(0);
self.add_controller(gestureClick);
conns.set(gestureClick, gestureClick.connect("released", (gesture, _, x, y) => {
if(gesture.get_current_button() === Gdk.BUTTON_PRIMARY) {
item.activate(x, y);
return;
}
if(gesture.get_current_button() === Gdk.BUTTON_SECONDARY) {
item.about_to_show();
popover.popup();
}
}))
}}>
<Gtk.Image gicon={createBinding(item, "gicon")} pixelSize={16} />
{popover}
</Gtk.Box>;
}}
</With>
</Gtk.Box>}
</For>
</Gtk.Box>
}
+155
View File
@@ -0,0 +1,155 @@
import { Gtk } from "ags/gtk4";
import AstalHyprland from "gi://AstalHyprland";
import { getAppIcon, getSymbolicIcon } from "../../scripts/apps";
import { Separator } from "../Separator";
import { generalConfig } from "../../app";
import { createBinding, createComputed, createState, For, With } from "ags";
import { variableToBoolean } from "../../scripts/utils";
import GObject from "ags/gobject";
const [showNumbers, setShowNumbers] = createState(false);
export const showWorkspaceNumber = (show: boolean) =>
setShowNumbers(show);
export const Workspaces = () => {
const workspaces = createBinding(AstalHyprland.get_default(), "workspaces"),
defaultWorkspaces = workspaces.as(wss =>
wss.filter(ws => ws.id > 0).sort((a, b) => a.id - b.id)),
specialWorkspaces = workspaces.as(wss =>
wss.filter(ws => ws.id < 0).sort((a, b) => a.id - b.id));
return <Gtk.Box class={"workspaces-row"}>
<Gtk.Box class={"special-workspaces"} spacing={4}>
<For each={specialWorkspaces}>
{(ws: AstalHyprland.Workspace) =>
<Gtk.Button class={"workspace"}
tooltipText={createBinding(ws, "name").as(name => {
name = name.replace(/^special\:/, "");
return name.charAt(0).toUpperCase().concat(name.substring(1, name.length));
})} onClicked={() => AstalHyprland.get_default().dispatch(
"togglespecialworkspace", ws.name.replace(/^special[:]/, "")
)}>
<With value={createBinding(ws, "lastClient")}>
{(lastClient: AstalHyprland.Client|null) => lastClient &&
<Gtk.Image class="last-client" iconName={
createBinding(lastClient, "initialClass").as(initialClass =>
getSymbolicIcon(initialClass) ?? getAppIcon(initialClass) ??
"application-x-executable-symbolic")}
/>
}
</With>
</Gtk.Button>
}
</For>
</Gtk.Box>
<Gtk.Revealer transitionType={Gtk.RevealerTransitionType.SLIDE_RIGHT}
transitionDuration={220} revealChild={variableToBoolean(specialWorkspaces)}>
<Separator alpha={.2} orientation={Gtk.Orientation.HORIZONTAL}
margin={12} spacing={8} visible={variableToBoolean(specialWorkspaces)}
/>
</Gtk.Revealer>
<Gtk.Box class={"default-workspaces"} spacing={4} $={(self) => {
const conns: Map<GObject.Object, Array<number>|number> = new Map();
const controllerScroll = Gtk.EventControllerScroll.new(
Gtk.EventControllerScrollFlags.VERTICAL
), controllerMotion = Gtk.EventControllerMotion.new();
self.add_controller(controllerScroll);
self.add_controller(controllerMotion);
conns.set(controllerScroll, controllerScroll.connect("scroll", (_, _dx, dy) => {
dy > 0 ?
AstalHyprland.get_default().dispatch("workspace", "e-1")
: AstalHyprland.get_default().dispatch("workspace", "e+1");
return true;
}));
conns.set(controllerMotion, [
controllerMotion.connect("enter", () => setShowNumbers(true)),
controllerMotion.connect("leave", () => setShowNumbers(false))
]);
conns.set(self, self.connect("destroy", () => conns.forEach((ids, obj) =>
Array.isArray(ids) ?
ids.forEach(id => obj.disconnect(id))
: obj.disconnect(ids)
)));
}}>
<For each={defaultWorkspaces}>
{(ws: AstalHyprland.Workspace, i) => {
const showId = createComputed([
generalConfig.bindProperty("workspaces.always_show_id", "boolean").as(Boolean),
generalConfig.bindProperty("workspaces.enable_helper", "boolean").as(Boolean),
showNumbers,
i
], (alwaysShowIds, enableHelper, showIds, i) => {
if(enableHelper && !alwaysShowIds) {
const previousWorkspace = defaultWorkspaces.get()[i-1];
const nextWorkspace = defaultWorkspaces.get()[i+1];
if((defaultWorkspaces.get().filter((_, ii) => ii < i).length > 0 &&
previousWorkspace?.id < (ws.id-1)) ||
(defaultWorkspaces.get().filter((_, ii) => ii > i).length > 0 &&
nextWorkspace?.id > (ws.id+1))
|| (i === 0 && ws.id > 1)) {
return true;
}
}
return alwaysShowIds || showIds;
});
return <Gtk.Button class={createComputed([
createBinding(AstalHyprland.get_default(), "focusedWorkspace"),
showId
], (focusedWs, showWsNumbers) =>
`workspace ${focusedWs.id === ws.id ? "focus" : ""} ${
showWsNumbers ? "show" : ""}`
)} tooltipText={createComputed([
createBinding(ws, "lastClient"),
createBinding(AstalHyprland.get_default(), "focusedWorkspace")
], (lastClient, focusWs) => focusWs.id === ws.id ? "" :
`workspace ${ws.id}${ lastClient ? ` - ${
!lastClient.title.toLowerCase().includes(lastClient.class) ?
`${lastClient.get_class()}: `
: ""
} ${lastClient.title}` : "" }`
)} onClicked={() => ws.focus()}>
<With value={createBinding(ws, "lastClient")}>
{(lastClient: AstalHyprland.Client) =>
<Gtk.Box class={"last-client"} hexpand>
<Gtk.Revealer transitionDuration={280} revealChild={showId}
transitionType={createBinding(AstalHyprland.get_default(), "focusedWorkspace")
.as(fws => fws.id !== ws.id ?
Gtk.RevealerTransitionType.SLIDE_LEFT
: Gtk.RevealerTransitionType.SLIDE_RIGHT)}>
<Gtk.Label label={createBinding(ws, "id").as(String)}
class={"id"} hexpand />
</Gtk.Revealer>
{lastClient && <Gtk.Image class={"last-client-icon"} iconName={
createBinding(lastClient, "initialClass").as(initialClass =>
getSymbolicIcon(initialClass) ?? getAppIcon(initialClass) ??
"application-x-executable-symbolic")}
hexpand vexpand visible={createBinding(AstalHyprland.get_default(), "focusedWorkspace")
.as(fws => fws.id !== ws.id)}
/>}
</Gtk.Box>
}
</With>
</Gtk.Button>
}}
</For>
</Gtk.Box>
</Gtk.Box>
}
+193
View File
@@ -0,0 +1,193 @@
import { timeout } from "ags/time";
import { Astal, Gtk } from "ags/gtk4";
import { Clipboard } from "../../scripts/clipboard";
import { getMediaUrl, player, setPlayer } from "../bar/Media";
import { createBinding, For } from "ags";
import { pathToURI, variableToBoolean } from "../../scripts/utils";
import AstalMpris from "gi://AstalMpris";
import AstalIO from "gi://AstalIO";
import Pango from "gi://Pango?version=1.0";
import Adw from "gi://Adw?version=1";
import { register } from "ags/gobject";
let dragTimer: (AstalIO.Time|undefined);
export const BigMedia = () => {
const availablePlayers = createBinding(AstalMpris.get_default(), "players").as(pls =>
pls.filter(p => p.available));
const carousel = <Adw.Carousel orientation={Gtk.Orientation.HORIZONTAL} spacing={6}
onPageChanged={(self, num) => {
const page = self.get_nth_page(num);
if(page instanceof PlayerWidget && player.get().busName !== page.player.busName)
setPlayer(page.player);
}}>
<For each={availablePlayers.as(players => players.sort(pl =>
pl.busName === player.get().busName ? -1 : 1))}>
{(player: AstalMpris.Player) => <PlayerWidget player={player} />}
</For>
</Adw.Carousel> as Adw.Carousel;
return <Gtk.Box class={"big-media"} orientation={Gtk.Orientation.VERTICAL} widthRequest={255}
visible={variableToBoolean(availablePlayers)}>
{carousel}
<Gtk.Revealer revealChild={availablePlayers.as(pls => pls.length > 1)} transitionDuration={300}
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}>
<Adw.CarouselIndicatorDots orientation={Gtk.Orientation.HORIZONTAL} carousel={carousel} />
</Gtk.Revealer>
</Gtk.Box> as Gtk.Box;
}
@register({ GTypeName: "PlayerWidget" })
class PlayerWidget extends Gtk.Box {
#player!: AstalMpris.Player;
get player() { return this.#player; }
constructor({ player }: { player: AstalMpris.Player }) {
super();
this.setPlayer(player);
this.set_orientation(Gtk.Orientation.VERTICAL);
this.set_hexpand(true);
this.append(
<Gtk.Revealer hexpand={false} revealChild={
createBinding(player, "artUrl").as(Boolean)
} transitionType={Gtk.RevealerTransitionType.SLIDE_LEFT} transitionDuration={300}>
<Gtk.Box class={"image"} css={createBinding(player, "artUrl").as((art) =>
`background-image: url("${pathToURI(art)}");`)}
hexpand={false} vexpand={false} widthRequest={132} heightRequest={128}
valign={Gtk.Align.START} halign={Gtk.Align.CENTER}
/>
</Gtk.Revealer> as Gtk.Revealer
);
this.append(
<Gtk.Box class={"info"} orientation={Gtk.Orientation.VERTICAL}
valign={Gtk.Align.CENTER} vexpand hexpand>
<Gtk.Label class={"title"} tooltipText={
createBinding(player, "title").as(title => title ?? "No Title")
} label={
createBinding(player, "title").as(title => title ?? "No Title")
} ellipsize={Pango.EllipsizeMode.END} maxWidthChars={25}
/>
<Gtk.Label class={"artist"} tooltipText={
createBinding(player, "artist").as(artist => artist ?? "No Artist")
} label={
createBinding(player, "artist").as(artist => artist ?? "No Artist")
} ellipsize={Pango.EllipsizeMode.END} maxWidthChars={28}
/>
</Gtk.Box> as Gtk.Box
);
this.append(
<Gtk.Box class={"progress"} hexpand visible={createBinding(player, "canSeek")}>
<Astal.Slider hexpand max={createBinding(player, "length").as(Math.floor)}
value={createBinding(player, "position").as(Math.floor)}
onChangeValue={(_, type, value) => {
if(type === undefined || type === null)
return;
if(!dragTimer) {
dragTimer = timeout(200, () =>
player.position = Math.floor(value));
return;
}
dragTimer.cancel();
dragTimer = timeout(200, () =>
player.position = Math.floor(value));
}}
/>
</Gtk.Box> as Gtk.Box
);
this.append(
<Gtk.CenterBox class={"bottom"} hexpand marginBottom={6}>
<Gtk.Label class={"elapsed"} xalign={0} yalign={0}
halign={Gtk.Align.START} label={createBinding(player, "position").as(pos => {
const sec = Math.floor(pos % 60);
return pos > 0 && player.length > 0 ?
`${Math.floor(pos / 60)}:${sec < 10 ? "0" : ""}${sec}`
: "0:00";
})} $type="start"
/>
<Gtk.Box class={"controls button-row"} $type="center">
<Gtk.Button class={"link"} iconName={"edit-paste-symbolic"}
tooltipText={"Copy link to clipboard"}
visible={variableToBoolean(getMediaUrl(player))}
onClicked={() => {
const url = getMediaUrl(player).get();
url && Clipboard.getDefault().copyAsync(url);
}}
/>
<Gtk.Button class={"shuffle"} visible={createBinding(player, "shuffleStatus").as(status =>
status !== AstalMpris.Shuffle.UNSUPPORTED)} iconName={
createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ?
"media-playlist-shuffle-symbolic"
: "media-playlist-consecutive-symbolic")} tooltipText={
createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ?
"Shuffle"
: "No shuffle")} onClicked={() => player.shuffle()}
/>
<Gtk.Button class={"previous"} iconName={"media-skip-backward-symbolic"}
tooltipText={"Previous"} onClicked={() => player.canGoPrevious && player.previous()}
/>
<Gtk.Button class={"play-pause"} tooltipText={
createBinding(player, "playbackStatus").as(status =>
status === AstalMpris.PlaybackStatus.PLAYING ? "Pause" : "Play")}
iconName={createBinding(player, "playbackStatus").as(status =>
status === AstalMpris.PlaybackStatus.PLAYING ?
"media-playback-pause-symbolic"
: "media-playback-start-symbolic")} onClicked={() => player.play_pause()}
/>
<Gtk.Button class={"next"} iconName={"media-skip-forward-symbolic"}
tooltipText={"Next"} onClicked={() => player.canGoNext && player.next()}
/>
<Gtk.Button class={"repeat"} iconName={createBinding(player, "loopStatus").as(status => {
if(status === AstalMpris.Loop.TRACK)
return "media-playlist-repeat-song-symbolic";
if(status === AstalMpris.Loop.PLAYLIST)
return "media-playlist-repeat-symbolic";
return "loop-arrow-symbolic";
})} visible={createBinding(player, "loopStatus").as(status =>
status !== AstalMpris.Loop.UNSUPPORTED)}
tooltipText={createBinding(player, "loopStatus").as(status => {
if(status === AstalMpris.Loop.TRACK)
return "Loop song";
if(status === AstalMpris.Loop.PLAYLIST)
return "Loop playlist";
return "No loop";
})} onClicked={() => player.loop()}
/>
</Gtk.Box>
<Gtk.Label class={"length"} xalign={1} yalign={0}
halign={Gtk.Align.END} label={createBinding(player, "length").as(len => { /* bananananananana */
const sec = Math.floor(len % 60);
return (len > 0 && Number.isFinite(len)) ?
`${Math.floor(len / 60)}:${sec < 10 ? "0" : ""}${sec}`
: "0:00";
})} $type="end"
/>
</Gtk.CenterBox> as Gtk.CenterBox
);
}
setPlayer(player: AstalMpris.Player) {
this.#player = player;
}
}
@@ -0,0 +1,49 @@
import { Gtk } from "ags/gtk4";
import { HistoryNotification, Notifications } from "../../scripts/notifications";
import { NotificationWidget } from "../Notification";
import { tr } from "../../i18n/intl";
import { createBinding, For } from "ags";
import AstalNotifd from "gi://AstalNotifd?version=0.1";
export const NotifHistory = () =>
<Gtk.Box orientation={Gtk.Orientation.VERTICAL}
class={createBinding(Notifications.getDefault(), "history").as(history =>
`notif-history ${history.length < 1 ? "hide" : ""}`)} vexpand={false}>
<Gtk.ScrolledWindow class={"history-scrollable"} hscrollbarPolicy={Gtk.PolicyType.NEVER}
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC} propagateNaturalHeight={true}
onShow={(self) => {
if(!(self.get_child()! as Gtk.Viewport).get_child()) return;
self.minContentHeight =
((self.get_child()! as Gtk.Viewport).get_child() as Gtk.Box
).get_first_child()!.get_allocation().height
|| 0;
}}>
<Gtk.Box class={"notifications"} hexpand={true} orientation={Gtk.Orientation.VERTICAL}
spacing={4} valign={Gtk.Align.START}>
<For each={createBinding(Notifications.getDefault(), "history")}>
{(notif: AstalNotifd.Notification|HistoryNotification) =>
<NotificationWidget notification={notif} showTime={true}
actionClose={(n) => Notifications.getDefault().removeHistory(n.id)}
actionClicked={(n) => Notifications.getDefault().removeHistory(n.id)}
/>}
</For>
</Gtk.Box>
</Gtk.ScrolledWindow>
<Gtk.Box class={"button-row"} hexpand>
<Gtk.Button class={"clear-all"} halign={Gtk.Align.END}
onClicked={() => Notifications.getDefault().clearHistory()}>
<Gtk.Box hexpand>
<Gtk.Image class={"icon"} iconName={"edit-clear-all-symbolic"}
css={"margin-right: 6px;"} />
<Gtk.Label label={tr("clear")} />
</Gtk.Box>
</Gtk.Button>
</Gtk.Box>
</Gtk.Box> as Gtk.Box;
+99
View File
@@ -0,0 +1,99 @@
import { register } from "ags/gobject";
import { Gtk } from "ags/gtk4";
import { Page } from "./pages/Page";
import { timeout } from "ags/time";
import AstalIO from "gi://AstalIO";
export { Pages };
export type PagesProps = {
initialPage?: Page;
transitionDuration?: number;
};
@register({ GTypeName: "Pages" })
class Pages extends Gtk.Box {
#timeouts: Array<[AstalIO.Time, (() => void)|undefined]> = [];
#page: (Page|undefined);
#transDuration: number;
#transType: Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.SLIDE_DOWN;
get isOpen() { return Boolean(this.#page); }
get page() { return this.#page; }
constructor(props?: PagesProps) {
super({
orientation: Gtk.Orientation.VERTICAL,
cssName: "pages",
name: "pages"
});
this.add_css_class("pages");
this.#transDuration = props?.transitionDuration ?? 280;
if(props?.initialPage)
this.open(props.initialPage);
const destroyId = this.connect("destroy", () => {
this.disconnect(destroyId);
this.#timeouts.forEach((tmout) => {
tmout[0].cancel();
(async () => tmout[1]?.())().catch((err: Error) => {
console.error(`${err.message}\n${err.stack}`);
});
});
});
}
toggle(newPage?: Page, onToggled?: () => void): void {
if(!newPage || (this.#page?.id === newPage.id)) {
this.close(onToggled);
return;
}
if(!this.isOpen) {
newPage && this.open(newPage, onToggled);
return;
}
if(this.#page?.id !== newPage.id) {
this.close();
this.open(newPage, onToggled);
}
}
open(newPage: Page, onOpen?: () => void) {
this.#page = newPage;
this.prepend(
<Gtk.Revealer revealChild={false} transitionType={this.#transType}
transitionDuration={this.#transDuration}>
{newPage.create()}
</Gtk.Revealer> as Gtk.Revealer
);
(this.get_first_child() as Gtk.Revealer)?.set_reveal_child(true);
onOpen?.();
}
close(onClosed?: () => void): void {
const page = this.get_first_child() as Gtk.Revealer|null;
if(!page) return;
this.#page?.actionClosed?.();
this.#page = undefined;
page.set_reveal_child(false);
this.#timeouts.push([
timeout(page.transitionDuration, () => {
this.remove(page);
onClosed?.();
}),
onClosed
]);
}
}
@@ -0,0 +1,87 @@
import { Gtk } from "ags/gtk4";
import { Windows } from "../../windows";
import { Wallpaper } from "../../scripts/wallpaper";
import { execApp } from "../../scripts/apps";
import { Accessor } from "ags";
import { createPoll } from "ags/time";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
const userFace: Gio.File = Gio.File.new_for_path(`${GLib.get_home_dir()}/.face`);
const uptime: Accessor<string> = createPoll("Just turned on", 1000, "uptime -p");
function LockButton(): Gtk.Button {
return <Gtk.Button iconName={"system-lock-screen-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
execApp("hyprlock");
}}
/> as Gtk.Button;
}
function ColorPickerButton(): Gtk.Button {
return <Gtk.Button iconName={"color-select-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
execApp("sh $HOME/.config/hypr/scripts/color-picker.sh");
}}
/> as Gtk.Button;
}
function ScreenshotButton(): Gtk.Button {
return <Gtk.Button iconName={"applets-screenshooter-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
execApp(`sh ${GLib.get_user_config_dir()}/hypr/scripts/screenshot.sh`);
}}
/> as Gtk.Button;
}
function SelectWallpaperButton(): Gtk.Button {
return <Gtk.Button iconName={"preferences-desktop-wallpaper-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
Wallpaper.getDefault().pickWallpaper();
}}
/> as Gtk.Button;
}
function LogoutButton(): Gtk.Button {
return <Gtk.Button iconName={"system-shutdown-symbolic"}
onClicked={() => {
Windows.getDefault().close("control-center");
Windows.getDefault().open("logout-menu");
}}
/> as Gtk.Button;
}
export const QuickActions = () =>
<Gtk.Box class={"quickactions"}>
<Gtk.Box halign={Gtk.Align.START} class={"left"} hexpand>
{userFace.query_exists(null) &&
<Gtk.Box class={"user-face"} css={
`background-image: url("${userFace.get_path()!}");`}
/>
}
<Gtk.Box orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"hostname"} xalign={0} tooltipText={"Host name"}
label={GLib.get_host_name()} />
<Gtk.Box>
<Gtk.Image iconName={"hourglass-symbolic"} />
<Gtk.Label class={"uptime"} xalign={0} tooltipText={"Up time"}
label={uptime.as(str => str.replace(/^up /, ""))} />
</Gtk.Box>
</Gtk.Box>
</Gtk.Box>
<Gtk.Box class={"right button-row"} halign={Gtk.Align.END} hexpand>
<LockButton />
<ColorPickerButton />
<ScreenshotButton />
<SelectWallpaperButton />
<LogoutButton />
</Gtk.Box>
</Gtk.Box> as Gtk.Box;
+53
View File
@@ -0,0 +1,53 @@
import { Astal, Gtk } from "ags/gtk4";
import { Wireplumber } from "../../scripts/volume";
import { Pages } from "./Pages";
import { PageSound } from "./pages/Sound";
import { PageMicrophone } from "./pages/Microphone";
import { createBinding, With } from "ags";
import AstalWp from "gi://AstalWp";
export let slidersPages: Pages|undefined;
export function Sliders() {
return <Gtk.Box class={"sliders"} orientation={Gtk.Orientation.VERTICAL}
hexpand spacing={10} onUnmap={() => slidersPages = undefined}>
<With value={createBinding(Wireplumber.getWireplumber(), "defaultSpeaker")}>
{(sink: AstalWp.Endpoint) => <Gtk.Box class={"sink speaker"} spacing={3}>
<Gtk.Button onClicked={() => Wireplumber.getDefault().toggleMuteSink()}
iconName={createBinding(sink, "volumeIcon").as((icon) =>
(!Wireplumber.getDefault().isMutedSink() &&
Wireplumber.getDefault().getSinkVolume() > 0
) ? icon : "audio-volume-muted-symbolic"
)} />
<Astal.Slider drawValue={false} hexpand value={createBinding(sink, "volume")}
max={Wireplumber.getDefault().getMaxSinkVolume() / 100}
onChangeValue={(_, __, value) => sink.set_volume(value)} />
<Gtk.Button class={"more"} iconName={"go-next-symbolic"} onClicked={() =>
slidersPages?.toggle(PageSound)} />
</Gtk.Box>}
</With>
<With value={createBinding(Wireplumber.getWireplumber(), "defaultMicrophone")}>
{(source: AstalWp.Endpoint) => <Gtk.Box class={"source microphone"} spacing={3}>
<Gtk.Button onClicked={() => Wireplumber.getDefault().toggleMuteSource()}
iconName={createBinding(source, "volumeIcon").as((icon) =>
(!Wireplumber.getDefault().isMutedSource() &&
Wireplumber.getDefault().getSourceVolume() > 0
) ? icon : "microphone-sensitivity-muted-symbolic"
)} />
<Astal.Slider drawValue={false} hexpand value={createBinding(source, "volume")}
max={Wireplumber.getDefault().getMaxSourceVolume() / 100}
onChangeValue={(_, __, value) => source.set_volume(value)} />
<Gtk.Button class={"more"} iconName={"go-next-symbolic"} onClicked={() =>
slidersPages?.toggle(PageMicrophone)} />
</Gtk.Box>}
</With>
<Pages $={(self) => slidersPages = self} />
</Gtk.Box>
}
+32
View File
@@ -0,0 +1,32 @@
import { Gtk } from "ags/gtk4";
import { TileNetwork } from "./tiles/Network";
import { TileBluetooth } from "./tiles/Bluetooth";
import { TileDND } from "./tiles/DoNotDisturb";
import { TileRecording } from "./tiles/Recording";
import { TileNightLight } from "./tiles/NightLight";
import { Pages } from "./Pages";
export let TilesPages: Pages|undefined;
export const tileList: Array<() => JSX.Element|Gtk.Widget> = [
TileNetwork,
TileBluetooth,
TileRecording,
TileDND,
TileNightLight
] as Array<() => Gtk.Widget>;
export function Tiles(): Gtk.Widget {
return <Gtk.Box class={"tiles-container"} orientation={Gtk.Orientation.VERTICAL}
onUnmap={() => TilesPages = undefined}>
<Gtk.FlowBox orientation={Gtk.Orientation.HORIZONTAL} rowSpacing={6}
columnSpacing={6} minChildrenPerLine={2} activateOnSingleClick
maxChildrenPerLine={2} hexpand homogeneous>
{tileList.map(t => t())}
</Gtk.FlowBox>
<Pages class={"tile-pages"} $={(self) => TilesPages = self} />
</Gtk.Box> as Gtk.Box;
}
@@ -0,0 +1,183 @@
import { Gtk } from "ags/gtk4";
import { Page, PageButton } from "./Page";
import { tr } from "../../../i18n/intl";
import { Windows } from "../../../windows";
import { Notifications } from "../../../scripts/notifications";
import { execApp } from "../../../scripts/apps";
import { createBinding, createComputed, For, With } from "ags";
import AstalNotifd from "gi://AstalNotifd";
import AstalBluetooth from "gi://AstalBluetooth";
export const BluetoothPage = new Page({
id: "bluetooth",
title: tr("control_center.pages.bluetooth.title"),
spacing: 6,
description: tr("control_center.pages.bluetooth.description"),
headerButtons: [{
icon: createBinding(AstalBluetooth.get_default().adapter, "discovering")
.as(discovering => !discovering ?
"arrow-circular-top-right-symbolic"
: "media-playback-stop-symbolic"
),
tooltipText: createBinding(AstalBluetooth.get_default().adapter, "discovering")
.as((discovering) => !discovering ?
tr("control_center.pages.bluetooth.start_discovering")
: tr("control_center.pages.bluetooth.stop_discovering")),
actionClicked: () => {
if(AstalBluetooth.get_default().adapter.discovering) {
AstalBluetooth.get_default().adapter.stop_discovery();
return;
}
AstalBluetooth.get_default().adapter.start_discovery();
}
}],
actionClosed: () => AstalBluetooth.get_default().adapter?.discovering &&
AstalBluetooth.get_default().adapter.stop_discovery(),
bottomButtons: [{
title: tr("control_center.pages.more_settings"),
actionClicked: () => {
Windows.getDefault().close("control-center");
execApp("overskride", "[float; animation slide right]");
}
}],
content: () => [
<Gtk.Box class={"adapters"} visible={createBinding(AstalBluetooth.get_default(), "adapters")
.as(adptrs => adptrs.length > 1)
} spacing={2} orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"sub-header"} label={tr("control_center.pages.bluetooth.adapters")}
xalign={0} />
<With value={createBinding(AstalBluetooth.get_default(), "adapters").as(adpts =>
adpts.length > 1)}>
{(hasMoreAdapters: boolean) => hasMoreAdapters &&
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={2}>
<For each={createBinding(AstalBluetooth.get_default(), "adapters")}>
{(adapter: AstalBluetooth.Adapter) => {
const isSelected = createBinding(AstalBluetooth.get_default(), "adapter").as(a =>
a.address === adapter.address);
return <PageButton class={isSelected.as(is => is ? "selected" : "")}
title={adapter.alias ?? "Adapter"} icon={"bluetooth-active-symbolic"}
endWidget={
<Gtk.Image iconName={"object-select-symbolic"} visible={isSelected} />
}
/>;
}}
</For>
</Gtk.Box>
}
</With>
</Gtk.Box>,
<Gtk.Box class={"connections"} orientation={Gtk.Orientation.VERTICAL} hexpand={true}
spacing={2}>
<Gtk.Box class={"paired"} orientation={Gtk.Orientation.VERTICAL} spacing={4}
visible={createBinding(AstalBluetooth.get_default(), "devices").as(devs =>
devs.filter(dev => dev.paired || dev.connected || dev.trusted).length > 0)}>
<Gtk.Label class={"sub-header"} label={tr("devices")} xalign={0} />
<For each={createBinding(AstalBluetooth.get_default(), "devices").as(devs =>
devs.filter(dev => dev.paired || dev.connected || dev.trusted))}>
{(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />}
</For>
</Gtk.Box>
<Gtk.Box class={"discovered"} orientation={Gtk.Orientation.VERTICAL} spacing={4}
visible={createBinding(AstalBluetooth.get_default(), "devices").as(devs =>
devs.filter(dev => !dev.connected && !dev.paired && !dev.trusted).length > 0)}>
<Gtk.Label class={"sub-header"} label={tr("control_center.pages.bluetooth.new_devices")}
xalign={0} />
<For each={createBinding(AstalBluetooth.get_default(), "devices").as(devs =>
devs.filter(dev => !dev.connected && !dev.paired && !dev.trusted))}>
{(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />}
</For>
</Gtk.Box>
</Gtk.Box>
]
});
function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget {
return <PageButton class={createBinding(device, "connected").as(conn =>
conn ? "selected" : "")} title={
createBinding(device, "alias").as(alias => alias ?? "Unknown Device")}
icon={createBinding(device, "icon").as(ico => ico ?? "bluetooth-active-symbolic")}
description={
createBinding(device, "connecting").as(connecting =>
connecting ? `${tr("connecting")}...` : "")}
tooltipText={
createBinding(device, "connected").as(connected =>
!connected ? tr("connect") : "")
} actionClicked={() => {
if(device.connected) return;
let skipConnection: boolean = false;
if(!device.paired)
(async () => device.pair())().catch((err: Error) => {
skipConnection = true;
Notifications.getDefault().sendNotification({
appName: "bluetooth",
summary: "Device pairing error",
body: `Couldn't connect to ${device.alias ?? device.name}, an error occurred: ${err.message || err.stack}`,
urgency: AstalNotifd.Urgency.NORMAL
})
}).then(() => device.set_trusted(true));
if(!skipConnection)
(async () => device.connect_device(null))().catch((err: Error) =>
Notifications.getDefault().sendNotification({
appName: "bluetooth",
summary: "Device connection error",
body: `Couldn't connect to ${device.alias ?? device.name}, an error occurred: ${err.message || err.stack}`,
urgency: AstalNotifd.Urgency.NORMAL
})
);
}}
endWidget={<Gtk.Box visible={createComputed([
createBinding(device, "batteryPercentage"),
createBinding(device, "connected")
]).as(([batt, connected]) => connected && (batt > -1))
}>
<Gtk.Label halign={Gtk.Align.END} label={
createBinding(device, "batteryPercentage").as(batt =>
`${Math.floor(batt * 100)}%`)} />
<Gtk.Image iconName={
createBinding(device, "batteryPercentage").as(batt =>
`battery-level-${Math.floor(batt * 100)}-symbolic`)
} css={"font-size: 16px; margin-left: 6px;"} />
</Gtk.Box>} extraButtons={<With value={createComputed([
createBinding(device, "connected"),
createBinding(device, "trusted")
])}>
{([connected, trusted]: [boolean, boolean]) =>
<Gtk.Box visible={connected || trusted}>
{<Gtk.Button iconName={connected ?
"list-remove-symbolic"
: "user-trash-symbolic"} tooltipText={tr(connected ?
"disconnect"
: "control_center.pages.bluetooth.unpair_device"
)} onClicked={() => {
if(!connected) {
AstalBluetooth.get_default().adapter?.remove_device(device);
return;
}
device.disconnect_device(null);
}} />}
<Gtk.Button iconName={trusted ?
"shield-safe-symbolic"
: "shield-danger-symbolic"} tooltipText={tr(
`control_center.pages.bluetooth.${trusted ? "un" : ""}trust_device`
)} onClicked={() => device.set_trusted(!trusted)}
/>
</Gtk.Box>
}
</With>}
/> as Gtk.Widget;
}
@@ -0,0 +1,34 @@
import { Page, PageButton } from "./Page";
import { Wireplumber } from "../../../scripts/volume";
import { Gtk } from "ags/gtk4";
import { tr } from "../../../i18n/intl";
import { createBinding, For } from "ags";
import { lookupIcon } from "../../../scripts/apps";
import AstalWp from "gi://AstalWp?version=0.1";
export const PageMicrophone = new Page({
id: "microphone",
title: tr("control_center.pages.microphone.title"),
description: tr("control_center.pages.microphone.description"),
content: () => [
<Gtk.Label class={"sub-header"} label={tr("devices")} xalign={0} />,
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={4}>
<For each={createBinding(Wireplumber.getWireplumber().get_audio()!, "microphones")}>
{(source: AstalWp.Endpoint) => <PageButton class={
createBinding(source, "isDefault").as(isDefault => isDefault ? "selected" : "")
} icon={createBinding(source, "icon").as(ico => lookupIcon(ico) ?
ico : "audio-input-microphone-symbolic")} title={
createBinding(source, "description").as(desc => desc ?? "Microphone")
} actionClicked={() => !source.isDefault && source.set_is_default(true)}
endWidget={
<Gtk.Image iconName={"object-select-symbolic"} visible={
createBinding(source, "isDefault")} css={"font-size: 18px;"}
/>
}
/>}
</For>
</Gtk.Box>
]
});
+170
View File
@@ -0,0 +1,170 @@
import { Gtk } from "ags/gtk4";
import { Page, PageButton } from "./Page";
import { Windows } from "../../../windows";
import { tr } from "../../../i18n/intl";
import { execApp } from "../../../scripts/apps";
import { Notifications } from "../../../scripts/notifications";
import { AskPopup, AskPopupProps } from "../../AskPopup";
import { encoder, variableToBoolean } from "../../../scripts/utils";
import { createBinding, For, With } from "ags";
import GLib from "gi://GLib?version=2.0";
import NM from "gi://NM";
import AstalNetwork from "gi://AstalNetwork";
export const PageNetwork = new Page({
id: "network",
title: tr("control_center.pages.network.title"),
headerButtons: createBinding(AstalNetwork.get_default(), "primary").as(primary =>
primary === AstalNetwork.Primary.WIFI ? [{
icon: "arrow-circular-top-right-symbolic",
tooltipText: "Re-scan networks",
actionClicked: () => AstalNetwork.get_default().wifi.scan()
}] : []
),
bottomButtons: [{
title: tr("control_center.pages.more_settings"),
actionClicked: () => {
Windows.getDefault().close("control-center");
execApp("nm-connection-editor", "[animationstyle gnomed]");
}
}],
content: () => [
<Gtk.Box class={"devices"} hexpand orientation={Gtk.Orientation.VERTICAL}
visible={variableToBoolean(createBinding(AstalNetwork.get_default().client, "devices"))}
spacing={4}>
<Gtk.Label label={tr("devices")} xalign={0} class={"sub-header"} />
<For each={createBinding(AstalNetwork.get_default().client, "devices").as(devs =>
devs.filter(dev => dev.interface !== "lo" && dev.real /* filter local device */))}>
{(device: NM.Device) => <PageButton title={createBinding(device, "interface").as(iface =>
iface ?? tr("control_center.pages.network.interface"))} class={"device"}
icon={createBinding(device, "deviceType").as(type => type === NM.DeviceType.WIFI ?
"network-wireless-symbolic" : "network-wired-symbolic")} extraButtons={[
<Gtk.Button iconName={"view-more-symbolic"} onClicked={() => {
Windows.getDefault().close("control-center");
execApp(
`nm-connection-editor --edit ${device.activeConnection?.connection.get_uuid()}`,
"[animationstyle gnomed; float]"
);
}} />
]}
/>}
</For>
</Gtk.Box>,
<With value={createBinding(AstalNetwork.get_default(), "primary").as(primary =>
primary === AstalNetwork.Primary.WIFI)}>
{(isWifi: boolean) => isWifi && <Gtk.Box class={"wireless-aps"} hexpand={true}
orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"sub-header"} label={"Wi-Fi"} />
<For each={createBinding(AstalNetwork.get_default().wifi, "accessPoints")}>
{(ap: AstalNetwork.AccessPoint) => <PageButton class={
createBinding(AstalNetwork.get_default().wifi, "activeAccessPoint").as(activeAP =>
activeAP.ssid === ap.ssid ? "active" : "")
} title={createBinding(ap, "ssid").as(ssid => ssid ?? "No SSID")}
icon={createBinding(ap, "iconName")} endWidget={<Gtk.Image iconName={
createBinding(ap, "flags").as(flags =>
// @ts-ignore
flags & NM["80211ApFlags"].PRIVACY ?
"channel-secure-symbolic"
: "channel-insecure-symbolic")}
css={"font-size: 18px;"}
/>} extraButtons={[
<Gtk.Button iconName={"window-close-symbolic"} visible={
createBinding(AstalNetwork.get_default().wifi, "activeAccessPoint").as(activeAp =>
activeAp.ssid === ap.ssid)
} css={"font-size: 18px;"} onClicked={() => {
const active = AstalNetwork.get_default().wifi.activeAccessPoint;
if(active?.ssid === ap.ssid) {
AstalNetwork.get_default().wifi.deactivate_connection((_, res) => {
try {
AstalNetwork.get_default().wifi.deactivate_connection_finish(res);
} catch(e: any) {
e = e as Error;
console.error(
`Network: couldn't deactivate connection with access point(SSID: ${
ap.ssid}. Stderr: \n${e.message}\n${e.stack}`
);
}
})
}
}}/>
]} actionClicked={() => {
const uuid = NM.utils_uuid_generate();
const ssidBytes = GLib.Bytes.new(encoder.encode(ap.ssid));
const connection = NM.SimpleConnection.new();
const connSetting = NM.SettingConnection.new();
const wifiSetting = NM.SettingWireless.new();
const wifiSecuritySetting = NM.SettingWirelessSecurity.new();
const setting8021x = NM.Setting8021x.new();
// @ts-ignore yep, type-gen issues again
if(ap.rsnFlags !& NM["80211ApSecurityFlags"].KEY_MGMT_802_1X &&
// @ts-ignore
ap.wpaFlags !& NM["80211ApSecurityFlags"].KEY_MGMT_802_1X) {
return;
}
connSetting.uuid = uuid;
connection.add_setting(connSetting);
connection.add_setting(wifiSetting);
wifiSetting.ssid = ssidBytes;
wifiSecuritySetting.keyMgmt = "wpa-eap";
connection.add_setting(wifiSecuritySetting);
setting8021x.add_eap_method("ttls");
setting8021x.phase2Auth = "mschapv2";
connection.add_setting(setting8021x);
}}
/>}
</For>
</Gtk.Box>}
</With>
]
});
function activateWirelessConnection(connection: NM.RemoteConnection, ssid: string): void {
AstalNetwork.get_default().get_client().activate_connection_async(
connection, AstalNetwork.get_default().wifi.get_device(), null, null, (_, asyncRes) => {
const activeConnection = AstalNetwork.get_default().get_client().activate_connection_finish(asyncRes);
if(!activeConnection) {
Notifications.getDefault().sendNotification({
appName: "network",
summary: "Couldn't activate wireless connection",
body: `An error occurred while activating the wireless connection "${ssid}"`
});
return;
}
}
);
}
function notifyConnectionError(ssid: string): void {
Notifications.getDefault().sendNotification({
appName: "network",
summary: "Coudn't connect Wi-Fi",
body: `An error occurred while trying to connect to the "${ssid}" access point. \nMaybe the password is invalid?`
});
}
function saveToDisk(remoteConnection: NM.RemoteConnection, ssid: string): void {
AskPopup({
text: `Save password for connection "${ssid}"?`,
acceptText: "Yes",
onAccept: () => remoteConnection.commit_changes_async(true, null, (_, asyncRes) =>
!remoteConnection.commit_changes_finish(asyncRes) && Notifications.getDefault().sendNotification({
appName: "network",
summary: "Couldn't save Wi-Fi password",
body: `An error occurred while trying to write the password for "${ssid}" to disk`
}))
} as AskPopupProps);
}
@@ -0,0 +1,43 @@
import { Page } from "./Page";
import { NightLight } from "../../../scripts/nightlight";
import { tr } from "../../../i18n/intl";
import { Astal, Gtk } from "ags/gtk4";
import { addSliderMarksFromMinMax } from "../../../scripts/utils";
import { createBinding } from "ags";
export const PageNightLight = new Page({
id: "night-light",
title: tr("control_center.pages.night_light.title"),
description: tr("control_center.pages.night_light.description"),
content: () => [
<Gtk.Label class={"sub-header"} label={tr(
"control_center.pages.night_light.temperature"
)} xalign={0} />,
<Astal.Slider class={"temperature"} $={(self) => {
self.value = NightLight.getDefault().temperature;
addSliderMarksFromMinMax(self, 5, "{}K");
}} value={createBinding(NightLight.getDefault(), "temperature")}
tooltipText={createBinding(NightLight.getDefault(), "temperature").as(temp =>
`${temp}K`)} min={NightLight.getDefault().minTemperature}
max={NightLight.getDefault().maxTemperature}
onChangeValue={(_, type, value) => {
if(type != undefined && type !== null)
NightLight.getDefault().temperature = Math.floor(value)
}}
/>,
<Gtk.Label class={"sub-header"} label={tr(
"control_center.pages.night_light.gamma"
)} xalign={0} />,
<Astal.Slider class={"gamma"} $={(self) => {
self.value = NightLight.getDefault().gamma;
addSliderMarksFromMinMax(self, 5, "{}%");
}} value={createBinding(NightLight.getDefault(), "gamma")}
tooltipText={createBinding(NightLight.getDefault(), "gamma").as(gamma =>
`${gamma}%`)} max={NightLight.getDefault().maxGamma}
onChangeValue={(_, type, value) => {
if(type != undefined && type !== null)
NightLight.getDefault().gamma = Math.floor(value)
}}
/>
]
});
+170
View File
@@ -0,0 +1,170 @@
import { Gtk } from "ags/gtk4";
import { Separator } from "../../Separator";
import { Accessor, createRoot } from "ags";
import { transformWidget, variableToBoolean, WidgetNodeType } from "../../../scripts/utils";
import Pango from "gi://Pango?version=1.0";
export type PageProps = {
id: string;
title: string;
description?: string;
headerButtons?: Array<HeaderButton> | Accessor<Array<HeaderButton>>;
bottomButtons?: Array<BottomButton> | Accessor<Array<BottomButton>>;
orientation?: Gtk.Orientation | Accessor<Gtk.Orientation>;
spacing?: number | Accessor<number>;
content: () => WidgetNodeType;
actionClosed?: () => void;
};
export type BottomButton = {
title: string | Accessor<string>;
description?: string | Accessor<string>;
tooltipText?: string | Accessor<string>;
tooltipMarkup?: string | Accessor<string>;
actionClicked?: () => void;
};
export type HeaderButton = {
label?: string|Accessor<string>;
icon: string|Accessor<string>;
tooltipText?: string | Accessor<string>;
tooltipMarkup?: string | Accessor<string>;
actionClicked?: () => void;
};
export class Page {
#title: string;
#description?: string;
#orientation: Gtk.Orientation|Accessor<
Gtk.Orientation> = Gtk.Orientation.VERTICAL;
#spacing: number|Accessor<number> = 4;
#headerButtons?: Array<HeaderButton>|Accessor<Array<HeaderButton>>;
#bottomButtons?: Array<BottomButton>|Accessor<Array<BottomButton>>;
readonly #id?: string;
readonly #create: () => WidgetNodeType;
public get id() { return this.#id; }
public get title() { return this.#title; }
public get description() { return this.#description; }
public get headerButtons() { return this.#headerButtons; }
public get bottomButtons() { return this.#bottomButtons; }
public readonly actionClosed?: () => void;
constructor(props: PageProps) {
this.#id = props.id;
this.#title = props.title;
this.#description = props.description;
this.#create = props.content;
this.actionClosed = props.actionClosed;
if(props.orientation != null)
this.#orientation = props.orientation;
if(props.spacing != null)
this.#spacing = props.spacing;
if(props.headerButtons != null)
this.#headerButtons = props.headerButtons;
}
public create(): Gtk.Box {
return createRoot((dispose) =>
<Gtk.Box hexpand class={`page container ${this.#id ?? ""}`} cssName={"page"} name={"page"}
orientation={Gtk.Orientation.VERTICAL} onUnmap={() => dispose()}>
<Gtk.Box class={"header"} orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Box class={"top"} hexpand>
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} hexpand>
<Gtk.Label class={"title"} label={this.#title} xalign={0}
ellipsize={Pango.EllipsizeMode.END} />
<Gtk.Label class={"description"} label={this.#description}
xalign={0} ellipsize={Pango.EllipsizeMode.END}
visible={variableToBoolean(this.#description)} />
</Gtk.Box>
<Gtk.Box class={"button-row"} visible={variableToBoolean(this.#headerButtons)}
hexpand={false}>
{this.#headerButtons && transformWidget(this.#headerButtons, (button) =>
<Gtk.Button class={"header-button"} label={button.label}
iconName={button.icon} onClicked={() => button.actionClicked?.()}
tooltipText={button.tooltipText} tooltipMarkup={button.tooltipMarkup}
/>
)}
</Gtk.Box>
</Gtk.Box>
</Gtk.Box>
<Gtk.Box class={"content"} hexpand={false} orientation={this.#orientation}
spacing={this.#spacing}>
{this.#create()}
</Gtk.Box>
<Separator alpha={.2} spacing={6} orientation={Gtk.Orientation.VERTICAL}
visible={variableToBoolean(this.#bottomButtons)}
/>
<Gtk.Box class={"bottom-buttons"} orientation={Gtk.Orientation.VERTICAL}
visible={variableToBoolean(this.#bottomButtons)} spacing={2}>
{this.#bottomButtons && transformWidget(this.#bottomButtons, (button) =>
<Gtk.Button onClicked={() => button?.actionClicked?.()} tooltipText={button?.tooltipText}
tooltipMarkup={button?.tooltipMarkup}>
<Gtk.Label class={"title"} label={button?.title} xalign={0} />
<Gtk.Label class={"description"} label={button?.description}
xalign={0} visible={variableToBoolean(button?.description)} />
</Gtk.Button>
)}
</Gtk.Box>
</Gtk.Box> as Gtk.Box
);
}
public static getContent(pageWidget: Gtk.Box) {
return pageWidget.get_first_child()!.get_next_sibling()! as Gtk.Box;
}
}
export function PageButton({ onUnmap, ...props }: {
class?: string | Accessor<string>;
icon?: string | Accessor<string>;
title: string | Accessor<string>;
endWidget?: WidgetNodeType;
description?: string | Accessor<string>;
extraButtons?: Array<WidgetNodeType> | WidgetNodeType;
maxWidthChars?: number | Accessor<number>;
onUnmap?: (self: Gtk.Box) => void;
actionClicked?: (self: Gtk.Button) => void;
tooltipText?: string | Accessor<string>;
tooltipMarkup?: string | Accessor<string>;
}): Gtk.Box {
return <Gtk.Box onUnmap={(self) => onUnmap?.(self)} class={"page-button"}>
<Gtk.Button onClicked={props.actionClicked} class={props.class} hexpand
tooltipText={props.tooltipText} tooltipMarkup={props.tooltipMarkup}>
<Gtk.Box class={"container"} hexpand>
{props.icon && <Gtk.Image iconName={props.icon} visible={variableToBoolean(props.icon)}
css={"font-size: 20px; margin-right: 6px;"} />}
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} hexpand vexpand={false}>
<Gtk.Label class={"title"} xalign={0} tooltipText={props.title}
ellipsize={Pango.EllipsizeMode.END} label={props.title}
maxWidthChars={props.maxWidthChars ?? 28}
/>
<Gtk.Label class={"description"} xalign={0} visible={variableToBoolean(props.description)}
label={props.description} ellipsize={Pango.EllipsizeMode.END}
tooltipText={props.description} />
</Gtk.Box>
<Gtk.Box visible={variableToBoolean(props.endWidget)} halign={Gtk.Align.END}>
{props.endWidget && props.endWidget}
</Gtk.Box>
</Gtk.Box>
</Gtk.Button>
<Gtk.Box class={"extra-buttons"} visible={variableToBoolean(props.extraButtons)}>
{props.extraButtons}
</Gtk.Box>
</Gtk.Box> as Gtk.Box;
}
+96
View File
@@ -0,0 +1,96 @@
import { Page, PageButton } from "./Page";
import { Astal, Gtk } from "ags/gtk4";
import { getAppIcon, lookupIcon } from "../../../scripts/apps";
import { Wireplumber } from "../../../scripts/volume";
import { tr } from "../../../i18n/intl";
import { createBinding, For } from "ags";
import { variableToBoolean } from "../../../scripts/utils";
import AstalWp from "gi://AstalWp";
import GObject from "gi://GObject?version=2.0";
import Pango from "gi://Pango?version=1.0";
export const PageSound = new Page({
id: "sound",
title: tr("control_center.pages.sound.title"),
description: tr("control_center.pages.sound.description"),
content: () => [
<Gtk.Label class={"sub-header"} label={tr("devices")} xalign={0} />,
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={4}>
<For each={createBinding(Wireplumber.getWireplumber().audio!, "speakers")}>
{(sink: AstalWp.Endpoint) =>
<PageButton class={createBinding(sink, "isDefault").as(isDefault =>
isDefault ? "selected" : "")}
icon={createBinding(sink, "icon").as(ico =>
lookupIcon(ico) ? ico : "audio-card-symbolic")}
title={createBinding(sink, "description").as(desc =>
desc ?? "Speaker")}
actionClicked={() => !sink.isDefault && sink.set_is_default(true)}
endWidget={
<Gtk.Image iconName={"object-select-symbolic"}
visible={createBinding(sink, "isDefault")}
css={"font-size: 18px;"}
/>
}
/>}
</For>
</Gtk.Box>,
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
<Gtk.Label class={"sub-header"} label={tr("apps")} xalign={0}
visible={variableToBoolean(
createBinding(Wireplumber.getWireplumber().audio!, "streams")
)}
/>
<For each={createBinding(Wireplumber.getWireplumber().audio!, "streams")}>
{(stream: AstalWp.Stream) =>
<Gtk.Box hexpand $={(self) => {
const conns: Map<GObject.Object, Array<number>> = new Map();
const controllerMotion = Gtk.EventControllerMotion.new();
self.add_controller(controllerMotion);
conns.set(controllerMotion, [
controllerMotion.connect("enter", () => {
const revealer = self.get_last_child()!.get_first_child() as Gtk.Revealer;
revealer.set_reveal_child(true);
}),
controllerMotion.connect("leave", () => {
const revealer = self.get_last_child()!.get_first_child() as Gtk.Revealer;
revealer.set_reveal_child(false);
})
]);
conns.set(self, [
self.connect("destroy", () => conns.forEach((ids, obj) =>
ids.forEach(id => obj.disconnect(id))
))
]);
}}>
<Gtk.Image iconName={createBinding(stream, "name").as(name =>
getAppIcon(name.split(' ')[0]) ?? "application-x-executable-symbolic")}
css={"font-size: 18px; margin-right: 6px;"} />
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} hexpand={true}>
<Gtk.Revealer transitionDuration={180}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}>
<Gtk.Label label={createBinding(stream, "description").as(desc =>
desc ?? "Unnamed audio stream")}
ellipsize={Pango.EllipsizeMode.END}
tooltipText={createBinding(stream, "name")}
class={"name"} xalign={0}
/>
</Gtk.Revealer>
<Astal.Slider drawValue={false} value={createBinding(stream, "volume")}
onChangeValue={(_, __, value) => stream.set_volume(value)}
hexpand min={0} max={1.5}
/>
</Gtk.Box>
</Gtk.Box>
}
</For>
</Gtk.Box>
]
});
@@ -0,0 +1,28 @@
import { Tile } from "./Tile";
import AstalBluetooth from "gi://AstalBluetooth";
import { BluetoothPage } from "../pages/Bluetooth";
import { TilesPages } from "../Tiles";
import { createBinding, createComputed } from "ags";
export const TileBluetooth = () =>
<Tile title={"Bluetooth"} visible={
createBinding(AstalBluetooth.get_default(), "adapter").as(Boolean)
} description={createBinding(AstalBluetooth.get_default(), "isConnected").as((connected) => {
const connectedDev = AstalBluetooth.get_default().devices.filter(dev => dev.connected)?.[0];
return connected && connectedDev ? connectedDev.get_alias() : ""
})} onToggledOn={() => AstalBluetooth.get_default().adapter?.set_powered(true)}
onToggledOff={() => AstalBluetooth.get_default().adapter?.set_powered(false)}
onClickMore={() => TilesPages?.toggle(BluetoothPage)}
enableOnClickMore={true} iconSize={16}
toggleState={createBinding(AstalBluetooth.get_default(), "isPowered")}
icon={createComputed([
createBinding(AstalBluetooth.get_default(), "isPowered"),
createBinding(AstalBluetooth.get_default(), "isConnected")
],
(powered: boolean, isConnected: boolean) =>
powered ? ( isConnected ?
"bluetooth-active-symbolic"
: "bluetooth-symbolic"
) : "bluetooth-disabled-symbolic")}
/>;
@@ -0,0 +1,15 @@
import { Notifications } from "../../../scripts/notifications";
import { Tile } from "./Tile";
import { tr } from "../../../i18n/intl";
import { createBinding } from "ags";
export const TileDND = () =>
<Tile title={tr("control_center.tiles.dnd.title")}
description={createBinding(Notifications.getDefault().getNotifd(), "dontDisturb").as(
(dnd: boolean) => dnd ? tr("control_center.tiles.enabled") : tr("control_center.tiles.disabled"))}
onToggledOff={() => Notifications.getDefault().getNotifd().dontDisturb = false}
onToggledOn={() => Notifications.getDefault().getNotifd().dontDisturb = true}
icon={"minus-circle-filled-symbolic"}
iconSize={16}
toggleState={Notifications.getDefault().getNotifd().dontDisturb}
/>;
@@ -0,0 +1,85 @@
import { execAsync } from "ags/process";
import { Tile } from "./Tile";
import AstalNetwork from "gi://AstalNetwork";
import { PageNetwork } from "../pages/Network";
import { tr } from "../../../i18n/intl";
import { TilesPages } from "../Tiles";
import { Gtk } from "ags/gtk4";
import { createBinding, createComputed, With } from "ags";
export const TileNetwork = () => <Gtk.Box>
<With value={createComputed([
createBinding(AstalNetwork.get_default(), "primary"),
createBinding(AstalNetwork.get_default(), "wired"),
createBinding(AstalNetwork.get_default(), "wifi")
])}>
{([primary, wired, wifi]: [AstalNetwork.Primary, AstalNetwork.Wired, AstalNetwork.Wifi]) => {
if(primary === AstalNetwork.Primary.WIFI) {
return <Tile title={tr("control_center.tiles.network.wireless")}
description={createComputed([
createBinding(wifi, "ssid"), createBinding(wifi, "internet")
], (ssid, internet) => ssid ? ssid : (() => {
switch(internet) {
case AstalNetwork.Internet.CONNECTED:
return tr("connected");
case AstalNetwork.Internet.DISCONNECTED:
return tr("disconnected");
case AstalNetwork.Internet.CONNECTING:
return tr("connecting") + "...";
}
})()
)} onToggledOn={() => wifi.set_enabled(true)}
onToggledOff={() => wifi.set_enabled(false)}
onClickMore={() => TilesPages?.toggle(PageNetwork)}
icon={"network-wireless-signal-excellent-symbolic"}
toggleState={createBinding(wifi, "enabled")}
/>
} else if(primary === AstalNetwork.Primary.WIRED) {
return <Tile title={tr("control_center.tiles.network.wired")}
description={createBinding(wired, "internet").as((internet: AstalNetwork.Internet) => {
switch(internet) {
case AstalNetwork.Internet.CONNECTED:
return tr("connected");
case AstalNetwork.Internet.DISCONNECTED:
return tr("disconnected");
case AstalNetwork.Internet.CONNECTING:
return tr("connecting") + "...";
}
})}
onToggledOn={() => execAsync("nmcli n on")}
onToggledOff={() => execAsync("nmcli n off")}
onClickMore={() => TilesPages?.toggle(PageNetwork)}
icon={createBinding(wired, "internet").as((internet: AstalNetwork.Internet) => {
switch(internet) {
case AstalNetwork.Internet.CONNECTED:
return "network-wired-symbolic";
case AstalNetwork.Internet.DISCONNECTED:
return "network-wired-disconnected-symbolic";
}
return "network-wired-no-route-symbolic";
})}
iconSize={16}
toggleState={createBinding(wired, "internet").as((internet: AstalNetwork.Internet) =>
internet === AstalNetwork.Internet.CONNECTING
|| internet === AstalNetwork.Internet.CONNECTED
)}
/>
}
return <Tile
title={tr("control_center.tiles.network.network")}
description={tr("disconnected")}
onToggledOn={() => execAsync("nmcli n on")}
onToggledOff={() => execAsync("nmcli n off")}
onClickMore={() => TilesPages?.toggle(PageNetwork)}
icon={"network-wired-disconnected-symbolic"}
iconSize={16}
toggleState={createBinding(wired, "internet").as((internet: AstalNetwork.Internet) =>
internet === AstalNetwork.Internet.CONNECTING || internet === AstalNetwork.Internet.CONNECTED)}
/>
}}
</With>
</Gtk.Box>;
@@ -0,0 +1,25 @@
import { Tile } from "./Tile";
import { NightLight } from "../../../scripts/nightlight";
import { PageNightLight } from "../pages/NightLight";
import { tr } from "../../../i18n/intl";
import { TilesPages } from "../Tiles";
import { isInstalled } from "../../../scripts/utils";
import { createBinding, createComputed } from "ags";
export const TileNightLight = () =>
<Tile title={tr("control_center.tiles.night_light.title")}
icon={"weather-clear-night-symbolic"}
description={createComputed([
createBinding(NightLight.getDefault(), "temperature"),
createBinding(NightLight.getDefault(), "gamma")
], (temp, gamma) => `${temp === NightLight.getDefault().identityTemperature ?
tr("control_center.tiles.night_light.default_desc") : `${temp}K`} ${
gamma < NightLight.getDefault().maxGamma ? `(${gamma}%)` : ""}`
)}
visible={isInstalled("hyprsunset")}
onToggledOff={() => NightLight.getDefault().identity = true}
onToggledOn={() => NightLight.getDefault().identity = false}
enableOnClickMore={true}
onClickMore={() => TilesPages?.toggle(PageNightLight)}
toggleState={createBinding(NightLight.getDefault(), "identity").as(identity => !identity)}
/>
@@ -0,0 +1,31 @@
import { Tile } from "./Tile";
import { Recording } from "../../../scripts/recording";
import { tr } from "../../../i18n/intl";
import { isInstalled, time } from "../../../scripts/utils";
import { createBinding, createComputed } from "ags";
export const TileRecording = () =>
<Tile title={tr("control_center.tiles.recording.title")}
description={createComputed([
createBinding(Recording.getDefault(), "recording"),
time
], (recording, dateTime) => {
if(!recording || !Recording.getDefault().startedAt)
return tr("control_center.tiles.recording.disabled_desc") || "Start recording";
const startedAtSeconds = dateTime.to_unix() - Recording.getDefault().startedAt!;
if(startedAtSeconds <= 0) return "00:00";
const minutes = Math.floor(startedAtSeconds / 60);
const seconds = Math.floor(startedAtSeconds % 60);
return `${ minutes < 10 ? `0${minutes}` : minutes }:${ seconds < 10 ? `0${seconds}` : seconds }`;
})}
icon={"media-record-symbolic"}
visible={isInstalled("wf-recorder")}
onToggledOff={() => Recording.getDefault().stopRecording()}
onToggledOn={() => Recording.getDefault().startRecording()}
toggleState={createBinding(Recording.getDefault(), "recording")}
iconSize={16}
/>;
+198
View File
@@ -0,0 +1,198 @@
import { Gtk } from "ags/gtk4";
import { tr } from "../../../i18n/intl";
import { Accessor, createBinding, createComputed, createState, getScope, onCleanup } from "ags";
import { omitObjectKeys, variableToBoolean } from "../../../scripts/utils";
import GObject, { property, register, signal } from "ags/gobject";
import Pango from "gi://Pango?version=1.0";
export { Tile };
@register({ GTypeName: "Tile" })
class Tile extends Gtk.Box {
@signal(Boolean) toggled(_state: boolean) {}
@signal() enabled() {}
@signal() disabled() {}
@signal() clicked() {}
@property(String)
public icon: string;
@property(String)
public title: string;
@property(String)
public description: string = "";
@property(Boolean)
public enableOnClicked: boolean = true;
@property(Boolean)
public state: boolean = false;
declare $signals: Gtk.Box.SignalSignatures & {
"toggled": (_state: boolean) => void;
"enabled": () => void;
"disabled": () => void;
"clicked": () => void;
};
public enable(): void {
if(this.state) return;
this.emit("toggled", true);
this.emit("enabled");
this.state = true;
}
public disable(): void {
if(!this.state) return;
this.emit("toggled", false);
this.emit("disabled");
this.state = false;
}
constructor(props: Omit<Gtk.Box.ConstructorProps, "orientation"> & {
icon: string;
title: string;
description?: string;
state?: boolean;
enableOnClicked?: boolean;
}) {
super(omitObjectKeys(props, [
"icon",
"title",
"description",
"state",
"enableOnClicked"
]));
this.icon = props.icon;
this.title = props.title;
if(props.description != null)
this.description = props.description;
if(props.state != null)
this.state = props.state;
if(props.enableOnClicked != null)
this.enableOnClicked = props.enableOnClicked;
const connections = new Map<GObject.Object, number>();
const gestureClick = Gtk.GestureClick.new();
this.add_controller(gestureClick);
connections.set(gestureClick, gestureClick.connect("released", () => {
this.emit("clicked");
if(this.enableOnClicked && !this.state)
this.enable();
return true;
}));
this.prepend(
<Gtk.Box hexpand={false} vexpand>
<Gtk.Image iconName={createBinding(this, "icon")} />
</Gtk.Box> as Gtk.Box
);
this.append(
<Gtk.Box class={"content"} orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"title"} label={createBinding(this, "title")} />
<Gtk.Label class={"description"} label={createBinding(this, "description")} />
</Gtk.Box> as Gtk.Box
);
getScope()?.onCleanup(() => connections.forEach((id, obj) => obj.disconnect(id)));
}
emit<Signal extends keyof typeof this.$signals>(
signal: Signal,
...args: Parameters<(typeof this.$signals)[Signal]>
): void {
super.emit(signal, ...args);
}
connect<Signal extends keyof typeof this.$signals>(
signal: Signal,
callback: (typeof this.$signals)[Signal]
): number {
return super.connect(signal, callback);
}
}
export function TileFun(props: TileProps): Gtk.Widget {
const subs: Array<() => void> = [];
const [toggled, setToggled] = createState(((props.toggleState instanceof Accessor) ?
props.toggleState.get()
: props.toggleState) ?? false);
(props.toggleState instanceof Accessor) && subs.push(
props.toggleState.subscribe(() =>
setToggled((props.toggleState as Accessor<boolean>).get() ?? false))
);
onCleanup(() => subs.forEach(s => s()));
return <Gtk.Box hexpand visible={props.visible} onUnmap={props.onUnmap}
canFocus focusable={false} class={
(props.class instanceof Accessor) ?
createComputed([props.class, toggled], (clss, isToggled) =>
`tile ${clss} ${isToggled ? "toggled" : ""} ${
props.onClickMore ? "has-more" : ""
}`
)
: toggled.as(isToggled =>
`tile ${props.class ? props.class : ""} ${isToggled ? "toggled" : ""} ${
props.onClickMore ? "has-more" : ""
}`
)
}>
<Gtk.Button class={"toggle-button"} onClicked={() => {
if(toggled.get()) {
setToggled(false);
props.onToggledOff?.();
return;
}
setToggled(true);
props.onToggledOn?.();
}}>
<Gtk.Box class={"content"} hexpand={true} vexpand={true}>
{props.icon && <Gtk.Image class={"icon"} iconName={props.icon} css={
(props.iconSize instanceof Accessor) ?
props.iconSize.as(size => `font-size: ${size}px;`)
: (props.iconSize ?
`font-size: ${props.iconSize ?? 16}px;`
: undefined)
} />}
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} class={"text"} vexpand={true} hexpand={true}
valign={Gtk.Align.CENTER}>
<Gtk.Label class={"title"} xalign={0} halign={Gtk.Align.START} ellipsize={Pango.EllipsizeMode.END}
label={props.title} />
{props.description && <Gtk.Label class={"description"} ellipsize={Pango.EllipsizeMode.END}
visible={variableToBoolean(props.description)} xalign={0} label={
(props.description instanceof Accessor) ?
props.description.as(str => str ?? "")
: (props.description ?? "")
} halign={Gtk.Align.START}
/>}
</Gtk.Box>
</Gtk.Box>
</Gtk.Button>
<Gtk.Button class={"more icon"} iconName={"go-next-symbolic"} widthRequest={32}
visible={Boolean(props.onClickMore)} halign={Gtk.Align.END} onClicked={() => {
((props.enableOnClickMore instanceof Accessor) ?
props.enableOnClickMore.get()
: props.enableOnClickMore) && props.onToggledOn?.();
props.onClickMore?.();
}} tooltipText={tr("control_center.tiles.more")} />
</Gtk.Box> as Gtk.Widget;
}