feat(ags): add wifi widget
All checks were successful
Discord / discord commits (push) Has been skipped

This commit is contained in:
matt1432 2024-12-19 17:46:57 -05:00
parent 8f82b1885a
commit 6bc32a8d8e
11 changed files with 388 additions and 68 deletions

View file

@ -12,6 +12,7 @@ import Calendar from '../widgets/date/wim';
import Clipboard from '../widgets/clipboard/main'; import Clipboard from '../widgets/clipboard/main';
import Corners from '../widgets/corners/main'; import Corners from '../widgets/corners/main';
import IconBrowser from '../widgets/icon-browser/main'; import IconBrowser from '../widgets/icon-browser/main';
import NetworkWindow from '../widgets/network/wim';
import { NotifPopups, NotifCenter } from '../widgets/notifs/wim'; import { NotifPopups, NotifCenter } from '../widgets/notifs/wim';
import OnScreenDisplay from '../widgets/on-screen-display/main'; import OnScreenDisplay from '../widgets/on-screen-display/main';
import OnScreenKeyboard from '../widgets/on-screen-keyboard/main'; import OnScreenKeyboard from '../widgets/on-screen-keyboard/main';
@ -100,6 +101,7 @@ export default () => {
Clipboard(); Clipboard();
Corners(); Corners();
IconBrowser(); IconBrowser();
NetworkWindow();
NotifPopups(); NotifPopups();
NotifCenter(); NotifCenter();
OnScreenDisplay(); OnScreenDisplay();

View file

@ -1,4 +1,4 @@
import { idle } from 'astal'; import { idle, subprocess } from 'astal';
import { App, Gdk, Gtk } from 'astal/gtk3'; import { App, Gdk, Gtk } from 'astal/gtk3';
import AstalHyprland from 'gi://AstalHyprland'; import AstalHyprland from 'gi://AstalHyprland';
@ -201,3 +201,68 @@ export const perMonitor = (window: (monitor: Gdk.Monitor) => Gtk.Widget) => idle
windows.delete(monitor); windows.delete(monitor);
}); });
}); });
interface NotifyAction {
id: string
label: string
callback: () => void
}
interface NotifySendProps {
actions?: NotifyAction[]
appName?: string
body?: string
category?: string
hint?: string
iconName: string
replaceId?: number
title: string
urgency?: 'low' | 'normal' | 'critical'
}
const escapeShellArg = (arg: string): string => `'${arg?.replace(/'/g, '\'\\\'\'')}'`;
export const notifySend = ({
actions = [],
appName,
body,
category,
hint,
iconName,
replaceId,
title,
urgency = 'normal',
}: NotifySendProps) => new Promise<number>((resolve) => {
let printedId = false;
const cmd = [
'notify-send',
'--print-id',
`--icon=${escapeShellArg(iconName)}`,
escapeShellArg(title),
escapeShellArg(body ?? ''),
// Optional params
appName ? `--app-name=${escapeShellArg(appName)}` : '',
category ? `--category=${escapeShellArg(category)}` : '',
hint ? `--hint=${escapeShellArg(hint)}` : '',
replaceId ? `--replace-id=${replaceId.toString()}` : '',
`--urgency=${urgency}`,
].concat(
actions.map(({ id, label }) => `--action=${escapeShellArg(id)}=${escapeShellArg(label)}`),
).join(' ');
subprocess(
cmd,
(out) => {
if (!printedId) {
resolve(parseInt(out));
printedId = true;
}
else {
actions.find((action) => action.id === out)?.callback();
}
},
(err) => {
console.error(`[Notify] ${err}`);
},
);
});

View file

