feat: a lot of new stuff!

support for default bluetooth adapter, notification popup position in configuration, code improvements
This commit is contained in:
retrozinndev
2025-09-24 21:48:34 -03:00
parent cb01765a8e
commit 7f3e66cc71
10 changed files with 383 additions and 160 deletions
+5 -1
View File
@@ -89,6 +89,7 @@
& calendar.view { & calendar.view {
$border-radius: 14px; $border-radius: 14px;
font-weight: 600; font-weight: 600;
background: colors.$bg-primary; background: colors.$bg-primary;
border-radius: $border-radius; border-radius: $border-radius;
@@ -104,7 +105,10 @@
margin: 4px; margin: 4px;
label.day-number { label.day-number {
min-height: 22px; $size: 24px;
min-height: $size;
min-width: $size;
} }
} }
+7 -1
View File
@@ -342,7 +342,13 @@ const generalConfigDefaults = {
notifications: { notifications: {
timeout_low: 4000, timeout_low: 4000,
timeout_normal: 6000, timeout_normal: 6000,
timeout_critical: 0 timeout_critical: 0,
/** notification popup horizontal position. can be "left" or "right"
* @default "right" */
position_h: "right",
/** vertical notification popup position. can be "top" or "bottom"
* @default "top" */
position_v: "top"
}, },
night_light: { night_light: {
+2 -2
View File
@@ -28,10 +28,10 @@ export function getAstalApps(): AstalApps.Apps {
/** handles running with uwsm if it's installed */ /** handles running with uwsm if it's installed */
export function execApp(app: AstalApps.Application|string, dispatchExecArgs?: string) { export function execApp(app: AstalApps.Application|string, dispatchExecArgs?: string) {
const executable = (typeof app === "string") ? app const executable = (typeof app === "string") ? app
: app.executable.replace(/(%f|%F|%u|%U|%i|%c|%k)/g, ""); : app.executable.replace(/%[fFcuUik]/g, "");
AstalHyprland.get_default().dispatch("exec", AstalHyprland.get_default().dispatch("exec",
`${dispatchExecArgs ? `${dispatchExecArgs} ` : ""}${uwsmIsActive ? "uwsm app -- " : ""}${executable}` `${dispatchExecArgs ? `${dispatchExecArgs} ` : ""}${uwsmIsActive ? "uwsm-app -- " : ""}${executable}`
); );
} }
+81
View File
@@ -0,0 +1,81 @@
import { createRoot, getScope, Scope } from "ags";
import GObject, { getter, gtype, register, setter } from "ags/gobject";
import AstalBluetooth from "gi://AstalBluetooth";
/** AstalBluetooth helper (implements the default adapter feature) */
@register({ GTypeName: "Bluetooth" })
export class Bluetooth extends GObject.Object {
private static instance: Bluetooth;
private astalBl = AstalBluetooth.get_default();
#connections: Map<GObject.Object, Array<number>|number> = new Map();
#adapter: AstalBluetooth.Adapter|null = this.astalBl.adapter ?? null;
#scope!: Scope;
#isAvailable: boolean = false;
@getter(Boolean)
get isAvailable() { return this.#isAvailable; }
@getter(gtype<AstalBluetooth.Adapter|null>(AstalBluetooth.Adapter))
get adapter() { return this.#adapter; }
@setter(gtype<AstalBluetooth.Adapter|null>(AstalBluetooth.Adapter))
set adapter(newAdapter: AstalBluetooth.Adapter|null) {
this.#adapter = newAdapter;
this.notify("adapter");
}
constructor() {
super();
createRoot((_) => {
this.#scope = getScope();
this.#connections.set(
AstalBluetooth.get_default(),
AstalBluetooth.get_default().connect("adapter-added", (self, adapter) => {
if(self.adapters.length === 1) // adapter was just added
this.adapter = adapter;
})
);
this.#connections.set(
AstalBluetooth.get_default(),
AstalBluetooth.get_default().connect("adapter-removed", (self, adapter) => {
if(self.adapters.length < 1) {
this.adapter = null;
this.#isAvailable = false;
this.notify("is-available");
}
if(this.#adapter?.address !== adapter.address)
return;
// the removed adapter was the default
if(self.adapters.length < 1) {
this.adapter = null;
this.#isAvailable = false;
this.notify("is-available");
return;
}
this.#adapter = self.adapters[0];
})
);
});
}
public static getDefault(): Bluetooth {
if(!this.instance)
this.instance = new Bluetooth();
return this.instance;
}
vfunc_dispose(): void {
this.#scope.dispose();
}
}
+37 -17
View File
@@ -1,6 +1,7 @@
import { execAsync } from "ags/process"; import { execAsync } from "ags/process";
import { getter, register, signal } from "ags/gobject"; import { getter, register, signal } from "ags/gobject";
import { Gdk } from "ags/gtk4"; import { Gdk } from "ags/gtk4";
import { createRoot, getScope, Scope } from "ags";
import { makeDirectory } from "./utils"; import { makeDirectory } from "./utils";
import { Notifications } from "./notifications"; import { Notifications } from "./notifications";
import { time } from "./utils"; import { time } from "./utils";
@@ -10,10 +11,8 @@ import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0"; import Gio from "gi://Gio?version=2.0";
export { Recording };
@register({ GTypeName: "Recording" }) @register({ GTypeName: "Recording" })
class Recording extends GObject.Object { export class Recording extends GObject.Object {
private static instance: Recording; private static instance: Recording;
@signal() started() {}; @signal() started() {};
@@ -21,6 +20,7 @@ class Recording extends GObject.Object {
#recording: boolean = false; #recording: boolean = false;
#path: string = "~/Recordings"; #path: string = "~/Recordings";
#recordingScope?: Scope;
/** Default extension: mp4(h264) */ /** Default extension: mp4(h264) */
#extension: string = "mp4"; #extension: string = "mp4";
@@ -64,7 +64,22 @@ class Recording extends GObject.Object {
this.notify("extension"); this.notify("extension");
} }
/** Recording output file name. %NULL if screen is not being recorded */ @getter(String)
public get recordingTime() {
if(!this.#recording || !this.#startedAt)
return "not recording";
const startedAtSeconds = time.get().to_unix() - Recording.getDefault().startedAt!;
if(startedAtSeconds <= 0) return "00:00";
const seconds = Math.floor(startedAtSeconds % 60);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
return `${hours > 0 ? `${hours < 10 ? '0' : ""}${hours}` : ""}${ minutes < 10 ? `0${minutes}` : minutes }:${ seconds < 10 ? `0${seconds}` : seconds }`;
}
/** Recording output file name. null if screen is not being recorded */
public get output() { return this.#output; } public get output() { return this.#output; }
/** Currently unsupported property */ /** Currently unsupported property */
@@ -90,18 +105,18 @@ class Recording extends GObject.Object {
} }
public startRecording(area?: Gdk.Rectangle) { public startRecording(area?: Gdk.Rectangle) {
if(this.recording) if(this.#recording)
throw new Error("Screen Recording is already running!"); throw new Error("Screen Recording is already running!");
createRoot(() => {
this.#recordingScope = getScope();
this.#output = `${time.get().format("%Y-%m-%d-%H%M%S")}_rec.${this.extension || "mp4"}`; this.#output = `${time.get().format("%Y-%m-%d-%H%M%S")}_rec.${this.extension || "mp4"}`;
this.#recording = true; this.#recording = true;
this.notify("recording"); this.notify("recording");
this.emit("started"); this.emit("started");
makeDirectory(this.path); makeDirectory(this.path);
const cancellable = Gio.Cancellable.new();
cancellable.cancel = () => {};
const areaString = `${area?.x ?? 0},${area?.y ?? 0} ${area?.width ?? 1}x${area?.height ?? 1}`; const areaString = `${area?.x ?? 0},${area?.y ?? 0} ${area?.width ?? 1}x${area?.height ?? 1}`;
this.#process = Gio.Subprocess.new([ this.#process = Gio.Subprocess.new([
@@ -111,20 +126,30 @@ class Recording extends GObject.Object {
`${this.path}/${this.output!}` `${this.path}/${this.output!}`
], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); ], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
this.#process.wait_async(cancellable, () => { this.#process.wait_async(null, () => {
this.stopRecording(); this.stopRecording();
}); });
this.#startedAt = time.get().to_unix(); this.#startedAt = time.get().to_unix();
this.notify("started-at");
const timeSub = time.subscribe(() => {
this.notify("recording-time");
});
this.#recordingScope.onCleanup(timeSub);
});
} }
public stopRecording() { public stopRecording() {
if(!this.#process) return; if(!this.#process || !this.#recording) return;
!this.#process.get_if_exited() && execAsync([ !this.#process.get_if_exited() && execAsync([
"kill", "-s", "SIGTERM", this.#process.get_identifier()! "kill", "-s", "SIGTERM", this.#process.get_identifier()!
]); ]);
this.#recordingScope?.dispose();
const path = this.#path; const path = this.#path;
const output = this.#output; const output = this.#output;
@@ -138,13 +163,8 @@ class Recording extends GObject.Object {
Notifications.getDefault().sendNotification({ Notifications.getDefault().sendNotification({
actions: [ actions: [
{ {
text: "View", text: "View", // will be hidden(can be triggered by clicking in the notification)
onAction: () => { id: "view",
execAsync(["nautilus", "-s", output!, path]);
}
},
{
text: "Open",
onAction: () => { onAction: () => {
execAsync(["xdg-open", `${path}/${output}`]); execAsync(["xdg-open", `${path}/${output}`]);
} }
+42 -6
View File
@@ -1,6 +1,6 @@
import { createPoll } from "ags/time"; import { createPoll } from "ags/time";
import { exec, execAsync } from "ags/process"; import { exec, execAsync } from "ags/process";
import { Accessor, For, With } from "ags"; import { Accessor, For, getScope, onCleanup, With } from "ags";
import { Astal, Gtk } from "ags/gtk4"; import { Astal, Gtk } from "ags/gtk4";
import { getSymbolicIcon } from "./apps"; import { getSymbolicIcon } from "./apps";
@@ -217,7 +217,7 @@ export function addSliderMarksFromMinMax(slider: Astal.Slider, amountOfMarks: nu
} }
/** initialize and sub class properties with accessors */ /** initialize and sub class properties with accessors */
export function construct(klass: object, props: Record<any, any|Accessor<any>>): Array<() => void> { export function construct<Class extends object>(klass: Class, props: Record<any, any|Accessor<any>>): Array<() => void> {
const subs: Array<() => void> = []; const subs: Array<() => void> = [];
const isGObject = klass instanceof GObject.Object; const isGObject = klass instanceof GObject.Object;
@@ -228,16 +228,52 @@ export function construct(klass: object, props: Record<any, any|Accessor<any>>):
if(v === undefined) return; if(v === undefined) return;
if(v instanceof Accessor) { if(v instanceof Accessor) {
subs.push(v.subscribe(() => { subs.push(v.subscribe(() => {
klass[k as keyof typeof klass] = v.get() as never; klass[k as keyof Class] = v.get() as Class[keyof Class];
if(isGObject) klass.notify(k); if(isGObject)
klass.notify(k.replace(/[A-Z]/g, (s) => `-${s.toLowerCase()}`
));
})); }));
klass[k as keyof typeof klass] = v.get() as never; klass[k as keyof Class] = v.get() as Class[keyof Class];
return; return;
} }
klass[k as keyof typeof klass] = v as never;
klass[k as keyof Class] = v as Class[keyof Class];
}); });
return subs; return subs;
} }
/** open connections to gobjects that are closed when the scope
* is disposed
* @experimental
* */
export function createConnetions<
GObj extends GObject.Object,
Signals extends GObj["$signals"],
Signal extends keyof Signals,
Callback extends Signals[Signal]
>(...conns: Array<[GObj, Signal, Callback]>): void {
const scope = getScope();
const connections: Map<GObj, Array<number>> = new Map();
scope.onCleanup(() => connections.forEach((ids, gobj) =>
ids.forEach(id => gobj.disconnect(id))
));
function add(gobj: GObj, id: number): void {
if(connections.has(gobj)) {
connections.get(gobj)!.push(id);
return;
}
connections.set(gobj, [id]);
}
conns.forEach(([gobj, sig, callback]) => {
// type stuff
add(gobj, gobj.connect(sig as string, callback as never));
});
}
+9 -19
View File
@@ -10,6 +10,7 @@ import GObject from "ags/gobject";
import AstalBluetooth from "gi://AstalBluetooth"; import AstalBluetooth from "gi://AstalBluetooth";
import AstalNetwork from "gi://AstalNetwork"; import AstalNetwork from "gi://AstalNetwork";
import AstalWp from "gi://AstalWp"; import AstalWp from "gi://AstalWp";
import { Bluetooth } from "../../modules/bluetooth";
export const Status = () => export const Status = () =>
@@ -22,14 +23,16 @@ export const Status = () =>
<VolumeStatus class="sink" endpoint={Wireplumber.getDefault().getDefaultSink()} <VolumeStatus class="sink" endpoint={Wireplumber.getDefault().getDefaultSink()}
icon={createBinding(Wireplumber.getDefault().getDefaultSink(), "volumeIcon").as(icon => icon={createBinding(Wireplumber.getDefault().getDefaultSink(), "volumeIcon").as(icon =>
!Wireplumber.getDefault().isMutedSink() && !Wireplumber.getDefault().isMutedSink() &&
Wireplumber.getDefault().getSinkVolume() > 0 ? icon Wireplumber.getDefault().getSinkVolume() > 0 ?
icon
: "audio-volume-muted-symbolic") : "audio-volume-muted-symbolic")
} /> } />
<VolumeStatus class="source" endpoint={Wireplumber.getDefault().getDefaultSource()} <VolumeStatus class="source" endpoint={Wireplumber.getDefault().getDefaultSource()}
icon={createBinding(Wireplumber.getDefault().getDefaultSource(), "volumeIcon").as(icon => icon={createBinding(Wireplumber.getDefault().getDefaultSource(), "volumeIcon").as(icon =>
!Wireplumber.getDefault().isMutedSource() && !Wireplumber.getDefault().isMutedSource() &&
Wireplumber.getDefault().getSourceVolume() > 0 ? icon Wireplumber.getDefault().getSourceVolume() > 0 ?
icon
: "microphone-sensitivity-muted-symbolic") : "microphone-sensitivity-muted-symbolic")
} /> } />
</Gtk.Box> </Gtk.Box>
@@ -40,22 +43,9 @@ export const Status = () =>
<Gtk.Image class={"recording state"} iconName={"media-record-symbolic"} <Gtk.Image class={"recording state"} iconName={"media-record-symbolic"}
css={"margin-right: 6px;"} /> css={"margin-right: 6px;"} />
<Gtk.Label class={"rec-time"} label={createComputed([ <Gtk.Label class={"rec-time"} label={
createBinding(Recording.getDefault(), "recording"), createBinding(Recording.getDefault(), "recordingTime")
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.Box>
</Gtk.Revealer> </Gtk.Revealer>
<StatusIcons /> <StatusIcons />
@@ -99,7 +89,7 @@ function StatusIcons() {
: "bluetooth-symbolic" : "bluetooth-symbolic"
) : "bluetooth-disabled-symbolic" ) : "bluetooth-disabled-symbolic"
})} class={"bluetooth state"} visible={ })} class={"bluetooth state"} visible={
createBinding(AstalBluetooth.get_default(), "adapter").as(Boolean) createBinding(Bluetooth.getDefault(), "adapter").as(Boolean)
} }
/> />
+71 -35
View File
@@ -4,10 +4,13 @@ import { tr } from "../../../i18n/intl";
import { Windows } from "../../../windows"; import { Windows } from "../../../windows";
import { Notifications } from "../../../modules/notifications"; import { Notifications } from "../../../modules/notifications";
import { execApp } from "../../../modules/apps"; import { execApp } from "../../../modules/apps";
import { execAsync } from "ags/process";
import { createBinding, createComputed, For, With } from "ags"; import { createBinding, createComputed, For, With } from "ags";
import { Bluetooth } from "../../../modules/bluetooth";
import AstalNotifd from "gi://AstalNotifd"; import AstalNotifd from "gi://AstalNotifd";
import AstalBluetooth from "gi://AstalBluetooth"; import AstalBluetooth from "gi://AstalBluetooth";
import Adw from "gi://Adw?version=1";
export const BluetoothPage = new Page({ export const BluetoothPage = new Page({
@@ -15,27 +18,27 @@ export const BluetoothPage = new Page({
title: tr("control_center.pages.bluetooth.title"), title: tr("control_center.pages.bluetooth.title"),
spacing: 6, spacing: 6,
description: tr("control_center.pages.bluetooth.description"), description: tr("control_center.pages.bluetooth.description"),
headerButtons: [{ headerButtons: createBinding(Bluetooth.getDefault(), "adapter").as(adapter => adapter ? [{
icon: createBinding(AstalBluetooth.get_default().adapter, "discovering") icon: createBinding(adapter, "discovering")
.as(discovering => !discovering ? .as(discovering => !discovering ?
"arrow-circular-top-right-symbolic" "arrow-circular-top-right-symbolic"
: "media-playback-stop-symbolic" : "media-playback-stop-symbolic"
), ),
tooltipText: createBinding(AstalBluetooth.get_default().adapter, "discovering") tooltipText: createBinding(adapter, "discovering")
.as((discovering) => !discovering ? .as((discovering) => !discovering ?
tr("control_center.pages.bluetooth.start_discovering") tr("control_center.pages.bluetooth.start_discovering")
: tr("control_center.pages.bluetooth.stop_discovering")), : tr("control_center.pages.bluetooth.stop_discovering")),
actionClicked: () => { actionClicked: () => {
if(AstalBluetooth.get_default().adapter.discovering) { if(adapter.discovering) {
AstalBluetooth.get_default().adapter.stop_discovery(); adapter.stop_discovery();
return; return;
} }
AstalBluetooth.get_default().adapter.start_discovery(); adapter.start_discovery();
} }
}], }]: []),
actionClosed: () => AstalBluetooth.get_default().adapter?.discovering && actionClosed: () => Bluetooth.getDefault().adapter?.discovering &&
AstalBluetooth.get_default().adapter.stop_discovery(), Bluetooth.getDefault().adapter?.stop_discovery(),
bottomButtons: [{ bottomButtons: [{
title: tr("control_center.pages.more_settings"), title: tr("control_center.pages.more_settings"),
actionClicked: () => { actionClicked: () => {
@@ -43,26 +46,32 @@ export const BluetoothPage = new Page({
execApp("overskride", "[float; animation slide right]"); execApp("overskride", "[float; animation slide right]");
} }
}], }],
content: () => [ content: () => {
<Gtk.Box class={"adapters"} visible={createBinding(AstalBluetooth.get_default(), "adapters") const adapter = createBinding(Bluetooth.getDefault(), "adapter");
.as(adptrs => adptrs.length > 1) const adapters = createBinding(AstalBluetooth.get_default(), "adapters");
const devices = createBinding(AstalBluetooth.get_default(), "devices");
return [
<Gtk.Box class={"adapters"} visible={adapters.as(adptrs => adptrs.length > 1)
} spacing={2} orientation={Gtk.Orientation.VERTICAL}> } spacing={2} orientation={Gtk.Orientation.VERTICAL}>
<Gtk.Label class={"sub-header"} label={tr("control_center.pages.bluetooth.adapters")} <Gtk.Label class={"sub-header"} label={tr("control_center.pages.bluetooth.adapters")}
xalign={0} /> xalign={0} />
<With value={createBinding(AstalBluetooth.get_default(), "adapters").as(adpts => <With value={adapters.as(adpts => adpts.length > 1)}>
adpts.length > 1)}>
{(hasMoreAdapters: boolean) => hasMoreAdapters && {(hasMoreAdapters: boolean) => hasMoreAdapters &&
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={2}> <Gtk.Box orientation={Gtk.Orientation.VERTICAL} spacing={2}>
<For each={createBinding(AstalBluetooth.get_default(), "adapters")}> <For each={adapters}>
{(adapter: AstalBluetooth.Adapter) => { {(adapter: AstalBluetooth.Adapter) => {
const isSelected = createBinding(AstalBluetooth.get_default(), "adapter").as(a => const isSelected = createBinding(Bluetooth.getDefault(), "adapter").as(a =>
a.address === adapter.address); adapter.address === a?.address);
return <PageButton class={isSelected.as(is => is ? "selected" : "")} return <PageButton class={isSelected.as(is => is ? "selected" : "")}
title={adapter.alias ?? "Adapter"} icon={"bluetooth-active-symbolic"} title={adapter.alias ?? "Adapter"} icon={"bluetooth-active-symbolic"}
description={createBinding(adapter, "address")} description={createBinding(adapter, "address")}
actionClicked={() =>
adapter.address !== Bluetooth.getDefault().adapter?.address &&
selectAdapter(adapter)
}
endWidget={ endWidget={
<Gtk.Image iconName={"object-select-symbolic"} visible={isSelected} /> <Gtk.Image iconName={"object-select-symbolic"} visible={isSelected} />
} }
@@ -73,33 +82,43 @@ export const BluetoothPage = new Page({
} }
</With> </With>
</Gtk.Box>, </Gtk.Box>,
<Gtk.Box class={"connections"} orientation={Gtk.Orientation.VERTICAL} hexpand={true} <Gtk.Box class={"connections"} orientation={Gtk.Orientation.VERTICAL} hexpand
spacing={2}> spacing={2}>
<Gtk.Box class={"paired"} orientation={Gtk.Orientation.VERTICAL} spacing={4} <Gtk.Box class={"paired"} orientation={Gtk.Orientation.VERTICAL} spacing={4}
visible={createBinding(AstalBluetooth.get_default(), "devices").as(devs => visible={devices.as(devs => devs.filter(dev =>
devs.filter(dev => dev.paired || dev.connected || dev.trusted).length > 0)}> (dev.adapter as AstalBluetooth.Adapter).address === adapter.get()?.address &&
dev.paired || dev.connected || dev.trusted).length > 0)
}>
<Gtk.Label class={"sub-header"} label={tr("devices")} xalign={0} /> <Gtk.Label class={"sub-header"} label={tr("devices")} xalign={0} />
<For each={createBinding(AstalBluetooth.get_default(), "devices").as(devs => <For each={devices.as(devs => devs.filter(dev =>
devs.filter(dev => dev.paired || dev.connected || dev.trusted))}> (dev.adapter as AstalBluetooth.Adapter).address === adapter.get()?.address &&
dev.paired || dev.connected || dev.trusted))
}>
{(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />} {(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />}
</For> </For>
</Gtk.Box> </Gtk.Box>
<Gtk.Box class={"discovered"} orientation={Gtk.Orientation.VERTICAL} spacing={4} <Gtk.Box class={"discovered"} orientation={Gtk.Orientation.VERTICAL} spacing={4}
visible={createBinding(AstalBluetooth.get_default(), "devices").as(devs => visible={devices.as(devs => devs.filter(dev =>
devs.filter(dev => !dev.connected && !dev.paired && !dev.trusted).length > 0)}> (dev.adapter as AstalBluetooth.Adapter).address === adapter.get()?.address &&
!dev.connected && !dev.paired && !dev.trusted).length > 0)
}>
<Gtk.Label class={"sub-header"} label={tr("control_center.pages.bluetooth.new_devices")} <Gtk.Label class={"sub-header"} label={tr("control_center.pages.bluetooth.new_devices")}
xalign={0} /> xalign={0} />
<For each={createBinding(AstalBluetooth.get_default(), "devices").as(devs => <For each={devices.as(devs => devs.filter(dev =>
devs.filter(dev => !dev.connected && !dev.paired && !dev.trusted))}> (dev.adapter as AstalBluetooth.Adapter).address === adapter.get()?.address &&
!dev.connected && !dev.paired && !dev.trusted))
}>
{(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />} {(dev: AstalBluetooth.Device) => <DeviceWidget device={dev} />}
</For> </For>
</Gtk.Box> </Gtk.Box>
</Gtk.Box> </Gtk.Box>
] ];
}
}); });
function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget { function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget {
@@ -107,9 +126,6 @@ function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget
conn ? "selected" : "")} title={ conn ? "selected" : "")} title={
createBinding(device, "alias").as(alias => alias ?? "Unknown Device")} createBinding(device, "alias").as(alias => alias ?? "Unknown Device")}
icon={createBinding(device, "icon").as(ico => ico ?? "bluetooth-active-symbolic")} icon={createBinding(device, "icon").as(ico => ico ?? "bluetooth-active-symbolic")}
description={
createBinding(device, "connecting").as(connecting =>
connecting ? `${tr("connecting")}...` : "")}
tooltipText={ tooltipText={
createBinding(device, "connected").as(connected => createBinding(device, "connected").as(connected =>
!connected ? tr("connect") : "") !connected ? tr("connect") : "")
@@ -138,19 +154,24 @@ function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget
}) })
); );
}} }}
endWidget={<Gtk.Box visible={createComputed([ endWidget={<Gtk.Box spacing={6}>
<Adw.Spinner visible={createBinding(device, "connecting")} />
<Gtk.Box visible={createComputed([
createBinding(device, "batteryPercentage"), createBinding(device, "batteryPercentage"),
createBinding(device, "connected") createBinding(device, "connected")
]).as(([batt, connected]) => connected && (batt > -1)) ]).as(([batt, connected]) => connected && (batt > -1))
}> } spacing={4}>
<Gtk.Label halign={Gtk.Align.END} label={ <Gtk.Label halign={Gtk.Align.END} label={
createBinding(device, "batteryPercentage").as(batt => createBinding(device, "batteryPercentage").as(batt =>
`${Math.floor(batt * 100)}%`)} /> `${Math.floor(batt * 100)}%`)
} visible={createBinding(device, "connected")}
/>
<Gtk.Image iconName={ <Gtk.Image iconName={
createBinding(device, "batteryPercentage").as(batt => createBinding(device, "batteryPercentage").as(batt =>
`battery-level-${Math.floor(batt * 100)}-symbolic`) `battery-level-${Math.floor(batt * 100)}-symbolic`)
} css={"font-size: 16px; margin-left: 6px;"} /> } css={"font-size: 16px; margin-left: 6px;"} />
</Gtk.Box>
</Gtk.Box>} extraButtons={<With value={createComputed([ </Gtk.Box>} extraButtons={<With value={createComputed([
createBinding(device, "connected"), createBinding(device, "connected"),
createBinding(device, "trusted") createBinding(device, "trusted")
@@ -164,7 +185,7 @@ function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget
: "control_center.pages.bluetooth.unpair_device" : "control_center.pages.bluetooth.unpair_device"
)} onClicked={() => { )} onClicked={() => {
if(!connected) { if(!connected) {
AstalBluetooth.get_default().adapter?.remove_device(device); Bluetooth.getDefault().adapter?.remove_device(device);
return; return;
} }
@@ -182,3 +203,18 @@ function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget
</With>} </With>}
/> as Gtk.Widget; /> as Gtk.Widget;
} }
function selectAdapter(adapter: AstalBluetooth.Adapter): void {
AstalBluetooth.get_default().adapters.filter(ad => {
if(ad.alias !== adapter.alias)
return true;
ad.set_powered(true);
return false;
}).forEach(ad => ad.set_powered(false));
execAsync(`bluetoothctl select ${adapter.address}`).catch(e =>
console.error(`Bluetooth: Couldn't select adapter. Stderr: ${e}`));
Bluetooth.getDefault().adapter = adapter;
}
@@ -3,17 +3,21 @@ import AstalBluetooth from "gi://AstalBluetooth";
import { BluetoothPage } from "../pages/Bluetooth"; import { BluetoothPage } from "../pages/Bluetooth";
import { TilesPages } from "../Tiles"; import { TilesPages } from "../Tiles";
import { createBinding, createComputed } from "ags"; import { createBinding, createComputed } from "ags";
import { Bluetooth } from "../../../modules/bluetooth";
export const TileBluetooth = () => export const TileBluetooth = () =>
<Tile title={"Bluetooth"} visible={ <Tile title={"Bluetooth"} visible={
createBinding(AstalBluetooth.get_default(), "adapter").as(Boolean) createBinding(Bluetooth.getDefault(), "adapter").as(Boolean)
} description={createBinding(AstalBluetooth.get_default(), "isConnected").as((connected) => { } description={createBinding(AstalBluetooth.get_default(), "isConnected").as((connected) => {
const connectedDev = AstalBluetooth.get_default().devices.filter(dev => dev.connected)?.[0]; if(!connected) return "";
return connected && connectedDev ? connectedDev.get_alias() : ""
const connectedDevs = AstalBluetooth.get_default().devices.filter(dev => dev.connected);
const connectedDev = connectedDevs[connectedDevs.length - 1]; // last connected device is on display
return connectedDev ? connectedDev.get_alias() : ""
})} })}
onEnabled={() => AstalBluetooth.get_default().adapter?.set_powered(true)} onEnabled={() => Bluetooth.getDefault().adapter?.set_powered(true)}
onDisabled={() => AstalBluetooth.get_default().adapter?.set_powered(false)} onDisabled={() => Bluetooth.getDefault().adapter?.set_powered(false)}
onClicked={() => TilesPages?.toggle(BluetoothPage)} onClicked={() => TilesPages?.toggle(BluetoothPage)}
enableOnClicked hasArrow enableOnClicked hasArrow
state={createBinding(AstalBluetooth.get_default(), "isPowered")} state={createBinding(AstalBluetooth.get_default(), "isPowered")}
+51 -5
View File
@@ -1,16 +1,50 @@
import { Astal, Gtk } from "ags/gtk4"; import { Astal, Gtk } from "ags/gtk4";
import { createBinding, For } from "ags"; import { createBinding, createComputed, For } from "ags";
import { Notifications } from "../modules/notifications"; import { Notifications } from "../modules/notifications";
import { NotificationWidget } from "../widget/Notification"; import { NotificationWidget } from "../widget/Notification";
import { generalConfig } from "../app";
import AstalNotifd from "gi://AstalNotifd?version=0.1"; import AstalNotifd from "gi://AstalNotifd";
import Adw from "gi://Adw?version=1"; import Adw from "gi://Adw?version=1";
const size = 450; const size = 450;
export const FloatingNotifications = (mon: number) => export const FloatingNotifications = (mon: number) =>
<Astal.Window namespace={"floating-notifications"} monitor={mon} layer={Astal.Layer.OVERLAY} <Astal.Window namespace={"floating-notifications"} monitor={mon} layer={Astal.Layer.OVERLAY}
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT} exclusivity={Astal.Exclusivity.NORMAL} anchor={createComputed([
generalConfig.bindProperty("notifications.position_h", "string"),
generalConfig.bindProperty("notifications.position_v", "string")
]).as(([posH, posV]) => {
let horizontal: Astal.WindowAnchor = Astal.WindowAnchor.RIGHT,
vertical: Astal.WindowAnchor = Astal.WindowAnchor.TOP;
switch(posH) {
case "left":
horizontal = Astal.WindowAnchor.LEFT;
break;
case "center":
horizontal = Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT;
break;
case "right":
horizontal = Astal.WindowAnchor.RIGHT;
break;
}
switch(posV) {
case "top":
vertical = Astal.WindowAnchor.TOP;
break;
case "center":
vertical = Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM;
break;
case "bottom":
vertical = Astal.WindowAnchor.BOTTOM;
break;
}
return horizontal | vertical;
})} exclusivity={Astal.Exclusivity.NORMAL}
resizable={false} widthRequest={450}> resizable={false} widthRequest={450}>
<Gtk.Box class={"floating-notifications-container"} spacing={12} <Gtk.Box class={"floating-notifications-container"} spacing={12}
@@ -18,8 +52,17 @@ export const FloatingNotifications = (mon: number) =>
<For each={createBinding(Notifications.getDefault(), "notifications")}> <For each={createBinding(Notifications.getDefault(), "notifications")}>
{(notif: AstalNotifd.Notification) => {(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}> <Adw.Clamp maximumSize={size}>
<Gtk.Box class={"float-notification"} widthRequest={size} vexpand={false}> <Gtk.Box class={"float-notification"} widthRequest={size} vexpand={false}
valign={Gtk.Align.CENTER} halign={Gtk.Align.CENTER}>
<NotificationWidget notification={notif} showTime={false} <NotificationWidget notification={notif} showTime={false}
actionClose={() => Notifications.getDefault().removeNotification(notif)} actionClose={() => Notifications.getDefault().removeNotification(notif)}
@@ -33,7 +76,10 @@ export const FloatingNotifications = (mon: number) =>
}} }}
/> />
</Gtk.Box> </Gtk.Box>
</Adw.Clamp> </Adw.Clamp> as Gtk.Widget
}>
</Gtk.StackPage>
</Gtk.Stack>
} }
</For> </For>
</Gtk.Box> </Gtk.Box>