[Build] Start refactor
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import Audio from './modules/Audio/Audio';
|
||||
import {
|
||||
BatteryBox
|
||||
} from './modules/Battery';
|
||||
import Bluetooth from './modules/Bluetooth/Bluetooth';
|
||||
import Brightness from './modules/Brightness/Brightness';
|
||||
import {
|
||||
Gtk
|
||||
} from 'astal/gtk4';
|
||||
import Network from './modules/Networking/Network';
|
||||
import Player from './modules/Player/Player';
|
||||
import Power from './modules/Power';
|
||||
import SysTray from './modules/SysTray';
|
||||
import {
|
||||
exec
|
||||
} from 'astal';
|
||||
|
||||
const QuickActions = () => {
|
||||
const popover = new Gtk.Popover( {
|
||||
'cssClasses': [ 'quick-actions-wrapper' ]
|
||||
} );
|
||||
|
||||
popover.set_child( renderQuickActions() );
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
const renderQuickActions = () => {
|
||||
const user = exec( '/bin/sh -c whoami' );
|
||||
const profile = exec( '/bin/fish -c get-profile-picture' );
|
||||
const cwd = exec( 'pwd' );
|
||||
const um = Power.UserMenu();
|
||||
|
||||
return (
|
||||
<box visible cssClasses={[
|
||||
'quick-actions',
|
||||
'popover-box'
|
||||
]} vertical>
|
||||
<centerbox
|
||||
startWidget={
|
||||
<button
|
||||
onClicked={() => um.popup()}
|
||||
cssClasses={[ 'stealthy-button' ]}
|
||||
child={
|
||||
<box>
|
||||
{um}
|
||||
<Gtk.Frame
|
||||
cssClasses={[ 'avatar-icon' ]}
|
||||
child={
|
||||
<image
|
||||
file={
|
||||
profile !== ''
|
||||
? profile
|
||||
: cwd
|
||||
+ '/no-avatar-icon.jpg'
|
||||
}
|
||||
></image>
|
||||
}
|
||||
></Gtk.Frame>
|
||||
<label label={user}></label>
|
||||
</box>
|
||||
}
|
||||
></button>
|
||||
}
|
||||
endWidget={
|
||||
<box
|
||||
hexpand={false}
|
||||
>
|
||||
<BatteryBox></BatteryBox>
|
||||
<SysTray.SystemTray></SysTray.SystemTray>
|
||||
<Power.Power></Power.Power>
|
||||
</box>
|
||||
}
|
||||
></centerbox>
|
||||
<Gtk.Separator marginTop={10} marginBottom={20}></Gtk.Separator>
|
||||
<box>
|
||||
<Bluetooth.BluetoothModule></Bluetooth.BluetoothModule>
|
||||
<Network.Network></Network.Network>
|
||||
</box>
|
||||
<Gtk.Separator marginTop={10} marginBottom={10}></Gtk.Separator>
|
||||
<Brightness.BrightnessModule></Brightness.BrightnessModule>
|
||||
<Audio.AudioModule></Audio.AudioModule>
|
||||
<Player.PlayerModule></Player.PlayerModule>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Expose additional functions to be usable through CLI
|
||||
export default {
|
||||
QuickActions,
|
||||
};
|
||||
35
configs/userland/astal/components/QuickActions/dump
Normal file
35
configs/userland/astal/components/QuickActions/dump
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import Power from "./modules/Power";
|
||||
import Audio from "./modules/Audio/Audio";
|
||||
import Bluetooth from "./modules/Bluetooth/Bluetooth";
|
||||
import Brightness from "./modules/Brightness/Brightness";
|
||||
import Player from "./modules/Player/Player";
|
||||
import { BatteryBox } from "./modules/Battery";
|
||||
|
||||
const QuickActions = () => {
|
||||
const popover = new Gtk.Overlay( { cssClasses: [ 'quick-actions-wrapper' ] } );
|
||||
popover.set_child(renderQuickActions());
|
||||
return popover;
|
||||
};
|
||||
|
||||
const renderQuickActions = () => {
|
||||
return (
|
||||
<box visible cssClasses={["quick-actions", "popover-box"]} vertical setup={ self }>
|
||||
<box halign={Gtk.Align.END}>
|
||||
<BatteryBox></BatteryBox>
|
||||
<Power></Power>
|
||||
</box>
|
||||
<Bluetooth.BluetoothModule></Bluetooth.BluetoothModule>
|
||||
<Gtk.Separator marginTop={10} marginBottom={10}></Gtk.Separator>
|
||||
<Brightness.BrightnessModule></Brightness.BrightnessModule>
|
||||
<Audio.AudioModule></Audio.AudioModule>
|
||||
<Gtk.Separator marginTop={20} marginBottom={10}></Gtk.Separator>
|
||||
<Player.PlayerModule></Player.PlayerModule>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Expose additional functions to be usable through CLI
|
||||
export default {
|
||||
QuickActions,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.audio-box {
|
||||
min-width: 320px;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { bind } from "astal";
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import AstalWp from "gi://AstalWp";
|
||||
|
||||
const wp = AstalWp.get_default()!;
|
||||
|
||||
const AudioModule = () => {
|
||||
const setVolumeSpeaker = (volume: number) => {
|
||||
wp.defaultSpeaker.set_volume(volume / 100);
|
||||
};
|
||||
|
||||
const setVolumeMicrophone = (volume: number) => {
|
||||
wp.defaultMicrophone.set_volume(volume / 100);
|
||||
};
|
||||
|
||||
const speakerSelector = SinkSelectPopover(AstalWp.MediaClass.AUDIO_SINK);
|
||||
const micSelector = SinkSelectPopover(AstalWp.MediaClass.AUDIO_SOURCE);
|
||||
|
||||
return (
|
||||
<box cssClasses={["audio-box"]} vertical>
|
||||
<box hexpand vexpand>
|
||||
<button
|
||||
onClicked={() =>
|
||||
wp.defaultSpeaker.set_mute(
|
||||
!wp.defaultSpeaker.get_mute(),
|
||||
)
|
||||
}
|
||||
tooltipText={"Mute audio output"}
|
||||
child={
|
||||
<image
|
||||
iconName={bind(wp.defaultSpeaker, "volumeIcon")}
|
||||
marginEnd={3}
|
||||
></image>
|
||||
}
|
||||
></button>
|
||||
<label
|
||||
label={bind(wp.defaultSpeaker, "volume").as(
|
||||
v => Math.round(100 * v) + "%",
|
||||
)}
|
||||
></label>
|
||||
<slider
|
||||
value={bind(wp.defaultSpeaker, "volume").as(v => 100 * v)}
|
||||
max={100}
|
||||
min={0}
|
||||
step={1}
|
||||
hexpand
|
||||
vexpand
|
||||
onChangeValue={self => setVolumeSpeaker(self.value)}
|
||||
></slider>
|
||||
<button
|
||||
cssClasses={["sink-select-button"]}
|
||||
tooltipText={"Pick audio output"}
|
||||
child={
|
||||
<box>
|
||||
<image iconName={"speaker-symbolic"}></image>
|
||||
{speakerSelector}
|
||||
</box>
|
||||
}
|
||||
onClicked={() => speakerSelector.popup()}
|
||||
></button>
|
||||
</box>
|
||||
<box hexpand vexpand>
|
||||
<button
|
||||
onClicked={() =>
|
||||
wp.defaultMicrophone.set_mute(
|
||||
!wp.defaultMicrophone.get_mute(),
|
||||
)
|
||||
}
|
||||
tooltipText={"Mute audio input"}
|
||||
child={
|
||||
<image
|
||||
iconName={bind(wp.defaultMicrophone, "volumeIcon")}
|
||||
marginEnd={3}
|
||||
></image>
|
||||
}
|
||||
></button>
|
||||
<label
|
||||
label={bind(wp.defaultMicrophone, "volume").as(
|
||||
v => Math.round(100 * v) + "%",
|
||||
)}
|
||||
></label>
|
||||
<slider
|
||||
value={bind(wp.defaultMicrophone, "volume").as(
|
||||
v => 100 * v,
|
||||
)}
|
||||
max={100}
|
||||
min={0}
|
||||
step={1}
|
||||
hexpand
|
||||
vexpand
|
||||
onChangeValue={self => setVolumeMicrophone(self.value)}
|
||||
></slider>
|
||||
<button
|
||||
cssClasses={["sink-select-button"]}
|
||||
tooltipText={"Select audio input"}
|
||||
child={
|
||||
<box>
|
||||
<image iconName={"microphone"}></image>
|
||||
{micSelector}
|
||||
</box>
|
||||
}
|
||||
onClicked={() => micSelector.popup()}
|
||||
></button>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const SinkPicker = (type: AstalWp.MediaClass) => {
|
||||
const devices = bind(wp, "nodes");
|
||||
wp.connect("ready", () => {
|
||||
const dev = wp.get_nodes()!
|
||||
for (let i = 0; i < dev.length; i++) {
|
||||
const d = dev[i];
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box vertical>
|
||||
<label
|
||||
label={`Available Audio ${type === AstalWp.MediaClass.AUDIO_SINK ? "Output" : type === AstalWp.MediaClass.AUDIO_SOURCE ? "Input" : ""} Devices`}
|
||||
cssClasses={[ 'title-2' ]}
|
||||
></label>
|
||||
<Gtk.Separator marginBottom={5} marginTop={3}></Gtk.Separator>
|
||||
<box vertical cssClasses={["sink-picker"]}>
|
||||
{devices.as(d => {
|
||||
return d.map(device => {
|
||||
if (device.get_media_class() !== type) {
|
||||
return <box cssClasses={[ 'empty' ]}></box>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
cssClasses={bind(device, "id").as(id => {
|
||||
if (
|
||||
id ===
|
||||
(type ===
|
||||
AstalWp.MediaClass.AUDIO_SINK
|
||||
? wp.defaultSpeaker.id
|
||||
: type ===
|
||||
AstalWp.MediaClass
|
||||
.AUDIO_SOURCE
|
||||
? wp.defaultMicrophone.id
|
||||
: "")
|
||||
) {
|
||||
return [
|
||||
"sink-option",
|
||||
"currently-selected-sink-option",
|
||||
];
|
||||
} else {
|
||||
return ["sink-option"];
|
||||
}
|
||||
})}
|
||||
child={
|
||||
<box halign={Gtk.Align.START}>
|
||||
<image
|
||||
iconName={bind(device, "icon").as(
|
||||
icon => icon,
|
||||
)}
|
||||
marginEnd={3}
|
||||
></image>
|
||||
<label
|
||||
label={bind(
|
||||
device,
|
||||
"description",
|
||||
).as(t => t ?? "")}
|
||||
></label>
|
||||
</box>
|
||||
}
|
||||
onClicked={() => {
|
||||
device.set_is_default(true);
|
||||
}}
|
||||
></button>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const SinkSelectPopover = (type: AstalWp.MediaClass) => {
|
||||
const popover = new Gtk.Popover();
|
||||
|
||||
popover.set_child(SinkPicker(type));
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
export default {
|
||||
AudioModule,
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
{devices.as(d => {
|
||||
return d.map(device => {
|
||||
if (device.get_media_class() !== type) {
|
||||
return <box cssClasses={[ 'empty' ]}></box>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
cssClasses={bind(device, "id").as(id => {
|
||||
if (
|
||||
id ===
|
||||
(type ===
|
||||
AstalWp.MediaClass.AUDIO_SPEAKER
|
||||
? wp.defaultSpeaker.id
|
||||
: type ===
|
||||
AstalWp.MediaClass
|
||||
.AUDIO_MICROPHONE
|
||||
? wp.defaultMicrophone.id
|
||||
: "")
|
||||
) {
|
||||
return [
|
||||
"sink-option",
|
||||
"currently-selected-sink-option",
|
||||
];
|
||||
} else {
|
||||
return ["sink-option"];
|
||||
}
|
||||
})}
|
||||
child={
|
||||
<box halign={Gtk.Align.START}>
|
||||
<image
|
||||
iconName={bind(device, "icon").as(
|
||||
icon => icon,
|
||||
)}
|
||||
marginEnd={3}
|
||||
></image>
|
||||
<label
|
||||
label={bind(
|
||||
device,
|
||||
"description",
|
||||
).as(t => t ?? "")}
|
||||
></label>
|
||||
</box>
|
||||
}
|
||||
onClicked={() => {
|
||||
device.set_is_default(true);
|
||||
}}
|
||||
></button>
|
||||
);
|
||||
});
|
||||
})}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { bind } from "astal";
|
||||
import Battery from "gi://AstalBattery";
|
||||
|
||||
export const BatteryBox = () => {
|
||||
const battery = Battery.get_default();
|
||||
const batteryEnergy = (energyRate: number) => {
|
||||
return energyRate > 0.1 ? `${Math.round(energyRate * 10) / 10} W ` : "";
|
||||
};
|
||||
return (
|
||||
<box
|
||||
cssClasses={["battery-info"]}
|
||||
visible={bind(battery, "isBattery")}
|
||||
hexpand={false}
|
||||
vexpand={false}
|
||||
>
|
||||
<image
|
||||
iconName={bind(battery, "batteryIconName")}
|
||||
tooltipText={bind(battery, "energyRate").as(er =>
|
||||
batteryEnergy(er),
|
||||
)}
|
||||
/>
|
||||
<label
|
||||
label={bind(battery, "percentage").as(
|
||||
p => ` ${Math.round(p * 100)}%`,
|
||||
)}
|
||||
marginEnd={3}
|
||||
/>
|
||||
<label
|
||||
cssClasses={["battery-time"]}
|
||||
visible={bind(battery, "charging").as(c => !c)}
|
||||
label={bind(battery, "timeToEmpty").as(t => `(${toTime(t)})`)}
|
||||
tooltipText={bind(battery, 'energyRate').as(er => `Time to empty. Power usage: ${batteryEnergy(er)}`)}
|
||||
/>
|
||||
<label
|
||||
cssClasses={["battery-time"]}
|
||||
visible={bind(battery, "charging")}
|
||||
label={bind(battery, "timeToFull").as(t => `(${toTime(t)})`)}
|
||||
tooltipText={bind(battery, 'energyRate').as(er => `Time to full. Charge rate: ${batteryEnergy(er)}`)}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = MINUTE * 60;
|
||||
const toTime = (time: number) => {
|
||||
if (!time) return "Waiting on BIOS"
|
||||
if (time > 24 * HOUR) return "24h+";
|
||||
|
||||
const hours = Math.round(time / HOUR);
|
||||
const minutes = Math.round((time - hours * HOUR) / MINUTE);
|
||||
|
||||
const hoursDisplay = hours > 0 ? `${hours}h` : "";
|
||||
const minutesDisplay = minutes > 0 ? `${minutes}m` : "";
|
||||
|
||||
return `${hoursDisplay}${minutesDisplay}`;
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import { bind, interval, readFile, timeout, writeFile } from "astal";
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import AstalBluetooth from "gi://AstalBluetooth";
|
||||
import BTDevice from "./Device";
|
||||
const ALIGN = Gtk.Align;
|
||||
|
||||
const bt = AstalBluetooth.get_default();
|
||||
|
||||
const BluetoothModule = () => {
|
||||
const picker = BluetoothPicker();
|
||||
|
||||
const openBTPicker = () => {
|
||||
try {
|
||||
bt.adapter.start_discovery();
|
||||
} catch (e) {
|
||||
printerr(e);
|
||||
}
|
||||
picker.popup();
|
||||
};
|
||||
|
||||
return (
|
||||
<box>
|
||||
<button
|
||||
cssClasses={bind(bt, "isPowered").as(powered =>
|
||||
powered
|
||||
? ["toggle-button", "toggle-on"]
|
||||
: ["toggle-button"],
|
||||
)}
|
||||
onClicked={() => {
|
||||
try {
|
||||
bt.adapter.set_powered(!bt.adapter.get_powered())
|
||||
} catch (_) { }
|
||||
}}
|
||||
child={
|
||||
<box vertical>
|
||||
<label
|
||||
cssClasses={["title-2"]}
|
||||
label={"Bluetooth"}
|
||||
halign={ALIGN.CENTER}
|
||||
valign={ALIGN.CENTER}
|
||||
></label>
|
||||
<box halign={ALIGN.CENTER} valign={ALIGN.CENTER}>
|
||||
<label
|
||||
visible={bind(bt, "isPowered").as(
|
||||
p => !p,
|
||||
)}
|
||||
label="Disabled"
|
||||
></label>
|
||||
<label
|
||||
visible={bind(bt, "isPowered")}
|
||||
label={bind(bt, "devices").as(devices => {
|
||||
let count = 0;
|
||||
devices.forEach(device => {
|
||||
if (device.connected) {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
return `On (${count} ${count === 1 ? "client" : "clients"} connected)`;
|
||||
})}
|
||||
></label>
|
||||
</box>
|
||||
<label></label>
|
||||
</box>
|
||||
}
|
||||
></button>
|
||||
<button
|
||||
cssClasses={["actions-button"]}
|
||||
visible={bind(bt, "isPowered")}
|
||||
child={
|
||||
<box>
|
||||
<image iconName={"arrow-right-symbolic"}></image>
|
||||
{picker}
|
||||
</box>
|
||||
}
|
||||
tooltipText={"View available devices"}
|
||||
onClicked={() => openBTPicker()}
|
||||
></button>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const BluetoothPickerList = () => {
|
||||
let btEnableState = false;
|
||||
|
||||
try {
|
||||
btEnableState = readFile("./btconf") === "true" ? true : false;
|
||||
} catch (_) { }
|
||||
|
||||
if (bt.get_adapter()) {
|
||||
print('Setting BT state to ' + btEnableState);
|
||||
bt.adapter.set_powered(btEnableState);
|
||||
} else {
|
||||
timeout(5000, () => {
|
||||
if (bt.get_adapter()) {
|
||||
print('Setting BT state to ' + btEnableState);
|
||||
bt.adapter.set_powered(btEnableState);
|
||||
} else {
|
||||
timeout(5000, () => {
|
||||
try {
|
||||
bt.adapter.set_powered(btEnableState);
|
||||
} catch (_) { }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateState = () => {
|
||||
btEnableState = !btEnableState;
|
||||
writeFile("./btconf", "" + btEnableState);
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
vertical
|
||||
onDestroy={() => {
|
||||
try {
|
||||
bt.adapter.stop_discovery()
|
||||
} catch (_) { }
|
||||
}}
|
||||
cssClasses={["popover-box"]}
|
||||
>
|
||||
<label cssClasses={["title"]} label={"Bluetooth"}></label>
|
||||
<Gtk.Separator marginTop={3} marginBottom={5}></Gtk.Separator>
|
||||
<centerbox
|
||||
startWidget={<label label={"Turn on at startup"}></label>}
|
||||
endWidget={
|
||||
<switch
|
||||
valign={ALIGN.END}
|
||||
halign={ALIGN.END}
|
||||
active={btEnableState}
|
||||
onButtonPressed={() => updateState()}
|
||||
></switch>
|
||||
}
|
||||
></centerbox>
|
||||
<label
|
||||
marginTop={10}
|
||||
label={"Connected & Trusted devices"}
|
||||
cssClasses={["title-2"]}
|
||||
></label>
|
||||
<Gtk.Separator marginTop={3} marginBottom={5}></Gtk.Separator>
|
||||
<box vertical cssClasses={["devices-list"]}>
|
||||
{bind(bt, "devices").as(devices => {
|
||||
return devices
|
||||
.filter(device => {
|
||||
if (device.get_connected() || device.get_paired()) {
|
||||
return device;
|
||||
}
|
||||
})
|
||||
.map(device => {
|
||||
return <BTDevice device={device}></BTDevice>;
|
||||
});
|
||||
})}
|
||||
</box>
|
||||
<label
|
||||
visible={bind(bt, "devices").as(devices => {
|
||||
return (
|
||||
devices.filter(device => {
|
||||
if (device.get_connected() || device.get_paired()) {
|
||||
return device;
|
||||
}
|
||||
}).length === 0
|
||||
);
|
||||
})}
|
||||
label={"No connected / trusted devices"}
|
||||
cssClasses={["bt-no-found", "bt-conn-list"]}
|
||||
></label>
|
||||
<label
|
||||
label={"Discovered bluetooth devices"}
|
||||
cssClasses={["title-2"]}
|
||||
></label>
|
||||
<Gtk.Separator marginBottom={5} marginTop={3}></Gtk.Separator>
|
||||
<box vertical>
|
||||
{bind(bt, "devices").as(devices => {
|
||||
return devices
|
||||
.filter(data => {
|
||||
if (!data.get_connected() && !data.get_paired()) {
|
||||
return data;
|
||||
}
|
||||
})
|
||||
.map(device => {
|
||||
return <BTDevice device={device}></BTDevice>;
|
||||
});
|
||||
})}
|
||||
</box>
|
||||
<label
|
||||
visible={bind(bt, "devices").as(devices => {
|
||||
return (
|
||||
devices.filter(device => {
|
||||
if (
|
||||
!device.get_connected() &&
|
||||
!device.get_paired()
|
||||
) {
|
||||
return device;
|
||||
}
|
||||
}).length === 0
|
||||
);
|
||||
})}
|
||||
label={"No discovered devices"}
|
||||
cssClasses={["bt-no-found"]}
|
||||
></label>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const BluetoothPicker = () => {
|
||||
const popover = new Gtk.Popover();
|
||||
|
||||
popover.set_child(BluetoothPickerList());
|
||||
popover.connect("closed", () => {
|
||||
try {
|
||||
bt.adapter.stop_discovery();
|
||||
} catch (e) {
|
||||
printerr(e);
|
||||
}
|
||||
});
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
export default {
|
||||
BluetoothModule,
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { bind } from "astal";
|
||||
import AstalBluetooth from "gi://AstalBluetooth";
|
||||
|
||||
const BTDevice = ({ device }: { device: AstalBluetooth.Device }) => {
|
||||
return (
|
||||
<button
|
||||
visible={bind(device, "name").as(n => n !== null)}
|
||||
child={
|
||||
<centerbox
|
||||
startWidget={
|
||||
<box>
|
||||
<image
|
||||
iconName={"chronometer-reset"}
|
||||
tooltipText={"Device is currently connecting"}
|
||||
visible={bind(device, "connecting")}
|
||||
></image>
|
||||
<image
|
||||
iconName={bind(device, "icon")}
|
||||
marginEnd={3}
|
||||
></image>
|
||||
</box>
|
||||
}
|
||||
centerWidget={
|
||||
<label
|
||||
label={bind(device, "name").as(n => n ?? "No name")}
|
||||
marginEnd={5}
|
||||
></label>
|
||||
}
|
||||
endWidget={
|
||||
<box>
|
||||
<label
|
||||
label={bind(device, "batteryPercentage").as(
|
||||
bat => (bat >= 0 ? bat + "%" : "?%"),
|
||||
)}
|
||||
tooltipText={"Device's battery percentage"}
|
||||
marginEnd={3}
|
||||
></label>
|
||||
<image
|
||||
iconName={bind(device, "paired").as(v =>
|
||||
v ? "network-bluetooth-activated-symbolic" : "bluetooth-disconnected-symbolic",
|
||||
)}
|
||||
></image>
|
||||
<button tooltipText={"Device trusted status"} child={
|
||||
<image
|
||||
iconName={bind(device, "trusted").as(v =>
|
||||
v ? "checkbox" : "window-close-symbolic",
|
||||
)}
|
||||
></image>
|
||||
} onClicked={() => device.set_trusted( !device.get_trusted() )}
|
||||
cssClasses={[ 'button-no-margin' ]}
|
||||
></button>
|
||||
</box>
|
||||
}
|
||||
></centerbox>
|
||||
}
|
||||
onClicked={() => {
|
||||
connectOrPair( device );
|
||||
}}
|
||||
></button>
|
||||
);
|
||||
};
|
||||
|
||||
const connectOrPair = (device: AstalBluetooth.Device) => {
|
||||
if ( device.get_paired() ) {
|
||||
device.connect_device(() => { });
|
||||
// Show failed message if tried to connect and failed
|
||||
} else {
|
||||
device.pair();
|
||||
}
|
||||
};
|
||||
|
||||
export default BTDevice;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { bind } from "astal";
|
||||
import Brightness from "../../../../util/brightness";
|
||||
|
||||
const brightness = Brightness.get_default();
|
||||
|
||||
const BrightnessModule = () => {
|
||||
const setBrightness = (value: number) => {
|
||||
brightness.screen = value;
|
||||
}
|
||||
|
||||
return (
|
||||
<box visible={bind(brightness, 'screenAvailable')}>
|
||||
<image iconName={"brightness-high-symbolic"}></image>
|
||||
<label label={bind(brightness, "screen").as(b => `${Math.round(100 * b)}%`)}></label>
|
||||
<slider
|
||||
value={Math.round( brightness.screen * 100) / 100}
|
||||
hexpand
|
||||
max={1}
|
||||
min={0.01}
|
||||
step={0.05}
|
||||
vexpand
|
||||
onChangeValue={self => setBrightness(self.value)}
|
||||
></slider>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
BrightnessModule
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
import { execAsync, bind } from "astal";
|
||||
import Network from "gi://AstalNetwork";
|
||||
import { App, Gtk } from "astal/gtk4";
|
||||
import { NetworkItem } from "./modules/NetworkItem";
|
||||
import { PasswordDialog } from "./modules/PasswordDialog";
|
||||
import {
|
||||
availableNetworks,
|
||||
savedNetworks,
|
||||
activeNetwork,
|
||||
showPasswordDialog,
|
||||
scanNetworks,
|
||||
getSavedNetworks,
|
||||
disconnectNetwork,
|
||||
forgetNetwork,
|
||||
isExpanded,
|
||||
refreshIntervalId,
|
||||
} from "./networkinghelper";
|
||||
|
||||
// Main WiFi Box component
|
||||
export const WiFiBox = () => {
|
||||
const network = Network.get_default();
|
||||
|
||||
// Initial scan when component is first used
|
||||
setTimeout(() => {
|
||||
scanNetworks();
|
||||
getSavedNetworks();
|
||||
}, 100);
|
||||
|
||||
return (
|
||||
<box vertical cssClasses={["wifi-menu", "toggle"]}>
|
||||
{/* WiFi Toggle Header */}
|
||||
<box cssClasses={["toggle", "wifi-toggle"]}>
|
||||
<button
|
||||
onClicked={() => {
|
||||
if (network.wifi.enabled) {
|
||||
network.wifi.set_enabled(false);
|
||||
} else network.wifi.set_enabled(true);
|
||||
}}
|
||||
cssClasses={bind(network.wifi, "enabled").as((enabled) =>
|
||||
enabled ? ["button"] : ["button-disabled"],
|
||||
)}
|
||||
>
|
||||
<image iconName={bind(network.wifi, "icon_name")} />
|
||||
</button>
|
||||
<button
|
||||
hexpand={true}
|
||||
onClicked={() => {
|
||||
if (network.wifi.enabled) {
|
||||
isExpanded.set(!isExpanded.get());
|
||||
if (isExpanded.get()) {
|
||||
scanNetworks();
|
||||
getSavedNetworks();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box hexpand={true}>
|
||||
<label
|
||||
hexpand={true}
|
||||
xalign={0}
|
||||
label={bind(network.wifi, "ssid").as(
|
||||
(ssid) =>
|
||||
ssid || (network.wifi.enabled ? "Not Connected" : "WiFi Off"),
|
||||
)}
|
||||
/>
|
||||
<image
|
||||
iconName="pan-end-symbolic"
|
||||
halign={Gtk.Align.END}
|
||||
cssClasses={bind(isExpanded).as((expanded) =>
|
||||
expanded
|
||||
? ["arrow-indicator", "arrow-down"]
|
||||
: ["arrow-indicator"],
|
||||
)}
|
||||
/>
|
||||
</box>
|
||||
</button>
|
||||
</box>
|
||||
|
||||
{/* Networks List Revealer */}
|
||||
<revealer
|
||||
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
|
||||
transitionDuration={300}
|
||||
revealChild={bind(isExpanded)}
|
||||
setup={() => {
|
||||
const clearScanInterval = () => {
|
||||
if (refreshIntervalId.get()) {
|
||||
clearInterval( parseInt( '' + refreshIntervalId.get() ));
|
||||
refreshIntervalId.set(null);
|
||||
}
|
||||
};
|
||||
bind(isExpanded).subscribe((expanded) => {
|
||||
// Clear existing interval
|
||||
clearScanInterval();
|
||||
|
||||
if (expanded) {
|
||||
// Scan networks
|
||||
network.wifi?.scan();
|
||||
|
||||
// Set up new interval if WiFi is enabled
|
||||
if (network.wifi?.enabled) {
|
||||
refreshIntervalId.set(
|
||||
setInterval(() => {
|
||||
scanNetworks();
|
||||
getSavedNetworks();
|
||||
print("updated");
|
||||
}, 10000),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Apply revealer bug fix when collapsed
|
||||
App.toggle_window("system-menu");
|
||||
App.toggle_window("system-menu");
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor window toggling
|
||||
const windowListener = App.connect("window-toggled", (_, window) => {
|
||||
if (window.name === "system-menu" && isExpanded.get()) {
|
||||
isExpanded.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up resources when component is destroyed
|
||||
return () => {
|
||||
App.disconnect(windowListener);
|
||||
clearScanInterval();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<box vertical cssClasses={["network-list"]}>
|
||||
<box visible={showPasswordDialog( v => v )}>
|
||||
<PasswordDialog />
|
||||
</box>
|
||||
|
||||
<label label="Available Networks" cssClasses={["section-label"]} />
|
||||
<label label="No networks found" cssClasses={["empty-label"]} visible={availableNetworks( net => net.length === 0 )}/>
|
||||
<box visible={availableNetworks( networks => networks.length > 1 )}>
|
||||
{availableNetworks( networks =>
|
||||
networks.map( (network) => <NetworkItem network={network} />)
|
||||
)}
|
||||
</box>
|
||||
|
||||
{savedNetworks((networks) => {
|
||||
// Filter out networks already shown in available networks
|
||||
const filteredNetworks = networks.filter(
|
||||
(ssid) => !availableNetworks.get().some((n) => n.ssid === ssid)
|
||||
);
|
||||
|
||||
// Only render the section if there are filtered networks to show
|
||||
return filteredNetworks.length > 0 ? (
|
||||
<box vertical>
|
||||
<label label="Saved Networks" cssClasses={["section-label"]} />
|
||||
{filteredNetworks.map((ssid) => (
|
||||
<box cssClasses={["saved-network"]}>
|
||||
<label label={ssid} />
|
||||
<box hexpand={true} />
|
||||
<button
|
||||
label="Forget"
|
||||
cssClasses={["forget-button", "button"]}
|
||||
onClicked={() => forgetNetwork(ssid)}
|
||||
/>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
) : (
|
||||
<box></box>
|
||||
);
|
||||
})}
|
||||
|
||||
<box hexpand>
|
||||
<button
|
||||
halign={Gtk.Align.START}
|
||||
cssClasses={["refresh-button"]}
|
||||
onClicked={() => {
|
||||
scanNetworks();
|
||||
getSavedNetworks();
|
||||
}}
|
||||
>
|
||||
<image iconName="view-refresh-symbolic" />
|
||||
</button>
|
||||
{/* Connected Network Options */}
|
||||
<box hexpand>
|
||||
{activeNetwork((active) =>
|
||||
active ? (
|
||||
<box vertical cssClasses={["connected-network"]} hexpand>
|
||||
<button
|
||||
label="Disconnect"
|
||||
cssClasses={["disconnect-button"]}
|
||||
onClicked={() => disconnectNetwork(active.ssid)}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
""
|
||||
),
|
||||
)}
|
||||
</box>
|
||||
<button
|
||||
cssClasses={["settings-button"]}
|
||||
halign={Gtk.Align.END}
|
||||
hexpand={false}
|
||||
onClicked={() => {
|
||||
execAsync([
|
||||
"sh",
|
||||
"-c",
|
||||
"XDG_CURRENT_DESKTOP=GNOME gnome-control-center wifi",
|
||||
]);
|
||||
isExpanded.set(false);
|
||||
}}
|
||||
>
|
||||
<image iconName={"emblem-system-symbolic"} />
|
||||
</button>
|
||||
) : (
|
||||
""
|
||||
),
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</revealer>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
# Source
|
||||
This is a modified version from [MatShell](https://github.com/Neurian/matshell)
|
||||
14
configs/userland/astal/components/QuickActions/modules/Networking-old/network.d.ts
vendored
Normal file
14
configs/userland/astal/components/QuickActions/modules/Networking-old/network.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import AstalNetwork from "gi://AstalNetwork?version=0.1";
|
||||
|
||||
interface CurrentWiFi {
|
||||
ssid: string;
|
||||
strength: number;
|
||||
secured: boolean;
|
||||
}
|
||||
|
||||
interface WiFiDetails extends CurrentWiFi {
|
||||
active: boolean;
|
||||
accessPoint: AstalNetwork.AccessPoint;
|
||||
iconName: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// From https://github.com/Neurarian/matshell/blob/master/utils/wifi.ts
|
||||
|
||||
import { execAsync, Variable } from "astal";
|
||||
import Network from "gi://AstalNetwork";
|
||||
import { CurrentWiFi, WiFiDetails } from "./network";
|
||||
|
||||
// State trackers
|
||||
export const availableNetworks: Variable<WiFiDetails[]> = Variable([]);
|
||||
export const savedNetworks: Variable<string[]> = Variable([]);
|
||||
export const activeNetwork: Variable<CurrentWiFi | null> = Variable(null);
|
||||
export const isConnecting: Variable<boolean> = Variable(false);
|
||||
export const showPasswordDialog: Variable<boolean> = Variable(false);
|
||||
export const errorMessage: Variable<string> = Variable("");
|
||||
export const isExpanded: Variable<boolean> = Variable(false);
|
||||
export const passwordInput: Variable<string> = Variable("");
|
||||
export const selectedNetwork: Variable<null | WiFiDetails> = Variable(null);
|
||||
export const refreshIntervalId: Variable<
|
||||
number | null | ReturnType<typeof setTimeout>
|
||||
> = Variable(null);
|
||||
|
||||
// Function to scan for available networks
|
||||
export const scanNetworks = () => {
|
||||
const network = Network.get_default();
|
||||
if (network && network.wifi) {
|
||||
network.wifi.scan();
|
||||
|
||||
// Get available networks from access points
|
||||
const networks: WiFiDetails[] = network.wifi.accessPoints
|
||||
.map(ap => ({
|
||||
ssid: ap.ssid,
|
||||
strength: ap.strength,
|
||||
secured: ap.flags !== 0,
|
||||
active: network.wifi.activeAccessPoint?.ssid === ap.ssid,
|
||||
accessPoint: ap,
|
||||
iconName: ap.iconName,
|
||||
}))
|
||||
.filter(n => n.ssid);
|
||||
|
||||
// Sort by signal strength
|
||||
networks.sort((a, b) => b.strength - a.strength);
|
||||
|
||||
// Remove duplicates (same SSID)
|
||||
const uniqueNetworks: WiFiDetails[] = [];
|
||||
const seen = new Set();
|
||||
networks.forEach(network => {
|
||||
if (!seen.has(network.ssid)) {
|
||||
seen.add(network.ssid);
|
||||
uniqueNetworks.push(network);
|
||||
}
|
||||
});
|
||||
|
||||
availableNetworks.set(uniqueNetworks);
|
||||
|
||||
// Update active network
|
||||
if (network.wifi.activeAccessPoint) {
|
||||
activeNetwork.set({
|
||||
ssid: network.wifi.activeAccessPoint.ssid,
|
||||
strength: network.wifi.activeAccessPoint.strength,
|
||||
secured: network.wifi.activeAccessPoint.flags !== 0,
|
||||
});
|
||||
} else {
|
||||
activeNetwork.set(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to list saved networks
|
||||
export const getSavedNetworks = () => {
|
||||
execAsync(["bash", "-c", "nmcli -t -f NAME,TYPE connection show"])
|
||||
.then(output => {
|
||||
if (typeof output === "string") {
|
||||
const savedWifiNetworks = output
|
||||
.split("\n")
|
||||
.filter(line => line.includes("802-11-wireless"))
|
||||
.map(line => line.split(":")[0].trim());
|
||||
savedNetworks.set(savedWifiNetworks);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error fetching saved networks:", error));
|
||||
};
|
||||
|
||||
// Function to connect to a network
|
||||
|
||||
export const connectToNetwork = (
|
||||
ssid: string,
|
||||
password: null | string = null,
|
||||
) => {
|
||||
isConnecting.set(true);
|
||||
errorMessage.set("");
|
||||
const network = Network.get_default();
|
||||
const currentSsid = network.wifi.ssid;
|
||||
|
||||
// Function to perform the actual connection
|
||||
const performConnection = () => {
|
||||
let command = "";
|
||||
if (password) {
|
||||
// Connect with password
|
||||
command = `echo '${password}' | nmcli device wifi connect "${ssid}" --ask`;
|
||||
} else {
|
||||
// Connect without password (saved or open network)
|
||||
command = `nmcli connection up "${ssid}" || nmcli device wifi connect "${ssid}"`;
|
||||
}
|
||||
|
||||
execAsync(["bash", "-c", command])
|
||||
.then(() => {
|
||||
showPasswordDialog.set(false);
|
||||
isConnecting.set(false);
|
||||
scanNetworks(); // Refresh network list
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Connection error:", error);
|
||||
errorMessage.set("Failed to connect. Check password.");
|
||||
isConnecting.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
// If already connected to a network, disconnect first
|
||||
if (currentSsid && currentSsid !== ssid) {
|
||||
console.log(
|
||||
`Disconnecting from ${currentSsid} before connecting to ${ssid}`,
|
||||
);
|
||||
execAsync(["bash", "-c", `nmcli connection down "${currentSsid}"`])
|
||||
.then(() => {
|
||||
// Wait a moment for the disconnection to complete fully
|
||||
setTimeout(() => {
|
||||
performConnection();
|
||||
}, 500); // 500ms delay for clean disconnection
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Disconnect error:", error);
|
||||
// Continue with connection attempt even if disconnect fails
|
||||
performConnection();
|
||||
});
|
||||
} else {
|
||||
// No active connection or connecting to same network (reconnect case)
|
||||
performConnection();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to disconnect from a network
|
||||
export const disconnectNetwork = (ssid: string) => {
|
||||
execAsync(["bash", "-c", `nmcli connection down "${ssid}"`])
|
||||
.then(() => {
|
||||
scanNetworks(); // Refresh network list
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Disconnect error:", error);
|
||||
});
|
||||
};
|
||||
|
||||
// Function to forget a saved network
|
||||
export const forgetNetwork = (ssid: string) => {
|
||||
execAsync(["bash", "-c", `nmcli connection delete "${ssid}"`])
|
||||
.then(() => {
|
||||
getSavedNetworks(); // Refresh saved networks list
|
||||
scanNetworks(); // Refresh network list
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Forget network error:", error);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { bind } from "astal";
|
||||
import AstalNetwork from "gi://AstalNetwork";
|
||||
import networkHelper from "./network-helper";
|
||||
import NetworkMenu from "./NetworkMenu";
|
||||
|
||||
const net = AstalNetwork.get_default();
|
||||
const STATE = AstalNetwork.DeviceState;
|
||||
|
||||
const Network = () => {
|
||||
const netMenu = NetworkMenu.NetworkMenu();
|
||||
return (
|
||||
<box>
|
||||
<button
|
||||
cssClasses={networkHelper.networkEnabled(en => {
|
||||
if (en) return ["toggle-button", "toggle-on"];
|
||||
else return ["toggle-button"];
|
||||
})}
|
||||
onClicked={() =>
|
||||
networkHelper.setNetworking(
|
||||
!networkHelper.networkEnabled.get(),
|
||||
)
|
||||
}
|
||||
child={
|
||||
<box vertical>
|
||||
<label
|
||||
label={bind(net.wifi, "enabled").as(
|
||||
stat => `Network (${stat ? "WiFi" : "Wired"})`,
|
||||
)}
|
||||
cssClasses={["title-2"]}
|
||||
></label>
|
||||
<box child=
|
||||
{bind(net, 'wired').as(v => {
|
||||
if (v) {
|
||||
return <label
|
||||
label={bind(net.wired, "state").as(state => {
|
||||
if (state === STATE.ACTIVATED) {
|
||||
return (
|
||||
"Wired. IP: " + networkHelper.getIP()
|
||||
);
|
||||
} else if (state === STATE.DISCONNECTED) {
|
||||
return "Disconnected";
|
||||
} else if (state === STATE.FAILED) {
|
||||
return "Error";
|
||||
} else if (
|
||||
state === STATE.PREPARE ||
|
||||
state === STATE.CONFIG ||
|
||||
state === STATE.IP_CHECK ||
|
||||
state === STATE.IP_CONFIG
|
||||
) {
|
||||
return "Connecting...";
|
||||
} else {
|
||||
return "Unavailable";
|
||||
}
|
||||
})}
|
||||
visible={bind(net.wifi, "enabled").as(v => !v)}
|
||||
></label>
|
||||
} else {
|
||||
return <label
|
||||
label={"State unavailable"}
|
||||
visible={bind(net.wifi, "enabled").as(v => !v)}
|
||||
></label>
|
||||
}
|
||||
})}></box>
|
||||
<label
|
||||
label={bind(net.wifi, "state").as(state => {
|
||||
if (state === STATE.ACTIVATED) {
|
||||
return `${net.wifi.get_ssid()} (${networkHelper.getIP()})`;
|
||||
} else if (state === STATE.DISCONNECTED) {
|
||||
return "Disconnected";
|
||||
} else if (state === STATE.FAILED) {
|
||||
return "Error";
|
||||
} else if (
|
||||
state === STATE.PREPARE ||
|
||||
state === STATE.CONFIG ||
|
||||
state === STATE.IP_CHECK ||
|
||||
state === STATE.IP_CONFIG
|
||||
) {
|
||||
return "Connecting...";
|
||||
} else {
|
||||
return "Unavailable";
|
||||
}
|
||||
})}
|
||||
visible={bind(net.wifi, "enabled")}
|
||||
></label>
|
||||
</box>
|
||||
}
|
||||
></button >
|
||||
<button
|
||||
cssClasses={["actions-button"]}
|
||||
visible={networkHelper.networkEnabled()}
|
||||
onClicked={() => netMenu.popup()}
|
||||
child={
|
||||
<box>
|
||||
<image iconName={"arrow-right-symbolic"}></image>
|
||||
{netMenu}
|
||||
</box>
|
||||
}
|
||||
tooltipText={"View available devices"}
|
||||
></button>
|
||||
</box >
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
Network,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Gtk } from "astal/gtk4";
|
||||
|
||||
const NetworkMenu = () => {
|
||||
const popover = new Gtk.Popover();
|
||||
popover.set_child( renderMenu() );
|
||||
return popover;
|
||||
};
|
||||
|
||||
const renderMenu = () => {
|
||||
return <box vertical>
|
||||
<image iconName={"appointment-soon-symbolic"} iconSize={Gtk.IconSize.LARGE}></image>
|
||||
<label label={"Coming later"}></label>
|
||||
</box>;
|
||||
};
|
||||
|
||||
export default {
|
||||
NetworkMenu,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { bind } from "astal";
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import AstalNetwork from "gi://AstalNetwork";
|
||||
import networkHelper from "./network-helper";
|
||||
|
||||
const net = AstalNetwork.get_default();
|
||||
const STATE = AstalNetwork.DeviceState;
|
||||
|
||||
const WiFiList = () => {
|
||||
const popover = new Gtk.Popover({ cssClasses: ["WiFiPicker"] });
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
const renderWiFiList = () => {
|
||||
return <box>
|
||||
<label label="Test"></label>
|
||||
</box>
|
||||
}
|
||||
|
||||
const Network = () => {
|
||||
const wifiList = WiFiList();
|
||||
|
||||
return (
|
||||
<box>
|
||||
<button
|
||||
cssClasses={networkHelper.networkEnabled(en => {
|
||||
if (en) return ["network-button", "net-on"];
|
||||
else return ["network-button"];
|
||||
})}
|
||||
onClicked={() =>
|
||||
networkHelper.setNetworking(
|
||||
!networkHelper.networkEnabled.get(),
|
||||
)
|
||||
}
|
||||
child={<box vertical>
|
||||
<label label="Wired" cssClasses={[ 'button-name' ]}></label>
|
||||
<label label={bind( net.wired, 'state' ).as( state => {
|
||||
if ( state === STATE.ACTIVATED ) {
|
||||
return 'Connected. IP: ' + networkHelper.getIP();
|
||||
} else if ( state === STATE.DISCONNECTED ) {
|
||||
return 'Disconnected';
|
||||
} else if ( state === STATE.FAILED ) {
|
||||
return 'Error';
|
||||
} else if ( state === STATE.PREPARE || state === STATE.CONFIG || state === STATE.IP_CHECK || state === STATE.IP_CONFIG ) {
|
||||
return 'Connecting...';
|
||||
} else {
|
||||
return 'Unavailable';
|
||||
}
|
||||
} )}></label>
|
||||
</box>}
|
||||
></button>
|
||||
<box>
|
||||
<button
|
||||
cssClasses={bind(net.wifi, "enabled").as(b => {
|
||||
const classes = ["network-button"];
|
||||
if (b) {
|
||||
classes.push("wifi-on");
|
||||
}
|
||||
return classes;
|
||||
})}
|
||||
child={<box vertical>
|
||||
<label label="WiFi" cssClasses={[ 'button-name' ]}></label>
|
||||
<label label={bind( net.wifi, 'state' ).as( state => {
|
||||
if ( state === STATE.ACTIVATED ) {
|
||||
return 'Connected. IP: ' + networkHelper.getIP();
|
||||
} else if ( state === STATE.DISCONNECTED ) {
|
||||
return 'Disconnected';
|
||||
} else if ( state === STATE.FAILED ) {
|
||||
return 'Error';
|
||||
} else if ( state === STATE.PREPARE || state === STATE.CONFIG || state === STATE.IP_CHECK || state === STATE.IP_CONFIG ) {
|
||||
return 'Connecting...';
|
||||
} else {
|
||||
return 'Unavailable';
|
||||
}
|
||||
} )} visible={bind(net.wifi, 'enabled').as( en => en )}></label>
|
||||
<label label="Disabled" visible={bind(net.wifi, 'enabled').as( en => !en )}></label>
|
||||
</box>}
|
||||
></button>
|
||||
<button
|
||||
cssClasses={["network-button-context"]}
|
||||
visible={bind(net.wifi, "enabled").as(b => b)}
|
||||
onClicked={() => wifiList.popup()}
|
||||
></button>
|
||||
</box>
|
||||
{wifiList}
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Network;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { exec, Variable } from "astal";
|
||||
import AstalNetwork from "gi://AstalNetwork";
|
||||
|
||||
const networkEnabled = Variable( exec( 'nmcli networking connectivity' ) !== 'none' );
|
||||
const network = AstalNetwork.get_default();
|
||||
|
||||
|
||||
const setNetworking = ( status: boolean ) => {
|
||||
if ( status === true ) {
|
||||
exec( 'nmcli networking on' );
|
||||
networkEnabled.set( true );
|
||||
} else {
|
||||
exec( 'nmcli networking off' );
|
||||
networkEnabled.set( false );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getIP = () => {
|
||||
return exec( `/bin/bash -c "ip addr show | grep 'inet ' | awk '{print $2}' | grep -v '127'"` ).split( '/' )[ 0 ];
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
networkEnabled,
|
||||
setNetworking,
|
||||
getIP
|
||||
}
|
||||
14
configs/userland/astal/components/QuickActions/modules/Networking/network.d.ts
vendored
Normal file
14
configs/userland/astal/components/QuickActions/modules/Networking/network.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import AstalNetwork from "gi://AstalNetwork?version=0.1";
|
||||
|
||||
interface CurrentWiFi {
|
||||
ssid: string;
|
||||
strength: number;
|
||||
secured: boolean;
|
||||
}
|
||||
|
||||
interface WiFiDetails extends CurrentWiFi {
|
||||
active: boolean;
|
||||
accessPoint: AstalNetwork.AccessPoint;
|
||||
iconName: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// From https://github.com/Neurarian/matshell/blob/master/utils/wifi.ts
|
||||
|
||||
import { execAsync, Variable } from "astal";
|
||||
import Network from "gi://AstalNetwork";
|
||||
import { CurrentWiFi, WiFiDetails } from "./network";
|
||||
|
||||
// State trackers
|
||||
export const availableNetworks: Variable<WiFiDetails[]> = Variable([]);
|
||||
export const savedNetworks: Variable<string[]> = Variable([]);
|
||||
export const activeNetwork: Variable<CurrentWiFi | null> = Variable(null);
|
||||
export const isConnecting: Variable<boolean> = Variable(false);
|
||||
export const showPasswordDialog: Variable<boolean> = Variable(false);
|
||||
export const errorMessage: Variable<string> = Variable("");
|
||||
export const isExpanded: Variable<boolean> = Variable(false);
|
||||
export const passwordInput: Variable<string> = Variable("");
|
||||
export const selectedNetwork: Variable<null | WiFiDetails> = Variable(null);
|
||||
export const refreshIntervalId: Variable<
|
||||
number | null | ReturnType<typeof setTimeout>
|
||||
> = Variable(null);
|
||||
|
||||
// Function to scan for available networks
|
||||
export const scanNetworks = () => {
|
||||
const network = Network.get_default();
|
||||
if (network && network.wifi) {
|
||||
network.wifi.scan();
|
||||
|
||||
// Get available networks from access points
|
||||
const networks: WiFiDetails[] = network.wifi.accessPoints
|
||||
.map(ap => ({
|
||||
ssid: ap.ssid,
|
||||
strength: ap.strength,
|
||||
secured: ap.flags !== 0,
|
||||
active: network.wifi.activeAccessPoint?.ssid === ap.ssid,
|
||||
accessPoint: ap,
|
||||
iconName: ap.iconName,
|
||||
}))
|
||||
.filter(n => n.ssid);
|
||||
|
||||
// Sort by signal strength
|
||||
networks.sort((a, b) => b.strength - a.strength);
|
||||
|
||||
// Remove duplicates (same SSID)
|
||||
const uniqueNetworks: WiFiDetails[] = [];
|
||||
const seen = new Set();
|
||||
networks.forEach(network => {
|
||||
if (!seen.has(network.ssid)) {
|
||||
seen.add(network.ssid);
|
||||
uniqueNetworks.push(network);
|
||||
}
|
||||
});
|
||||
|
||||
availableNetworks.set(uniqueNetworks);
|
||||
|
||||
// Update active network
|
||||
if (network.wifi.activeAccessPoint) {
|
||||
activeNetwork.set({
|
||||
ssid: network.wifi.activeAccessPoint.ssid,
|
||||
strength: network.wifi.activeAccessPoint.strength,
|
||||
secured: network.wifi.activeAccessPoint.flags !== 0,
|
||||
});
|
||||
} else {
|
||||
activeNetwork.set(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to list saved networks
|
||||
export const getSavedNetworks = () => {
|
||||
execAsync(["bash", "-c", "nmcli -t -f NAME,TYPE connection show"])
|
||||
.then(output => {
|
||||
if (typeof output === "string") {
|
||||
const savedWifiNetworks = output
|
||||
.split("\n")
|
||||
.filter(line => line.includes("802-11-wireless"))
|
||||
.map(line => line.split(":")[0].trim());
|
||||
savedNetworks.set(savedWifiNetworks);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error fetching saved networks:", error));
|
||||
};
|
||||
|
||||
// Function to connect to a network
|
||||
|
||||
export const connectToNetwork = (
|
||||
ssid: string,
|
||||
password: null | string = null,
|
||||
) => {
|
||||
isConnecting.set(true);
|
||||
errorMessage.set("");
|
||||
const network = Network.get_default();
|
||||
const currentSsid = network.wifi.ssid;
|
||||
|
||||
// Function to perform the actual connection
|
||||
const performConnection = () => {
|
||||
let command = "";
|
||||
if (password) {
|
||||
// Connect with password
|
||||
command = `echo '${password}' | nmcli device wifi connect "${ssid}" --ask`;
|
||||
} else {
|
||||
// Connect without password (saved or open network)
|
||||
command = `nmcli connection up "${ssid}" || nmcli device wifi connect "${ssid}"`;
|
||||
}
|
||||
|
||||
execAsync(["bash", "-c", command])
|
||||
.then(() => {
|
||||
showPasswordDialog.set(false);
|
||||
isConnecting.set(false);
|
||||
scanNetworks(); // Refresh network list
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Connection error:", error);
|
||||
errorMessage.set("Failed to connect. Check password.");
|
||||
isConnecting.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
// If already connected to a network, disconnect first
|
||||
if (currentSsid && currentSsid !== ssid) {
|
||||
console.log(
|
||||
`Disconnecting from ${currentSsid} before connecting to ${ssid}`,
|
||||
);
|
||||
execAsync(["bash", "-c", `nmcli connection down "${currentSsid}"`])
|
||||
.then(() => {
|
||||
// Wait a moment for the disconnection to complete fully
|
||||
setTimeout(() => {
|
||||
performConnection();
|
||||
}, 500); // 500ms delay for clean disconnection
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Disconnect error:", error);
|
||||
// Continue with connection attempt even if disconnect fails
|
||||
performConnection();
|
||||
});
|
||||
} else {
|
||||
// No active connection or connecting to same network (reconnect case)
|
||||
performConnection();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to disconnect from a network
|
||||
export const disconnectNetwork = (ssid: string) => {
|
||||
execAsync(["bash", "-c", `nmcli connection down "${ssid}"`])
|
||||
.then(() => {
|
||||
scanNetworks(); // Refresh network list
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Disconnect error:", error);
|
||||
});
|
||||
};
|
||||
|
||||
// Function to forget a saved network
|
||||
export const forgetNetwork = (ssid: string) => {
|
||||
execAsync(["bash", "-c", `nmcli connection delete "${ssid}"`])
|
||||
.then(() => {
|
||||
getSavedNetworks(); // Refresh saved networks list
|
||||
scanNetworks(); // Refresh network list
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Forget network error:", error);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
$fg-color: #{"@theme_fg_color"};
|
||||
$bg-color: #{"@theme_bg_color"};
|
||||
|
||||
box.players-box {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
box.player {
|
||||
padding: 0.6rem;
|
||||
|
||||
.cover-art {
|
||||
min-width: 100px;
|
||||
min-height: 100px;
|
||||
border-radius: 9px;
|
||||
margin-right: 0.6rem;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
scale {
|
||||
padding: 0;
|
||||
margin: 0.4rem 0;
|
||||
border-radius: 20px;
|
||||
|
||||
trough {
|
||||
min-height: 8px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
highlight {
|
||||
background-color: $fg-color;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
slider {
|
||||
all: unset;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
centerbox.actions {
|
||||
min-width: 220px;
|
||||
|
||||
button {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 0.4rem;
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { bind } from "astal";
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import AstalMpris from "gi://AstalMpris";
|
||||
import Pango from "gi://Pango?version=1.0";
|
||||
const ALIGN = Gtk.Align;
|
||||
|
||||
const mpris = AstalMpris.get_default();
|
||||
mpris.connect("player-added", p => {
|
||||
print("Player added:", p);
|
||||
});
|
||||
|
||||
const PlayerModule = () => {
|
||||
return (
|
||||
<box vertical cssClasses={ [ 'players-box' ] }>
|
||||
<label label={"Music Players"} halign={ALIGN.CENTER} cssClasses={[ 'title-2' ]}></label>
|
||||
<Gtk.Separator marginTop={3} marginBottom={5}></Gtk.Separator>
|
||||
<box cssClasses={["players"]}>
|
||||
{bind(mpris, "players").as(players => {
|
||||
return players.map(player => {
|
||||
return <PlayerItem player={player}></PlayerItem>;
|
||||
});
|
||||
})}
|
||||
</box>
|
||||
<label label={"No playback active"} visible={bind(mpris, "players").as( players => players.length === 0 )}></label>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Update widths
|
||||
const pbStatus = AstalMpris.PlaybackStatus;
|
||||
const PlayerItem = ({ player }: { player: AstalMpris.Player }) => {
|
||||
return (
|
||||
<box cssClasses={["player"]}>
|
||||
<image
|
||||
cssClasses={["cover-art"]}
|
||||
file={bind(player, "coverArt")}
|
||||
hexpand
|
||||
vexpand
|
||||
></image>
|
||||
<box vertical>
|
||||
<label
|
||||
label={bind(player, "title").as(
|
||||
title => title ?? "Unknown title",
|
||||
)}
|
||||
cssClasses={["title"]}
|
||||
halign={ALIGN.START}
|
||||
valign={ALIGN.START}
|
||||
maxWidthChars={30}
|
||||
ellipsize={Pango.EllipsizeMode.END}
|
||||
></label>
|
||||
<label
|
||||
label={bind(player, "artist").as(
|
||||
artist => artist ?? "Unknown artist",
|
||||
)}
|
||||
halign={ALIGN.START}
|
||||
valign={ALIGN.START}
|
||||
maxWidthChars={30}
|
||||
ellipsize={Pango.EllipsizeMode.END}
|
||||
></label>
|
||||
<slider
|
||||
visible={bind(player, "length").as(l => l > 0)}
|
||||
value={bind(player, "position")}
|
||||
min={0}
|
||||
max={bind(player, "length")}
|
||||
onChangeValue={v =>
|
||||
player.set_position(v.get_value())
|
||||
}
|
||||
></slider>
|
||||
<centerbox
|
||||
cssClasses={["actions"]}
|
||||
startWidget={
|
||||
<label
|
||||
label={bind(player, "position").as(v =>
|
||||
secondsToFriendlyTime(v),
|
||||
)}
|
||||
hexpand
|
||||
cssClasses={["position"]}
|
||||
></label>
|
||||
}
|
||||
centerWidget={
|
||||
<box>
|
||||
<button
|
||||
visible={bind(player, "canGoPrevious")}
|
||||
child={
|
||||
<image
|
||||
iconName={
|
||||
"media-skip-backward-symbolic"
|
||||
}
|
||||
></image>
|
||||
}
|
||||
onClicked={() => player.previous()}
|
||||
></button>
|
||||
<button
|
||||
visible={bind(player, "canControl")}
|
||||
child={
|
||||
<image
|
||||
iconName={bind(
|
||||
player,
|
||||
"playbackStatus",
|
||||
).as(status => {
|
||||
if (status === pbStatus.PLAYING) {
|
||||
return "media-playback-pause-symbolic";
|
||||
} else {
|
||||
return "media-playback-start-symbolic";
|
||||
}
|
||||
})}
|
||||
></image>
|
||||
}
|
||||
onClicked={() => player.play_pause()}
|
||||
></button>
|
||||
<button
|
||||
visible={bind(player, "canGoNext")}
|
||||
child={
|
||||
<image
|
||||
iconName={"media-skip-forward-symbolic"}
|
||||
></image>
|
||||
}
|
||||
onClicked={() => player.next()}
|
||||
></button>
|
||||
</box>
|
||||
}
|
||||
endWidget={
|
||||
<label
|
||||
cssClasses={["length"]}
|
||||
hexpand
|
||||
label={bind(player, "length").as(v =>
|
||||
secondsToFriendlyTime(v),
|
||||
)}
|
||||
></label>
|
||||
}
|
||||
></centerbox>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const secondsToFriendlyTime = (time: number) => {
|
||||
const m = Math.floor(time / 60);
|
||||
const minutes = Math.floor(m % 60);
|
||||
const hours = Math.floor(m / 60 % 24);
|
||||
const seconds = Math.floor(time % 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${expandTime(minutes)}:${expandTime(seconds)}`;
|
||||
} else {
|
||||
return `${minutes}:${expandTime(seconds)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const expandTime = (time: number): string => {
|
||||
return time < 10 ? `0${time}` : "" + time;
|
||||
};
|
||||
|
||||
export default {
|
||||
PlayerModule,
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
exec
|
||||
} from 'astal';
|
||||
import {
|
||||
Gtk
|
||||
} from 'astal/gtk4';
|
||||
|
||||
const PowerMenu = (): Gtk.Popover => {
|
||||
const popover = new Gtk.Popover( {
|
||||
'cssClasses': [ 'PowerMenu' ]
|
||||
} );
|
||||
|
||||
const powerMenuBox = () => {
|
||||
return (
|
||||
<box>
|
||||
<button
|
||||
cssClasses={[ 'power-button' ]}
|
||||
child={
|
||||
<image iconName={'system-shutdown-symbolic'}></image>
|
||||
}
|
||||
onClicked={() => exec( '/bin/sh -c \'shutdown now\'' )}
|
||||
></button>
|
||||
<button
|
||||
cssClasses={[ 'power-button' ]}
|
||||
child={<image iconName={'system-reboot-symbolic'}></image>}
|
||||
onClicked={() => exec( '/bin/sh -c \'reboot\'' )}
|
||||
></button>
|
||||
<button
|
||||
cssClasses={[ 'power-button' ]}
|
||||
child={<image iconName={'system-suspend-symbolic'}></image>}
|
||||
onClicked={() => exec( '/bin/sh -c \'systemctl suspend\'' )}
|
||||
></button>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
popover.set_child( powerMenuBox() );
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
const Power = () => {
|
||||
const pm = PowerMenu();
|
||||
|
||||
return (
|
||||
<button
|
||||
widthRequest={0}
|
||||
hexpand={false}
|
||||
vexpand={false}
|
||||
cssClasses={[ 'power-menu-button' ]}
|
||||
child={
|
||||
<box>
|
||||
<image iconName={'system-shutdown-symbolic'}></image>
|
||||
{pm}
|
||||
</box>
|
||||
}
|
||||
onClicked={() => pm.popup()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMenu = (): Gtk.Popover => {
|
||||
const popover = new Gtk.Popover();
|
||||
|
||||
const powerMenuBox = () => {
|
||||
return (
|
||||
<box>
|
||||
<button
|
||||
cssClasses={[ 'power-button' ]}
|
||||
child={
|
||||
<image iconName={'system-lock-screen-symbolic'}></image>
|
||||
}
|
||||
onClicked={() => exec( '/bin/sh -c \'hyprlock\'' )}
|
||||
></button>
|
||||
<button
|
||||
cssClasses={[ 'power-button' ]}
|
||||
child={<image iconName={'system-log-out-symbolic'}></image>}
|
||||
onClicked={() => exec( '/bin/sh -c \'hyprctl dispatch exit 0\'' )
|
||||
}
|
||||
></button>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
popover.set_child( powerMenuBox() );
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
export default {
|
||||
Power,
|
||||
UserMenu
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import AstalTray from 'gi://AstalTray';
|
||||
import {
|
||||
GObject
|
||||
} from 'astal';
|
||||
import {
|
||||
Gtk
|
||||
} from 'astal/gtk4';
|
||||
|
||||
const SYNC = GObject.BindingFlags.SYNC_CREATE;
|
||||
|
||||
const SysTray = () => {
|
||||
const trayBox = new Gtk.Box( {
|
||||
'cssClasses': [ '' ]
|
||||
} );
|
||||
const tray = AstalTray.get_default();
|
||||
const trayItems = new Map<string, Gtk.MenuButton>();
|
||||
const trayAddedHandler = tray.connect( 'item-added', ( _, id ) => {
|
||||
const item = tray.get_item( id );
|
||||
const popover = Gtk.PopoverMenu.new_from_model( item.menu_model );
|
||||
const icon = new Gtk.Image();
|
||||
const button = new Gtk.MenuButton( {
|
||||
popover,
|
||||
'child': icon,
|
||||
'cssClasses': [ 'tray-item' ],
|
||||
} );
|
||||
|
||||
item.bind_property(
|
||||
'gicon', icon, 'gicon', SYNC
|
||||
);
|
||||
popover.insert_action_group( 'dbusmenu', item.action_group );
|
||||
item.connect( 'notify::action-group', () => {
|
||||
popover.insert_action_group( 'dbusmenu', item.action_group );
|
||||
} );
|
||||
|
||||
trayItems.set( id, button );
|
||||
trayBox.append( button );
|
||||
} );
|
||||
const trayRemovedHandler = tray.connect( 'item-removed', ( _, id ) => {
|
||||
const button = trayItems.get( id );
|
||||
|
||||
if ( button ) {
|
||||
trayBox.remove( button );
|
||||
button.run_dispose();
|
||||
trayItems.delete( id );
|
||||
}
|
||||
} );
|
||||
|
||||
trayBox.connect( 'destroy', () => {
|
||||
tray.disconnect( trayAddedHandler );
|
||||
tray.disconnect( trayRemovedHandler );
|
||||
} );
|
||||
|
||||
return trayBox;
|
||||
};
|
||||
|
||||
const TrayPopover = () => {
|
||||
const popover = new Gtk.Popover( {
|
||||
'cssClasses': [ 'TrayPopover' ]
|
||||
} );
|
||||
|
||||
popover.set_child( SysTray() );
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
const SystemTray = () => {
|
||||
const systray = TrayPopover();
|
||||
|
||||
return (
|
||||
<button
|
||||
widthRequest={0}
|
||||
hexpand={false}
|
||||
vexpand={false}
|
||||
cssClasses={[ 'power-menu-button' ]}
|
||||
child={
|
||||
<box>
|
||||
<image iconName={'systemtray'}></image>
|
||||
{systray}
|
||||
</box>
|
||||
}
|
||||
onClicked={() => systray.popup()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
SystemTray
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
@use "./modules/Player/Player.scss";
|
||||
@use "./modules/Audio/Audio.scss";
|
||||
@use "../../util/colours.scss" as *;
|
||||
|
||||
.quick-actions-wrapper {
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
box.quick-actions {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
popover * {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.button-no-margin {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.devices-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
button.toggle-button {
|
||||
min-width: 220px;
|
||||
border-radius: 50px;
|
||||
|
||||
&.toggle-on {
|
||||
min-width: 190px;
|
||||
margin-right: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
background-color: $accent-color;
|
||||
}
|
||||
}
|
||||
|
||||
button.actions-button {
|
||||
margin-left: 0;
|
||||
border-radius: 0;
|
||||
background-color: $accent-color;
|
||||
border-top-right-radius: 50px;
|
||||
border-bottom-right-radius: 50px;
|
||||
}
|
||||
|
||||
.avatar-icon {
|
||||
border-radius: 100px;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
84
configs/userland/astal/components/bar/Bar.tsx
Normal file
84
configs/userland/astal/components/bar/Bar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
App, Astal, Gdk, Gtk
|
||||
} from 'astal/gtk4';
|
||||
import Calendar from './modules/Calendar';
|
||||
import {
|
||||
CenterBox
|
||||
} from 'astal/gtk4/widget';
|
||||
import Hyprland from './modules/Hyprland';
|
||||
import QuickView from './modules/QuickView';
|
||||
import SystemInfo from './modules/SystemInfo';
|
||||
|
||||
const Bar = ( {
|
||||
gdkmonitor, name
|
||||
}: {
|
||||
'gdkmonitor': Gdk.Monitor,
|
||||
'name': string
|
||||
} ) => {
|
||||
const {
|
||||
TOP, LEFT, RIGHT
|
||||
} = Astal.WindowAnchor;
|
||||
|
||||
return (
|
||||
<window
|
||||
gdkmonitor={gdkmonitor}
|
||||
cssClasses={[ 'Bar' ]}
|
||||
name={name}
|
||||
namespace={'bar'}
|
||||
exclusivity={Astal.Exclusivity.EXCLUSIVE}
|
||||
anchor={TOP | LEFT | RIGHT}
|
||||
visible
|
||||
application={App}
|
||||
child={
|
||||
<CenterBox
|
||||
orientation={Gtk.Orientation.HORIZONTAL}
|
||||
startWidget={
|
||||
<box
|
||||
hexpand
|
||||
halign={Gtk.Align.START}
|
||||
>
|
||||
<Hyprland.ModeStatus />
|
||||
<Calendar.Time />
|
||||
<Hyprland.Workspace />
|
||||
</box>
|
||||
}
|
||||
centerWidget={<Hyprland.ActiveWindow />}
|
||||
endWidget={
|
||||
<box
|
||||
hexpand
|
||||
halign={Gtk.Align.END}
|
||||
cssClasses={[ 'BarRight' ]}
|
||||
>
|
||||
<SystemInfo.SystemInfo />
|
||||
<QuickView.QuickView />
|
||||
</box>
|
||||
}
|
||||
></CenterBox>
|
||||
}
|
||||
></window>
|
||||
);
|
||||
};
|
||||
|
||||
const cliHandler = ( args: string[] ): string => {
|
||||
console.debug( args );
|
||||
|
||||
return 'Not implemented';
|
||||
};
|
||||
|
||||
const BarLauncher = ( monitor: Gdk.Monitor ) => {
|
||||
const windowName = `bar-${ monitor.get_connector() }`;
|
||||
|
||||
const createBar = () => {
|
||||
return <Bar gdkmonitor={monitor} name={windowName}></Bar>;
|
||||
};
|
||||
|
||||
// Actually start the bar
|
||||
createBar();
|
||||
|
||||
return windowName;
|
||||
};
|
||||
|
||||
export default {
|
||||
BarLauncher,
|
||||
cliHandler,
|
||||
};
|
||||
111
configs/userland/astal/components/bar/bar.scss
Normal file
111
configs/userland/astal/components/bar/bar.scss
Normal file
@@ -0,0 +1,111 @@
|
||||
@use "../../util/colours.scss" as *;
|
||||
|
||||
window.Bar {
|
||||
font-family: "Comfortaa, sans-serif";
|
||||
background: transparent;
|
||||
color: $fg-color;
|
||||
font-weight: bold;
|
||||
|
||||
/* >centerbox { */
|
||||
/* background: $bg-color; */
|
||||
/* border-radius: 10px; */
|
||||
/* margin: 8px; */
|
||||
/* } */
|
||||
.mode-status {
|
||||
color: white;
|
||||
background-color: #00002dff;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
margin-left: 5px;
|
||||
border-radius: 20px;
|
||||
font-family: $monospace-font;
|
||||
|
||||
&.command-mode {
|
||||
background-color: darkslategray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.windowing-mode {
|
||||
background-color: darkslategray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.workspace-mode {
|
||||
background-color: darkblue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.launch-mode {
|
||||
background-color: darkgreen;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.device-mode {
|
||||
background-color: darkred;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.screenshotting-mode {
|
||||
background-color: purple;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.notifications-mode {
|
||||
background-color: darkorange;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-button {
|
||||
border-radius: 20px;
|
||||
margin: 2px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background-color: $bg-color;
|
||||
|
||||
& button {
|
||||
background-color: $bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-action-button {
|
||||
border-radius: 20px;
|
||||
margin: 2px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
button.workspace-button {
|
||||
border-radius: 20px;
|
||||
margin: 1px;
|
||||
|
||||
&.focused-workspace-button {
|
||||
color: $accent-color-2;
|
||||
}
|
||||
}
|
||||
|
||||
.tray-item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
& button {
|
||||
margin: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
min-width: 11rem;
|
||||
padding: 3px;
|
||||
|
||||
& button {
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-view-symbol {
|
||||
margin: 4px;
|
||||
}
|
||||
}
|
||||
26
configs/userland/astal/components/bar/modules/Calendar.tsx
Normal file
26
configs/userland/astal/components/bar/modules/Calendar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { GLib, Variable } from "astal";
|
||||
import { Gtk } from "astal/gtk4";
|
||||
|
||||
const Time = ({ format = "%a, %e.%m %H:%M:%S" }) => {
|
||||
const time = Variable<string>("").poll(
|
||||
1000,
|
||||
() => GLib.DateTime.new_now_local().format(format)!,
|
||||
);
|
||||
|
||||
return (
|
||||
<menubutton
|
||||
cssClasses={["time", "bar-button"]}
|
||||
hexpand
|
||||
halign={Gtk.Align.CENTER}
|
||||
>
|
||||
<label onDestroy={() => time.drop()} label={time()} halign={Gtk.Align.CENTER}></label>
|
||||
<popover>
|
||||
<Gtk.Calendar />
|
||||
</popover>
|
||||
</menubutton>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
Time,
|
||||
};
|
||||
146
configs/userland/astal/components/bar/modules/Hyprland.tsx
Normal file
146
configs/userland/astal/components/bar/modules/Hyprland.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
bind,
|
||||
exec,
|
||||
readFile
|
||||
} from 'astal';
|
||||
import AstalHyprland from 'gi://AstalHyprland';
|
||||
import {
|
||||
Gtk
|
||||
} from 'astal/gtk4';
|
||||
import Pango from 'gi://Pango?version=1.0';
|
||||
|
||||
const hypr = AstalHyprland.get_default();
|
||||
|
||||
const Workspace = () => {
|
||||
return (
|
||||
<box>
|
||||
{bind( hypr, 'workspaces' ).as( wss => wss
|
||||
.filter( ws => !( ws.id >= -99 && ws.id <= -2 ) ) // filter out special workspaces
|
||||
.sort( ( a, b ) => a.id - b.id )
|
||||
.map( ws => (
|
||||
<button
|
||||
cssClasses={bind( hypr, 'focusedWorkspace' ).as( fw => ws === fw
|
||||
? [
|
||||
'focused-workspace-button',
|
||||
'workspace-button',
|
||||
]
|
||||
: [ 'workspace-button' ], )}
|
||||
onButtonPressed={() => ws.focus()}
|
||||
child={<label label={String( ws.id )}></label>}
|
||||
></button>
|
||||
) ), )}
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays the name of the currently active window and provides a popover for
|
||||
* displaying all available clients
|
||||
*/
|
||||
const ActiveWindow = () => {
|
||||
const focused = bind( hypr, 'focusedClient' );
|
||||
|
||||
const WindowPopover = (): Gtk.Popover => {
|
||||
// Set up boxes + Popover
|
||||
const popover = new Gtk.Popover();
|
||||
const popoverBox = WindowPopoverBox();
|
||||
|
||||
popover.set_child( popoverBox );
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
const windowPopover = WindowPopover();
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Return fully assembled HyprlandFocusedClient box
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<box visible={focused.as( Boolean )}>
|
||||
<button
|
||||
onClicked={() => windowPopover.popup()}
|
||||
cssClasses={[ 'bar-button' ]}
|
||||
child={
|
||||
focused.as( client => client && (
|
||||
<label
|
||||
maxWidthChars={70}
|
||||
ellipsize={Pango.EllipsizeMode.END}
|
||||
label={bind( client, 'title' ).as( String )} />
|
||||
), )
|
||||
}></button>
|
||||
{windowPopover}
|
||||
</box >
|
||||
);
|
||||
};
|
||||
|
||||
type submaps = 'device' | 'launch' | 'workspace' | 'windowing' | 'screenshotting' | 'notifications' | '';
|
||||
|
||||
const ModeStatus = () => {
|
||||
let isUsingHyprvim = false;
|
||||
|
||||
try {
|
||||
const path = exec( 'bash -c "cd ~ && pwd"' ) + '/.config/hyprvim';
|
||||
|
||||
isUsingHyprvim = readFile( path ).trim() === 'y';
|
||||
} catch ( e ) {
|
||||
printerr( 'Failed to read hyprvim config', e );
|
||||
}
|
||||
|
||||
const label = new Gtk.Label();
|
||||
|
||||
if ( !isUsingHyprvim ) return label;
|
||||
|
||||
print( '==> Using hyprvim config' );
|
||||
|
||||
const map = {
|
||||
'device': 'DEV',
|
||||
'launch': 'LNC',
|
||||
'workspace': 'WSP',
|
||||
'windowing': 'WIN',
|
||||
'screenshotting': 'SCS',
|
||||
'notifications': 'NOT',
|
||||
'': 'NRM'
|
||||
};
|
||||
|
||||
label.label = map[''];
|
||||
label.cssClasses = [ 'mode-status' ];
|
||||
|
||||
// TODO: Possibly add popover to it that lists binds
|
||||
hypr.connect( 'submap', ( _, name: submaps ) => {
|
||||
label.label = map[name];
|
||||
label.cssClasses = [
|
||||
'mode-status',
|
||||
name + '-mode'
|
||||
];
|
||||
} );
|
||||
|
||||
return label;
|
||||
};
|
||||
|
||||
const WindowPopoverBox = () => {
|
||||
return <box vertical>
|
||||
<label label={'Available Windows'} cssClasses={[ 'title-2' ]}></label>
|
||||
<Gtk.Separator marginTop={5} marginBottom={5}></Gtk.Separator>
|
||||
<box vertical>
|
||||
{bind( hypr, 'clients' ).as( clients => {
|
||||
return clients.map( client => {
|
||||
return <button child={
|
||||
<box>
|
||||
<label label={bind( client, 'workspace' ).as( w => `(WS ${ w.name })` )}></label>
|
||||
<label label={bind( client, 'initialClass' ).as( c => `[${ c }]` )}></label>
|
||||
<label label={bind( client, 'title' )}></label>
|
||||
</box>
|
||||
}
|
||||
onClicked={() => client.focus()}
|
||||
></button>;
|
||||
} );
|
||||
} )}
|
||||
</box>
|
||||
</box>;
|
||||
};
|
||||
|
||||
export default {
|
||||
Workspace,
|
||||
ActiveWindow,
|
||||
ModeStatus
|
||||
};
|
||||
204
configs/userland/astal/components/bar/modules/QuickView.tsx
Normal file
204
configs/userland/astal/components/bar/modules/QuickView.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import AstalBattery from 'gi://AstalBattery';
|
||||
import AstalBluetooth from 'gi://AstalBluetooth';
|
||||
import AstalNetwork from 'gi://AstalNetwork';
|
||||
import AstalWp from 'gi://AstalWp';
|
||||
import Brightness from '../../../util/brightness';
|
||||
import {
|
||||
Gtk
|
||||
} from 'astal/gtk4';
|
||||
import QuickActions from '../../QuickActions/QuickActions';
|
||||
import {
|
||||
bind
|
||||
} from 'astal';
|
||||
import {
|
||||
execAsync
|
||||
} from 'astal';
|
||||
|
||||
const STATE = AstalNetwork.DeviceState;
|
||||
|
||||
const QuickView = () => {
|
||||
const qa = QuickActions.QuickActions();
|
||||
|
||||
const showQuickActions = () => {
|
||||
qa.popup();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClicked={() => showQuickActions()}
|
||||
cssClasses={[ 'quick-action-button' ]}
|
||||
child={
|
||||
<box>
|
||||
<BatteryWidget></BatteryWidget>
|
||||
<Audio></Audio>
|
||||
<BluetoothWidget></BluetoothWidget>
|
||||
<NetworkWidget></NetworkWidget>
|
||||
<image iconName={'system-shutdown-symbolic'}></image>
|
||||
{qa}
|
||||
</box>
|
||||
}
|
||||
></button>
|
||||
);
|
||||
};
|
||||
|
||||
const NetworkWidget = () => {
|
||||
const network = AstalNetwork.get_default();
|
||||
|
||||
return (
|
||||
<box>
|
||||
<image
|
||||
iconName={bind( network, 'state' ).as( state => {
|
||||
if ( state === AstalNetwork.State.CONNECTING ) {
|
||||
return 'chronometer-reset-symbolic';
|
||||
} else if (
|
||||
state === AstalNetwork.State.CONNECTED_LOCAL
|
||||
|| state === AstalNetwork.State.CONNECTED_SITE
|
||||
|| state === AstalNetwork.State.CONNECTED_GLOBAL
|
||||
) {
|
||||
return 'network-wired-activated-symbolic';
|
||||
} else {
|
||||
return 'paint-unknown-symbolic';
|
||||
}
|
||||
} )}
|
||||
cssClasses={[
|
||||
'network-widget',
|
||||
'quick-view-symbol'
|
||||
]}
|
||||
visible={bind( network.wifi, 'state' ).as( state => state !== STATE.ACTIVATED, )}
|
||||
></image>
|
||||
<image
|
||||
iconName={bind( network.wifi, 'state' ).as( state => {
|
||||
if ( state === STATE.ACTIVATED ) {
|
||||
return network.wifi.iconName;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
} )}
|
||||
tooltipText={bind( network.wifi, 'ssid' )}
|
||||
cssClasses={[
|
||||
'network-widget',
|
||||
'quick-view-symbol'
|
||||
]}
|
||||
visible={bind( network.wifi, 'state' ).as( state => state === STATE.ACTIVATED, )}
|
||||
></image>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const BluetoothWidget = () => {
|
||||
const bluetooth = AstalBluetooth.get_default();
|
||||
const enabled = bind( bluetooth, 'isPowered' );
|
||||
const connected = bind( bluetooth, 'isConnected' );
|
||||
|
||||
// For each connected BT device, render status
|
||||
return (
|
||||
<box>
|
||||
<box visible={enabled.as( e => e )}>
|
||||
<image
|
||||
iconName={'bluetooth-active-symbolic'}
|
||||
visible={connected.as( c => c )}
|
||||
></image>
|
||||
<image
|
||||
iconName={'bluetooth-disconnected-symbolic'}
|
||||
visible={connected.as( c => !c )}
|
||||
></image>
|
||||
</box>
|
||||
<image
|
||||
iconName={'bluetooth-disabled-symbolic'}
|
||||
visible={enabled.as( e => !e )}
|
||||
></image>
|
||||
<box>
|
||||
{bind( bluetooth, 'devices' ).as( devices => {
|
||||
return devices.map( device => {
|
||||
return (
|
||||
<image
|
||||
iconName={bind( device, 'icon' ).as( icon => icon, )}
|
||||
visible={bind( device, 'connected' )}
|
||||
tooltipText={bind( device, 'batteryPercentage' ).as( n => {
|
||||
return device.get_name() + ': ' + n + '%';
|
||||
}, )}
|
||||
></image>
|
||||
);
|
||||
} );
|
||||
} )}
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
let hasSentNotification = false;
|
||||
|
||||
const BatteryWidget = () => {
|
||||
const battery = AstalBattery.get_default();
|
||||
|
||||
if ( battery.get_is_present() ) {
|
||||
return (
|
||||
<image
|
||||
iconName={bind( battery, 'batteryIconName' ).as( icon => icon )}
|
||||
cssClasses={[ 'quick-view-symbol' ]}
|
||||
tooltipText={bind( battery, 'percentage' ).as( p => {
|
||||
const level = Math.round( p * 100 );
|
||||
|
||||
if ( level < 20 && !hasSentNotification ) {
|
||||
hasSentNotification = true;
|
||||
execAsync( 'bash -c "notify-send \'Battery level below 20%\'"' );
|
||||
}
|
||||
|
||||
return `Battery Level: ${ level }%`;
|
||||
} )}
|
||||
></image>
|
||||
);
|
||||
} else {
|
||||
return <box></box>;
|
||||
}
|
||||
// Else, no battery available -> Don't show the widget
|
||||
};
|
||||
|
||||
const BrightnessWidget = () => {
|
||||
const brightness = Brightness.get_default();
|
||||
const screen_brightness = bind( brightness, 'screen' );
|
||||
|
||||
return (
|
||||
<box cssClasses={[ 'quick-view-symbol' ]}>
|
||||
<image iconName={'brightness-high-symbolic'}></image>
|
||||
<label
|
||||
label={screen_brightness.as( b => '' + Math.round( 100 * b ) )}
|
||||
visible={bind( brightness, 'screenAvailable' )}
|
||||
></label>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const Audio = () => {
|
||||
const wireplumber = AstalWp.get_default();
|
||||
|
||||
if ( wireplumber ) {
|
||||
return (
|
||||
<box orientation={Gtk.Orientation.HORIZONTAL}>
|
||||
<image
|
||||
iconName={bind( wireplumber.defaultSpeaker, 'volumeIcon' ).as( icon => icon, )}
|
||||
cssClasses={[ 'quick-view-symbol' ]}
|
||||
tooltipText={bind( wireplumber.defaultSpeaker, 'volume' ).as( v => Math.round( 100 * v ) + '%' )}
|
||||
></image>
|
||||
<image
|
||||
iconName={bind( wireplumber.defaultMicrophone,
|
||||
'volumeIcon', ).as( icon => icon )}
|
||||
cssClasses={[ 'quick-view-symbol' ]}
|
||||
tooltipText={bind( wireplumber.defaultMicrophone, 'volume' ).as( v => Math.round( 100 * v ) + '%' )}
|
||||
></image>
|
||||
</box>
|
||||
);
|
||||
} else {
|
||||
print( '[ WirePlumber ] Could not connect, Audio support in bar will be missing', );
|
||||
|
||||
return <image iconName={'action-unavailable-symbolic'}></image>;
|
||||
}
|
||||
};
|
||||
|
||||
// cssClasses={[ 'quick-view-symbol' ]}
|
||||
|
||||
export default {
|
||||
QuickView,
|
||||
BrightnessWidget
|
||||
};
|
||||
105
configs/userland/astal/components/bar/modules/SystemInfo.tsx
Normal file
105
configs/userland/astal/components/bar/modules/SystemInfo.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Gtk
|
||||
} from 'astal/gtk4';
|
||||
import {
|
||||
execAsync
|
||||
} from 'astal';
|
||||
import sysinfo from '../sysinfo';
|
||||
|
||||
const info = () => {
|
||||
return (
|
||||
<box vertical>
|
||||
<label
|
||||
label={'System Information'}
|
||||
cssClasses={[ 'title-2' ]}
|
||||
></label>
|
||||
<Gtk.Separator marginTop={5} marginBottom={10}></Gtk.Separator>
|
||||
<label
|
||||
vexpand
|
||||
halign={Gtk.Align.START}
|
||||
hexpand
|
||||
label={sysinfo.ramUsed( used => {
|
||||
return 'RAM: ' + used + ` (${ sysinfo.ramUtil.get() }%)`;
|
||||
} )}
|
||||
></label>
|
||||
<label
|
||||
label={sysinfo.systemStats( stats => {
|
||||
return `CPU: ${ stats.cpuTemp }, ${ stats.cpuClk }
|
||||
GPU: ${ stats.gpuTemp }, ${ stats.gpuClk } (${ stats.vram } / ${ stats.availableVRAM })
|
||||
Kernel: ${ stats.kernel }`;
|
||||
} )}
|
||||
></label>
|
||||
<Gtk.Separator marginTop={10}></Gtk.Separator>
|
||||
<button
|
||||
onClicked={() => execAsync( '/bin/sh -c "kitty --hold fish -c \'fastfetch\'"' )}
|
||||
child={
|
||||
<label label={'View FastFetch'}></label>
|
||||
}></button>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const SystemInformationPanel = () => {
|
||||
const popover = new Gtk.Popover();
|
||||
|
||||
popover.set_child( info() );
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
|
||||
const panel = SystemInformationPanel();
|
||||
|
||||
const SystemInfo = () => {
|
||||
sysinfo.startSysInfoFetcher();
|
||||
|
||||
const openSysInfo = async () => {
|
||||
panel.popup();
|
||||
sysinfo.refreshStats();
|
||||
};
|
||||
|
||||
if ( sysinfo.enabled ) {
|
||||
return (
|
||||
<button
|
||||
onClicked={() => openSysInfo()}
|
||||
child={
|
||||
<box tooltipText={sysinfo.ramUsed( v => v )}>
|
||||
<box
|
||||
cssClasses={[ 'quick-view-symbol' ]}
|
||||
>
|
||||
<image
|
||||
iconName={'power-profile-performance-symbolic'}
|
||||
marginEnd={1}
|
||||
></image>
|
||||
<label
|
||||
label={sysinfo.cpuUtil( util => util )}
|
||||
marginEnd={5}
|
||||
></label>
|
||||
</box>
|
||||
<box
|
||||
cssClasses={[ 'quick-view-symbol' ]}
|
||||
>
|
||||
<image iconName={'memory'}></image>
|
||||
<label label={sysinfo.ramUtil( util => util )}></label>
|
||||
</box>
|
||||
<box
|
||||
cssClasses={[ 'quick-view-symbol' ]}
|
||||
>
|
||||
<image iconName={'show-gpu-effects-symbolic'}></image>
|
||||
<label label={sysinfo.gpuUtil( util => util )}></label>
|
||||
</box>
|
||||
{panel}
|
||||
</box>
|
||||
}
|
||||
cssClasses={[ 'bar-button' ]}
|
||||
></button>
|
||||
);
|
||||
} else {
|
||||
return <image iconName={'action-unavailable-symbolic'}></image>;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
SystemInfo,
|
||||
panel,
|
||||
};
|
||||
9
configs/userland/astal/components/bar/modules/stats.d.ts
vendored
Normal file
9
configs/userland/astal/components/bar/modules/stats.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
interface Stats {
|
||||
kernel: string;
|
||||
cpuTemp: string;
|
||||
cpuClk: string;
|
||||
gpuTemp: string;
|
||||
gpuClk: string;
|
||||
vram: string;
|
||||
availableVRAM: string;
|
||||
}
|
||||
137
configs/userland/astal/components/bar/sysinfo.ts
Normal file
137
configs/userland/astal/components/bar/sysinfo.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { exec, execAsync, interval, Variable } from "astal";
|
||||
|
||||
const FETCH_INTERVAL = 2000;
|
||||
const cpuUtil = Variable("0%");
|
||||
const ramUtil = Variable("0%");
|
||||
const ramUsed = Variable("0MiB");
|
||||
const gpuUtil = Variable("0%");
|
||||
let gpuName = "card1";
|
||||
let enabled = true;
|
||||
|
||||
const getStats = (): Stats => {
|
||||
gpuName = exec(`/bin/bash -c "ls /sys/class/drm/ | grep '^card[0-9]*$'"`);
|
||||
const cpuNameInSensors = "CPUTIN";
|
||||
const stats = {
|
||||
kernel: exec("uname -sr"),
|
||||
cpuTemp: exec(
|
||||
`/bin/bash -c "sensors | grep -m1 ${cpuNameInSensors} | awk '{print $2}'"`,
|
||||
),
|
||||
cpuClk: exec(
|
||||
`awk '/cpu MHz/ {sum+=$4; ++n} END {print sum/n " MHz"}' /proc/cpuinfo`,
|
||||
),
|
||||
gpuTemp: exec(
|
||||
`/bin/bash -c "sensors | grep -E 'edge' | awk '{print $2}'"`,
|
||||
),
|
||||
gpuClk: exec(
|
||||
`/bin/bash -c "cat /sys/class/drm/${gpuName}/device/pp_dpm_sclk | grep '\\*' | awk '{print $2 $3}'"`,
|
||||
),
|
||||
vram:
|
||||
Math.round(
|
||||
parseInt(
|
||||
exec(
|
||||
`cat /sys/class/drm/${gpuName}/device/mem_info_vram_used`,
|
||||
),
|
||||
) /
|
||||
1024 /
|
||||
1024,
|
||||
) + "MiB",
|
||||
availableVRAM:
|
||||
Math.round(
|
||||
parseInt(
|
||||
exec(
|
||||
`cat /sys/class/drm/${gpuName}/device/mem_info_vram_total`,
|
||||
),
|
||||
) /
|
||||
1024 /
|
||||
1024,
|
||||
) + "MiB",
|
||||
};
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
const systemStats: Variable<Stats> = Variable(getStats());
|
||||
const availableFeatures = {
|
||||
cpu: true,
|
||||
ram: true,
|
||||
};
|
||||
|
||||
const refreshStats = () => {
|
||||
systemStats.set(getStats());
|
||||
}
|
||||
|
||||
const featureTest = () => {
|
||||
print('[SysInfo] Feature test started...');
|
||||
// Check if awk & sed are available
|
||||
try {
|
||||
exec("awk -V");
|
||||
exec("sed --version");
|
||||
} catch (e) {
|
||||
printerr(
|
||||
"[ SysInfo ] AWK or SED missing! No system info will be available",
|
||||
);
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if mpstat is available
|
||||
try {
|
||||
exec("mpstat -V");
|
||||
} catch (e) {
|
||||
availableFeatures.cpu = false;
|
||||
printerr(
|
||||
"[ SysInfo ] Feature Test for CPU info failed. mpstat from the sysstat package missing!",
|
||||
);
|
||||
}
|
||||
print('[SysInfo] Feature test complete');
|
||||
};
|
||||
|
||||
const sysInfoFetcher = () => {
|
||||
if (enabled) {
|
||||
if (availableFeatures.cpu) {
|
||||
execAsync(`/bin/fish -c cpu-utilization`).then(v => {
|
||||
cpuUtil.set("" + Math.round(parseFloat(v)));
|
||||
}).catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
if (availableFeatures.ram) {
|
||||
execAsync(
|
||||
`/bin/bash -c "free | awk '/Mem:/ {print $3 \\" \\" $2}'"`,
|
||||
).then(v => {
|
||||
const util = parseInt(v.split(' ')[0]);
|
||||
const available = parseInt(v.split(' ')[1]);
|
||||
ramUtil.set("" + Math.round(util / available * 100));
|
||||
ramUsed.set(`${Math.round(util / 1024 / 1024 * 10) / 10} GiB of ${Math.round(available / 1024 / 1024 * 10) / 10} GiB used`);
|
||||
}).catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
gpuUtil.set(exec("cat /sys/class/drm/card1/device/gpu_busy_percent"));
|
||||
}
|
||||
};
|
||||
|
||||
let sysInfoFetcherRunning = false;
|
||||
const startSysInfoFetcher = () => {
|
||||
if (!sysInfoFetcherRunning) {
|
||||
sysInfoFetcherRunning = true;
|
||||
|
||||
featureTest();
|
||||
|
||||
if (enabled) {
|
||||
// Start interval
|
||||
interval(FETCH_INTERVAL, sysInfoFetcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
startSysInfoFetcher,
|
||||
enabled,
|
||||
gpuUtil,
|
||||
cpuUtil,
|
||||
ramUsed,
|
||||
ramUtil,
|
||||
refreshStats,
|
||||
systemStats
|
||||
}
|
||||
87
configs/userland/astal/components/launcher/Launcher.tsx
Normal file
87
configs/userland/astal/components/launcher/Launcher.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Variable } from "astal";
|
||||
import { App, Astal, Gdk, Gtk, hook } from "astal/gtk4";
|
||||
import AstalApps from "gi://AstalApps";
|
||||
import AppList from "./modules/Apps";
|
||||
|
||||
const prefixes = ['='];
|
||||
|
||||
function hide() {
|
||||
App.get_window("launcher")!.hide();
|
||||
}
|
||||
|
||||
const Launcher = () => {
|
||||
const apps = new AstalApps.Apps();
|
||||
const width = Variable(1000);
|
||||
const height = Variable(1000);
|
||||
|
||||
const text = Variable("");
|
||||
const visible = Variable(false);
|
||||
const onEnter = () => {
|
||||
// TODO handle custom stuff
|
||||
apps.fuzzy_query(text.get())?.[0].launch();
|
||||
hide();
|
||||
};
|
||||
return <window
|
||||
name="launcher"
|
||||
visible={visible()}
|
||||
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM}
|
||||
exclusivity={Astal.Exclusivity.EXCLUSIVE}
|
||||
keymode={Astal.Keymode.ON_DEMAND}
|
||||
application={App}
|
||||
onShow={(self) => {
|
||||
width.set(self.get_current_monitor().geometry.width);
|
||||
height.set(self.get_current_monitor().geometry.height);
|
||||
}}
|
||||
onKeyPressed={(self, keyval) => {
|
||||
if (keyval === Gdk.KEY_Escape) self.hide();
|
||||
}}
|
||||
child={
|
||||
<box
|
||||
vertical
|
||||
cssClasses={["app-launcher-wrapper"]}
|
||||
widthRequest={width()}
|
||||
heightRequest={height()}
|
||||
valign={Gtk.Align.CENTER}
|
||||
>
|
||||
<button onClicked={hide} visible={false} />
|
||||
<box
|
||||
vertical
|
||||
cssClasses={["app-launcher"]}
|
||||
valign={Gtk.Align.CENTER}
|
||||
halign={Gtk.Align.CENTER}
|
||||
widthRequest={500}
|
||||
>
|
||||
<button onClicked={hide} visible={false}></button>
|
||||
<box cssClasses={["search"]}>
|
||||
<image iconName={"system-search-symbolic"}></image>
|
||||
<entry
|
||||
placeholderText={"Search..."}
|
||||
text={text.get()}
|
||||
setup={self => {
|
||||
hook(self, App, 'window-toggled', (_, win) => {
|
||||
if (win.name == 'launcher') {
|
||||
self.set_text('');
|
||||
self.grab_focus();
|
||||
}
|
||||
})
|
||||
}}
|
||||
onNotifyText={self => text.set(self.text)}
|
||||
primaryIconSensitive
|
||||
onActivate={onEnter}
|
||||
hexpand></entry>
|
||||
</box>
|
||||
<AppList
|
||||
hide={hide}
|
||||
query={text}
|
||||
visible={text(v => {
|
||||
return !prefixes.includes(v.slice(0, 1));
|
||||
})}
|
||||
></AppList>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
</window>
|
||||
}
|
||||
|
||||
export default Launcher;
|
||||
16
configs/userland/astal/components/launcher/launcher.scss
Normal file
16
configs/userland/astal/components/launcher/launcher.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
@use '../../util/colours.scss' as *;
|
||||
|
||||
window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
box.app-launcher-wrapper {
|
||||
background-color: $shadow-color;
|
||||
|
||||
>box.app-launcher {
|
||||
background-color: $bg-color;
|
||||
border-radius: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid $accent-color-2;
|
||||
}
|
||||
}
|
||||
59
configs/userland/astal/components/launcher/modules/Apps.tsx
Normal file
59
configs/userland/astal/components/launcher/modules/Apps.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Binding, Variable } from "astal";
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import AstalApps from "gi://AstalApps";
|
||||
import Pango from "gi://Pango?version=1.0";
|
||||
|
||||
const MAX_ITEMS = 8;
|
||||
|
||||
const AppList = ({ hide, query, visible }: { hide: () => void, query: Variable<string>, visible: Binding<Boolean> }) => {
|
||||
const apps = new AstalApps.Apps();
|
||||
const list = query((text) => apps.fuzzy_query(text).slice(0, MAX_ITEMS));
|
||||
return <box>
|
||||
<box
|
||||
spacing={6}
|
||||
vertical
|
||||
cssClasses={["app-list"]}
|
||||
visible={list.as(l => l.length > 0)}
|
||||
>
|
||||
{list.as(l => l.map(app => <AppButton app={app} hide={hide}></AppButton>))}
|
||||
</box>
|
||||
<box
|
||||
halign={Gtk.Align.CENTER}
|
||||
cssClasses={["list-empty"]}
|
||||
vertical
|
||||
visible={list.as(l => l.length === 0)}
|
||||
>
|
||||
<image iconName={"system-search-symbolic"}></image>
|
||||
<label label={"No match found"}></label>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
|
||||
const AppButton = ({ app, hide }: { app: AstalApps.Application, hide: () => void }) => {
|
||||
return <button
|
||||
onClicked={() => {
|
||||
hide();
|
||||
app.launch();
|
||||
}}
|
||||
child={
|
||||
<box>
|
||||
<image iconName={app.iconName}></image>
|
||||
<box valign={Gtk.Align.CENTER} vertical>
|
||||
<label
|
||||
cssClasses={["title-2"]}
|
||||
ellipsize={Pango.EllipsizeMode.END}
|
||||
maxWidthChars={40}
|
||||
xalign={0}
|
||||
label={app.name}
|
||||
></label>
|
||||
<label
|
||||
wrap xalign={0}
|
||||
label={app.description}
|
||||
></label>
|
||||
</box>
|
||||
</box>
|
||||
}>
|
||||
</button>
|
||||
}
|
||||
|
||||
export default AppList;
|
||||
@@ -0,0 +1,6 @@
|
||||
# Source
|
||||
This has been copied from [matshell](https://github.com/Neurarian/matshell)
|
||||
|
||||
It is not yet used, as it has not been adapted yet to feature a notification history.
|
||||
|
||||
Potentially, a notification centre will be added to make this here work better. Styling is also missing
|
||||
32
configs/userland/astal/components/notifications-opt/main.tsx
Normal file
32
configs/userland/astal/components/notifications-opt/main.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Astal } from "astal/gtk4";
|
||||
import Notifd from "gi://AstalNotifd";
|
||||
import Hyprland from "gi://AstalHyprland";
|
||||
import { bind } from "astal";
|
||||
import { NotificationWidget } from "./modules/Notification";
|
||||
import { hyprToGdk } from "../../util/hyprland";
|
||||
|
||||
export default function Notifications() {
|
||||
const notifd = Notifd.get_default();
|
||||
const hyprland = Hyprland.get_default();
|
||||
const { TOP, RIGHT } = Astal.WindowAnchor;
|
||||
|
||||
return (
|
||||
<window
|
||||
name="notifications"
|
||||
gdkmonitor={bind(hyprland, "focusedMonitor").as(
|
||||
(focused: Hyprland.Monitor) => hyprToGdk(focused),
|
||||
)}
|
||||
anchor={TOP | RIGHT}
|
||||
visible={bind(notifd, "notifications").as(
|
||||
(notifications) => notifications.length > 0,
|
||||
)}
|
||||
child={
|
||||
<box vertical={true} cssClasses={["notifications"]}>
|
||||
{bind(notifd, "notifications").as((notifications) =>
|
||||
notifications.map((n) => <NotificationWidget notification={n} />),
|
||||
)}
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import Notifd from "gi://AstalNotifd";
|
||||
import { fileExists, isIcon } from "../../../util/notifd";
|
||||
|
||||
|
||||
export function NotificationIcon(notification: Notifd.Notification) {
|
||||
if (notification.image || notification.appIcon || notification.desktopEntry) {
|
||||
const icon = notification.image || notification.appIcon || notification.desktopEntry;
|
||||
if (fileExists(icon)) {
|
||||
return (
|
||||
<box expand={false} valign={Gtk.Align.CENTER}>
|
||||
<image file={icon} />
|
||||
</box>
|
||||
);
|
||||
} else if (isIcon(icon)) {
|
||||
return (
|
||||
<box expand={false} valign={Gtk.Align.CENTER}>
|
||||
<image iconName={icon} />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { bind } from "astal";
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import Notifd from "gi://AstalNotifd";
|
||||
import { NotificationIcon } from "./Icon";
|
||||
import { createTimeoutManager } from "../../../util/notifd";
|
||||
|
||||
export function NotificationWidget({
|
||||
notification,
|
||||
}: {
|
||||
notification: Notifd.Notification;
|
||||
}) {
|
||||
const { START, CENTER, END } = Gtk.Align;
|
||||
const actions = notification.actions || [];
|
||||
const TIMEOUT_DELAY = 3000;
|
||||
|
||||
// Keep track of notification validity
|
||||
const notifd = Notifd.get_default();
|
||||
const timeoutManager = createTimeoutManager(
|
||||
() => notification.dismiss(),
|
||||
TIMEOUT_DELAY,
|
||||
);
|
||||
return (
|
||||
<box
|
||||
setup={(self) => {
|
||||
// Set up timeout
|
||||
timeoutManager.setupTimeout();
|
||||
const clickGesture = Gtk.GestureClick.new();
|
||||
clickGesture.set_button(0); // 0 means any button
|
||||
clickGesture.connect("pressed", (gesture, _) => {
|
||||
try {
|
||||
// Get which button was pressed (1=left, 2=middle, 3=right)
|
||||
const button = gesture.get_current_button();
|
||||
|
||||
if (button === 1) {
|
||||
// PRIMARY/LEFT
|
||||
if (actions.length > 0) n.invoke(actions[0]);
|
||||
} else if (button === 2) {
|
||||
// MIDDLE
|
||||
notifd.notifications?.forEach((n) => {
|
||||
n.dismiss();
|
||||
});
|
||||
} else if (button === 3) {
|
||||
// SECONDARY/RIGHT
|
||||
notification.dismiss();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
self.add_controller(clickGesture);
|
||||
|
||||
self.connect("unrealize", () => {
|
||||
timeoutManager.cleanup();
|
||||
});
|
||||
}}
|
||||
onHoverEnter={timeoutManager.handleHover}
|
||||
onHoverLeave={timeoutManager.handleHoverLost}
|
||||
vertical
|
||||
vexpand={false}
|
||||
cssClasses={["notification", `${urgency(notification)}`]}
|
||||
name={notification.id.toString()}
|
||||
>
|
||||
<box cssClasses={["header"]}>
|
||||
<label
|
||||
cssClasses={["app-name"]}
|
||||
halign={CENTER}
|
||||
label={bind(notification, "app_name")}
|
||||
/>
|
||||
<label
|
||||
cssClasses={["time"]}
|
||||
hexpand
|
||||
halign={END}
|
||||
label={time(notification.time)}
|
||||
/>
|
||||
</box>
|
||||
<Gtk.Separator />
|
||||
<box cssClasses={["content"]}>
|
||||
<box
|
||||
cssClasses={["thumb"]}
|
||||
visible={Boolean(NotificationIcon(notification))}
|
||||
halign={CENTER}
|
||||
valign={CENTER}
|
||||
vexpand={true}
|
||||
>
|
||||
{NotificationIcon(notification)}
|
||||
</box>
|
||||
<box
|
||||
vertical
|
||||
cssClasses={["text-content"]}
|
||||
hexpand={true}
|
||||
halign={CENTER}
|
||||
valign={CENTER}
|
||||
>
|
||||
<label
|
||||
cssClasses={["title"]}
|
||||
valign={CENTER}
|
||||
wrap={false}
|
||||
label={bind(notification, "summary")}
|
||||
/>
|
||||
{notification.body && (
|
||||
<label
|
||||
cssClasses={["body"]}
|
||||
valign={CENTER}
|
||||
wrap={true}
|
||||
maxWidthChars={50}
|
||||
label={bind(notification, "body")}
|
||||
/>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
{actions.length > 0 && (
|
||||
<box cssClasses={["actions"]}>
|
||||
{actions.map(({ label, action }) => (
|
||||
<button
|
||||
hexpand
|
||||
cssClasses={["action-button"]}
|
||||
onClicked={() => notification.invoke(action)}
|
||||
>
|
||||
<label label={label} halign={CENTER} hexpand />
|
||||
</button>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
299
configs/userland/astal/components/notifications/handler.tsx
Normal file
299
configs/userland/astal/components/notifications/handler.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* dotfiles - handler.ts
|
||||
*
|
||||
* Created by Janis Hutz 03/21/2025, Licensed under the GPL V3 License
|
||||
* https://janishutz.com, development@janishutz.com
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
import { App, Astal, Gdk } from "astal/gtk4"
|
||||
import Notifd from "gi://AstalNotifd";
|
||||
import Notification from "./notifications";
|
||||
import { timeout, Variable } from "astal"
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Config
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
const TIMEOUT_DELAY = 5000;
|
||||
let isRunning = false;
|
||||
let notificationMenuOpen = false;
|
||||
|
||||
interface NotificationDetails {
|
||||
notification: Notifd.Notification;
|
||||
backendID: number;
|
||||
notifdID: number;
|
||||
}
|
||||
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// ╭───────────────────────────────────────────────╮
|
||||
// │ Handler │
|
||||
// ╰───────────────────────────────────────────────╯
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
let ShownNotifications: Variable<number[]> = Variable( [] );
|
||||
let Notifications: NotificationDetails[] = [];
|
||||
|
||||
const notifd = Notifd.get_default();
|
||||
notifd.ignoreTimeout = true;
|
||||
|
||||
|
||||
/**
|
||||
* Delete a notification by its internal ID
|
||||
* @param index The notifd ID of the notification
|
||||
*/
|
||||
const deleteNotification = ( index: number ): void => {
|
||||
hideNotification( index );
|
||||
Notifications.splice( index, 1 );
|
||||
if ( Notifications.length === 0 ) {
|
||||
notificationMenuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Delete a notification by notifd id
|
||||
* @param id The notifd ID of the notification
|
||||
*/
|
||||
const deleteNotificationByNotificationID = ( id: number ): void => {
|
||||
const index = findNotificationByNotificationID( id );
|
||||
if ( index > -1 ) {
|
||||
deleteNotification( index );
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Find the internal ID from the notifd id for a notification (helper function)
|
||||
* @param id The notifd ID of the notification
|
||||
* @returns The internal ID or -1 if not found
|
||||
*/
|
||||
const findNotificationByNotificationID = ( id: number ): number => {
|
||||
// Find index in Notifications array
|
||||
for (let index = 0; index < Notifications.length; index++) {
|
||||
if ( Notifications[ index ].notifdID === id ) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Add a notification to the notification handler
|
||||
* @param id The notifd ID of the notification
|
||||
*/
|
||||
const addNotification = ( id: number ): void => {
|
||||
const currIndex = Notifications.length;
|
||||
Notifications.push( {
|
||||
notifdID: id,
|
||||
backendID: currIndex,
|
||||
notification: notifd.get_notification( id )
|
||||
} );
|
||||
|
||||
showNotification( currIndex );
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Start the notifd runner and handle notifications.
|
||||
*/
|
||||
const hookToNotificationDaemon = (): void => {
|
||||
if ( isRunning ) {
|
||||
printerr( '[ Notifications ] Error: Already running' );
|
||||
return;
|
||||
}
|
||||
isRunning = true;
|
||||
|
||||
notifd.connect( 'notified', ( _, id ) => {
|
||||
addNotification( id );
|
||||
} );
|
||||
|
||||
notifd.connect( 'resolved', ( _, id ) => {
|
||||
deleteNotificationByNotificationID( id );
|
||||
} );
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Show a notification. It will stay on screen (regardless of removeAgain passed in), if
|
||||
* critical urgency
|
||||
* @param id The internal id (index in Notifications array)
|
||||
* @param removeAgain = true If to remove the notification from the screen again automatically
|
||||
*/
|
||||
const showNotification = ( id: number, removeAgain: boolean = true ) => {
|
||||
// Add notification to UI for display
|
||||
const not = [...ShownNotifications.get()].reverse();
|
||||
not.push( id );
|
||||
ShownNotifications.set( not.reverse() );
|
||||
|
||||
// Set delay to remove the notification again
|
||||
if ( removeAgain && Notifications[ id ].notification.get_urgency() !== Notifd.Urgency.CRITICAL ) {
|
||||
timeout( TIMEOUT_DELAY, () => {
|
||||
hideNotification( id );
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Stop displaying notification
|
||||
* @param id The internal id (index in the Notifications array)
|
||||
*/
|
||||
const hideNotification = ( id: number ) => {
|
||||
if ( !notificationMenuOpen ) {
|
||||
const not = [...ShownNotifications.get()];
|
||||
not.splice( not.indexOf( id ), 1 );
|
||||
ShownNotifications.set( not );
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Open the notification menu. Called by toggleNotificationMenu too
|
||||
*/
|
||||
const openNotificationMenu = () => {
|
||||
// Simply show all notifications
|
||||
notificationMenuOpen = true;
|
||||
const not = [];
|
||||
for (let index = 0; index < Notifications.length; index++) {
|
||||
not.push( index );
|
||||
}
|
||||
ShownNotifications.set( not.reverse() );
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Close the notification menu. Called by toggleNotificationMenu too
|
||||
*/
|
||||
const closeNotificationMenu = () => {
|
||||
// Hide all notifications
|
||||
notificationMenuOpen = true;
|
||||
ShownNotifications.set( [] );
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Toggle the notification menu (i.e. show all notifications)
|
||||
*/
|
||||
const toggleNotificationMenu = (): string => {
|
||||
if ( notificationMenuOpen ) {
|
||||
closeNotificationMenu();
|
||||
return 'Toggle notification menu closed';
|
||||
} else {
|
||||
openNotificationMenu();
|
||||
return 'Toggled notification menu open';
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Delete all notifications
|
||||
*/
|
||||
const clearAllNotifications = () => {
|
||||
Notifications = [];
|
||||
ShownNotifications.set( [] );
|
||||
// TODO: Hiding for each individual deleteNotification
|
||||
notificationMenuOpen = false;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Delete the newest notifications
|
||||
*/
|
||||
const clearNewestNotifications = () => {
|
||||
const not = [...ShownNotifications.get()];
|
||||
not.splice( 0, 1 );
|
||||
ShownNotifications.set( not );
|
||||
|
||||
Notifications.splice( Notifications.length - 1, 1 );
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// ╭───────────────────────────────────────────────╮
|
||||
// │ User Interface │
|
||||
// ╰───────────────────────────────────────────────╯
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
const startNotificationHandler = (gdkmonitor: Gdk.Monitor) => {
|
||||
const { TOP, RIGHT } = Astal.WindowAnchor
|
||||
|
||||
hookToNotificationDaemon();
|
||||
|
||||
return <window
|
||||
cssClasses={["NotificationHandler"]}
|
||||
gdkmonitor={gdkmonitor}
|
||||
exclusivity={Astal.Exclusivity.EXCLUSIVE}
|
||||
anchor={TOP | RIGHT}
|
||||
visible={ShownNotifications( list => list.length > 0 )}
|
||||
application={App}>
|
||||
<box vertical>
|
||||
{ShownNotifications( list => list.map( i => {
|
||||
// i is index in ShownNotifications array
|
||||
return <Notification id={i} delete={deleteNotification} notification={Notifications[ i ].notification}></Notification>
|
||||
} ) ) }
|
||||
</box>
|
||||
</window>
|
||||
}
|
||||
|
||||
const cliHandler = ( args: string[] ): string => {
|
||||
if ( args[ 1 ] == 'show' ) {
|
||||
openNotificationMenu();
|
||||
return 'Showing all open notifications';
|
||||
} else if ( args[ 1 ] == 'hide' ) {
|
||||
closeNotificationMenu();
|
||||
return 'Hid all notifications';
|
||||
} else if ( args[ 1 ] == 'clear' ) {
|
||||
clearAllNotifications();
|
||||
return 'Cleared all notifications';
|
||||
} else if ( args[ 1 ] == 'clear-newest' ) {
|
||||
clearNewestNotifications();
|
||||
return 'Cleared newest notification';
|
||||
} else if ( args[ 1 ] == 'toggle' ) {
|
||||
return toggleNotificationMenu();
|
||||
} else if ( args[ 1 ] == 'list' ){
|
||||
if ( Notifications.length > 0 ) {
|
||||
let list = 'Currently unviewed notifications: ';
|
||||
for (let index = 0; index < Notifications.length; index++) {
|
||||
const element = Notifications[index];
|
||||
|
||||
list += `\n - (${element.notifdID}) ${element.notification.get_app_name()}: ${element.notification.get_summary()}`;
|
||||
}
|
||||
return list;
|
||||
} else {
|
||||
return 'No currently unviewed notifications'
|
||||
}
|
||||
} else {
|
||||
return 'Unknown command!';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
startNotificationHandler,
|
||||
cliHandler
|
||||
}
|
||||
12
configs/userland/astal/components/notifications/helper.ts
Normal file
12
configs/userland/astal/components/notifications/helper.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Gtk, Gdk } from "astal/gtk4";
|
||||
import { GLib } from "astal";
|
||||
|
||||
export const isIcon = (icon: string) => {
|
||||
const display = Gdk.Display.get_default();
|
||||
if (!display) return false;
|
||||
const iconTheme = Gtk.IconTheme.get_for_display(display);
|
||||
return iconTheme.has_icon(icon);
|
||||
};
|
||||
|
||||
export const fileExists = (path: string) =>
|
||||
GLib.file_test(path, GLib.FileTest.EXISTS);
|
||||
24
configs/userland/astal/components/notifications/icon.tsx
Normal file
24
configs/userland/astal/components/notifications/icon.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import Notifd from "gi://AstalNotifd";
|
||||
import { fileExists, isIcon } from "./helper";
|
||||
|
||||
|
||||
export function NotificationIcon(notification: Notifd.Notification) {
|
||||
if ( notification.image || notification.appIcon || notification.desktopEntry) {
|
||||
const icon = notification.image || notification.appIcon || notification.desktopEntry;
|
||||
if (fileExists(icon)) {
|
||||
return (
|
||||
<box expand={false} valign={Gtk.Align.CENTER}>
|
||||
<image file={icon} />
|
||||
</box>
|
||||
);
|
||||
} else if (isIcon(icon)) {
|
||||
return (
|
||||
<box expand={false} valign={Gtk.Align.CENTER}>
|
||||
<image iconName={icon} />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
@use "sass:string";
|
||||
|
||||
@function gtkalpha($c, $a) {
|
||||
@return string.unquote("alpha(#{$c},#{$a})");
|
||||
}
|
||||
|
||||
// https://gitlab.gnome.org/GNOME/gtk/-/blob/gtk-3-24/gtk/theme/Adwaita/_colors-public.scss
|
||||
$fg-color: #{"@theme_fg_color"};
|
||||
$bg-color: #{"@theme_bg_color"};
|
||||
$error: red;
|
||||
|
||||
window.NotificationHandler {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
box.notification {
|
||||
|
||||
&:first-child {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
& {
|
||||
min-width: 400px;
|
||||
border-radius: 13px;
|
||||
background-color: $bg-color;
|
||||
margin: .5rem 1rem .5rem 1rem;
|
||||
box-shadow: 2px 3px 8px 0 gtkalpha(black, .4);
|
||||
border: 1pt solid gtkalpha($fg-color, .03);
|
||||
}
|
||||
|
||||
&.critical {
|
||||
border: 1pt solid gtkalpha($error, .4);
|
||||
|
||||
.header {
|
||||
|
||||
.app-name {
|
||||
color: gtkalpha($error, .8);
|
||||
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
color: gtkalpha($error, .6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: .5rem;
|
||||
color: gtkalpha($fg-color, 0.5);
|
||||
|
||||
.app-icon {
|
||||
margin: 0 .4rem;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
margin-right: .3rem;
|
||||
font-weight: bold;
|
||||
|
||||
&:first-child {
|
||||
margin-left: .4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
margin: 0 .4rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: .2rem;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
separator {
|
||||
margin: 0 .4rem;
|
||||
background-color: gtkalpha($fg-color, .1);
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 1rem;
|
||||
margin-top: .5rem;
|
||||
|
||||
.summary {
|
||||
font-size: 1.2em;
|
||||
color: $fg-color;
|
||||
}
|
||||
|
||||
.body {
|
||||
color: gtkalpha($fg-color, 0.8);
|
||||
}
|
||||
|
||||
.image {
|
||||
border: 1px solid gtkalpha($fg-color, .02);
|
||||
margin-right: .5rem;
|
||||
border-radius: 9px;
|
||||
min-width: 100px;
|
||||
min-height: 100px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin: 1rem;
|
||||
margin-top: 0;
|
||||
|
||||
button {
|
||||
margin: 0 .3rem;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// From astal examples
|
||||
|
||||
import { bind, GLib } from "astal";
|
||||
import { Gtk } from "astal/gtk4";
|
||||
import Notifd from "gi://AstalNotifd";
|
||||
import { NotificationIcon } from "./icon";
|
||||
// import Pango from "gi://Pango?version=1.0"
|
||||
|
||||
const fileExists = (path: string) => GLib.file_test(path, GLib.FileTest.EXISTS);
|
||||
|
||||
const time = (time: number, format = "%H:%M") =>
|
||||
GLib.DateTime.new_from_unix_local(time).format(format)!;
|
||||
|
||||
const urgency = (n: Notifd.Notification) => {
|
||||
const { LOW, NORMAL, CRITICAL } = Notifd.Urgency;
|
||||
// match operator when?
|
||||
switch (n.urgency) {
|
||||
case LOW:
|
||||
return "low";
|
||||
case CRITICAL:
|
||||
return "critical";
|
||||
case NORMAL:
|
||||
default:
|
||||
return "normal";
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
delete: (id: number) => void;
|
||||
notification: Notifd.Notification;
|
||||
id: number;
|
||||
};
|
||||
|
||||
export default function Notification(props: Props) {
|
||||
const { notification: n, id: id, delete: del } = props;
|
||||
const { START, CENTER, END } = Gtk.Align;
|
||||
|
||||
return (
|
||||
<box vertical cssClasses={["notification", `${urgency(n)}`]}>
|
||||
<box cssClasses={["header"]}>
|
||||
{n.appIcon || n.desktopEntry ? (
|
||||
<Gtk.Image
|
||||
cssClasses={["app-icon"]}
|
||||
visible={Boolean(n.appIcon || n.desktopEntry)}
|
||||
iconName={n.appIcon || n.desktopEntry}
|
||||
/>
|
||||
) : (
|
||||
<image iconName={"window-close-symbolic"}></image>
|
||||
)}
|
||||
<label
|
||||
cssClasses={["app-name"]}
|
||||
halign={START}
|
||||
// ellipsize={Pango.EllipsizeMode.END}
|
||||
label={n.appName || "Unknown"}
|
||||
/>
|
||||
<label
|
||||
cssClasses={["time"]}
|
||||
hexpand
|
||||
halign={END}
|
||||
label={time(n.time)}
|
||||
/>
|
||||
<button
|
||||
onClicked={() => {
|
||||
del(id);
|
||||
}}
|
||||
child={<image iconName="window-close-symbolic" />}
|
||||
></button>
|
||||
</box>
|
||||
<Gtk.Separator visible />
|
||||
<box cssClasses={["content"]}>
|
||||
<box
|
||||
cssClasses={["image"]}
|
||||
visible={Boolean(NotificationIcon(n))}
|
||||
halign={CENTER}
|
||||
valign={CENTER}
|
||||
vexpand={true}
|
||||
>
|
||||
{NotificationIcon(n)}
|
||||
</box>
|
||||
<box vertical>
|
||||
<label
|
||||
cssClasses={["summary"]}
|
||||
halign={START}
|
||||
xalign={0}
|
||||
useMarkup
|
||||
label={bind(n, "summary")}
|
||||
// ellipsize={Pango.EllipsizeMode.END}
|
||||
/>
|
||||
{n.body && (
|
||||
<label
|
||||
cssClasses={["body"]}
|
||||
valign={CENTER}
|
||||
wrap={true}
|
||||
maxWidthChars={50}
|
||||
label={bind(n, "body")}
|
||||
/>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
{n.get_actions().length > 0 ? (
|
||||
<box cssClasses={["actions"]}>
|
||||
{n.get_actions().map(({ label, id }) => (
|
||||
<button hexpand onClicked={() => n.invoke(id)}>
|
||||
<label label={label} halign={CENTER} hexpand />
|
||||
</button>
|
||||
))}
|
||||
</box>
|
||||
) : (
|
||||
<box></box>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user