@ -1,76 +1,12 @@
import { execAsync, subprocess } from 'astal'; import { execAsync, subprocess } from 'astal';
import GObject, { register } from 'astal/gobject'; import GObject, { register } from 'astal/gobject';
/* Types */ import { notifySend } from '../lib';
interface NotifyAction {
id: string
label: string
callback: () => void
}
interface NotifySendProps {
actions?: NotifyAction[]
appName?: string
body?: string
category?: string
hint?: string
iconName: string
replaceId?: number
title: string
urgency?: 'low' | 'normal' | 'critical'
}
const APP_NAME = 'gpu-screen-recorder'; const APP_NAME = 'gpu-screen-recorder';
const ICON_NAME = 'nvidia'; const ICON_NAME = 'nvidia';
const escapeShellArg = (arg: string): string => `'${arg.replace(/'/g, '\'\\\'\'')}'`;
const notifySend = ({
actions = [],
appName,
body,
category,
hint,
iconName,
replaceId,
title,
urgency = 'normal',
}: NotifySendProps) => new Promise<number>((resolve) => {
let printedId = false;
const cmd = [
'notify-send',
'--print-id',
`--icon=${escapeShellArg(iconName)}`,
escapeShellArg(title),
escapeShellArg(body ?? ''),
// Optional params
appName ? `--app-name=${escapeShellArg(appName)}` : '',
category ? `--category=${escapeShellArg(category)}` : '',
hint ? `--hint=${escapeShellArg(hint)}` : '',
replaceId ? `--replace-id=${replaceId.toString()}` : '',
`--urgency=${urgency}`,
].concat(
actions.map(({ id, label }) => `--action=${escapeShellArg(id)}=${escapeShellArg(label)}`),
).join(' ');
subprocess(
cmd,
(out) => {
if (!printedId) {
resolve(parseInt(out));
printedId = true;
}
else {
actions.find((action) => action.id === out)?.callback();
}
},
(err) => {
console.error(`[Notify] ${err}`);
},
);
});
@register() @register()
export default class GpuScreenRecorder extends GObject.Object { export default class GpuScreenRecorder extends GObject.Object {
private _lastNotifID: number | undefined; private _lastNotifID: number | undefined;

View file

@ -8,6 +8,7 @@
@use '../widgets/date'; @use '../widgets/date';
@use '../widgets/icon-browser'; @use '../widgets/icon-browser';
@use '../widgets/misc'; @use '../widgets/misc';
@use '../widgets/network';
@use '../widgets/notifs'; @use '../widgets/notifs';
@use '../widgets/on-screen-display'; @use '../widgets/on-screen-display';
@use '../widgets/on-screen-keyboard'; @use '../widgets/on-screen-keyboard';

View file

@ -1,8 +1,11 @@
import { bind, Variable } from 'astal'; import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3'; import { App, Gtk } from 'astal/gtk3';
import AstalNetwork from 'gi://AstalNetwork'; import AstalNetwork from 'gi://AstalNetwork';
/* Types */
import PopupWindow from '../../misc/popup-window';
export default () => { export default () => {
const network = AstalNetwork.get_default(); const network = AstalNetwork.get_default();
@ -16,6 +19,17 @@ export default () => {
onHover={() => Hovered.set(true)} onHover={() => Hovered.set(true)}
onHoverLost={() => Hovered.set(false)} onHoverLost={() => Hovered.set(false)}
onButtonReleaseEvent={(self) => {
const win = App.get_window('win-network') as PopupWindow;
win.set_x_pos(
self.get_allocation(),
'right',
);
win.visible = !win.visible;
}}
> >
{bind(network, 'primary').as((primary) => { {bind(network, 'primary').as((primary) => {
if (primary === AstalNetwork.Primary.UNKNOWN) { if (primary === AstalNetwork.Primary.UNKNOWN) {

View file

@ -1,7 +1,7 @@
@use 'sass:color'; @use 'sass:color';
@use '../../style/colors'; @use '../../style/colors';
.bluetooth { .bluetooth.widget {
margin-top: 0; margin-top: 0;
* { * {

View file

@ -0,0 +1,32 @@
@use 'sass:color';
@use '../../style/colors';
.network.widget {
margin-top: 0;
* {
font-size: 20px;
}
row {
all: unset;
}
.toggle-button {
padding: 4px;
min-width: 30px;
min-height: 30px;
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
box-shadow: 2px 1px 2px colors.$accent-color;
margin: 4px 4px 4px 4px;
background-color: colors.$window_bg_color;
&.active {
box-shadow: 0 0 0 white;
margin: 6px 4px 2px 4px;
background-color: color.adjust(colors.$window_bg_color, $lightness: -3%);
}
}
}

View file

@ -0,0 +1,137 @@
import { bind, execAsync } from 'astal';
import { Gtk, Widget } from 'astal/gtk3';
import { register } from 'astal/gobject';
import AstalNetwork from 'gi://AstalNetwork';
import Separator from '../misc/separator';
import { notifySend } from '../../lib';
const apCommand = (ap: AstalNetwork.AccessPoint, cmd: string[]): void => {
execAsync([
'nmcli',
...cmd,
ap.get_ssid()!,
]).catch((e) => notifySend({
title: 'Network',
iconName: ap.iconName,
body: (e as Error).message,
actions: [
{
id: 'open',
label: 'Open network manager',
callback: () =>
execAsync('nm-connection-editor'),
},
],
})).catch((e) => console.error(e));
};
const apConnect = (ap: AstalNetwork.AccessPoint): void => {
execAsync(['nmcli', 'connection', 'show', ap.get_ssid()!])
.catch(() => apCommand(ap, ['device', 'wifi', 'connect']))
.then(() => apCommand(ap, ['connection', 'up']));
};
const apDisconnect = (ap: AstalNetwork.AccessPoint): void => {
apCommand(ap, ['connection', 'down']);
};
@register()
export default class AccessPointWidget extends Widget.Box {
readonly aps: AstalNetwork.AccessPoint[];
getStrongest() {
return this.aps.sort((apA, apB) => apB.get_strength() - apA.get_strength())[0];
}
constructor({ aps }: { aps: AstalNetwork.AccessPoint[] }) {
const wifi = AstalNetwork.get_default().get_wifi();
if (!wifi) {
throw new Error('Could not find wifi device.');
}
const rev = (
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
>
<box vertical halign={Gtk.Align.FILL} hexpand>
<Separator size={8} vertical />
<centerbox>
<label
label="Connected"
valign={Gtk.Align.CENTER}
halign={Gtk.Align.START}
/>
<box />
<switch
cursor="pointer"
valign={Gtk.Align.CENTER}
halign={Gtk.Align.END}
state={bind(wifi, 'activeAccessPoint')
.as((activeAp) => aps.includes(activeAp))}
onButtonReleaseEvent={(self) => {
if (self.state) {
apDisconnect(this.getStrongest());
}
else {
apConnect(this.getStrongest());
}
}}
/>
</centerbox>
<Separator size={8} vertical />
</box>
</revealer>
) as Widget.Revealer;
const button = (
<button
cursor="pointer"
onButtonReleaseEvent={() => {
rev.revealChild = !rev.revealChild;
}}
>
<box>
<icon
icon="check-active-symbolic"
css={bind(wifi, 'activeAccessPoint').as((activeAp) => aps.includes(activeAp) ?
'' :
'opacity: 0;')}
/>
<Separator size={8} />
<icon
icon={bind(aps[0], 'iconName')}
/>
<Separator size={8} />
<label label={aps[0].get_ssid()!} />
</box>
</button>
);
super({
vertical: true,
children: [
button,
rev,
(<Separator size={8} vertical />),
],
});
this.aps = aps;
};
};

View file

@ -0,0 +1,117 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalNetwork from 'gi://AstalNetwork';
import { ToggleButton } from '../misc/subclasses';
import Separator from '../misc/separator';
import AccessPointWidget from './access-point';
export default () => {
const wifi = AstalNetwork.get_default().get_wifi();
if (!wifi) {
throw new Error('Could not find wifi device.');
}
const IsRefreshing = Variable<boolean>(false);
const AccessPoints = Variable<AstalNetwork.AccessPoint[]>(wifi.get_access_points());
wifi.connect('notify::access-points', () => {
if (IsRefreshing.get()) {
AccessPoints.set(wifi.get_access_points());
}
});
IsRefreshing.subscribe(() => {
if (IsRefreshing.get()) {
AccessPoints.set(wifi.get_access_points());
}
});
const apList = (
<scrollable
className="list"
css="min-height: 300px;"
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
>
<box vertical>
{bind(AccessPoints).as(() => {
const joined = new Map<string, AstalNetwork.AccessPoint[]>();
AccessPoints.get()
.filter((ap) => ap.get_ssid())
.sort((apA, apB) => {
const sort = apB.get_strength() - apA.get_strength();
return sort !== 0 ?
sort :
apA.get_ssid()!.localeCompare(apB.get_ssid()!);
})
.forEach((ap) => {
const arr = joined.get(ap.get_ssid()!);
if (arr) {
arr.push(ap);
}
else {
joined.set(ap.get_ssid()!, [ap]);
}
});
return [...joined.values()].map((aps) => <AccessPointWidget aps={aps} />);
})}
</box>
</scrollable>
);
return (
<box
className="network widget"
vertical
>
<centerbox homogeneous>
<switch
cursor="pointer"
valign={Gtk.Align.CENTER}
halign={Gtk.Align.START}
active={bind(wifi, 'enabled')}
setup={(self) => {
self.connect('notify::active', () => {
wifi.set_enabled(self.active);
});
}}
/>
<box />
<ToggleButton
cursor="pointer"
halign={Gtk.Align.END}
className="toggle-button"
sensitive={bind(wifi, 'enabled')}
active={bind(IsRefreshing)}
onToggled={(self) => {
self.toggleClassName('active', self.active);
IsRefreshing.set(self.active);
}}
>
<icon icon="emblem-synchronizing-symbolic" css="font-size: 30px;" />
</ToggleButton>
</centerbox>
<Separator size={8} vertical />
{apList}
</box>
);
};

View file

@ -0,0 +1,15 @@
import { Astal } from 'astal/gtk3';
import PopupWindow from '../misc/popup-window';
import NetworkWidget from './main';
export default () => (
<PopupWindow
name="network"
anchor={Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.TOP}
>
<NetworkWidget />
</PopupWindow>
);

View file

@ -74,6 +74,7 @@ in {
++ (attrValues { ++ (attrValues {
inherit inherit
(pkgs) (pkgs)
networkmanagerapplet
playerctl playerctl
wayfreeze wayfreeze
; ;