sideleft: create popdown window

This commit is contained in:
2 * r + 2 * t 2025-01-30 21:31:19 +11:00
parent 31bc49a97a
commit ad22dbdfeb
12 changed files with 598 additions and 21 deletions

View file

@ -84,3 +84,19 @@ export const weather = {
location: "", // Location as a string or empty to autodetect
imperial: false,
};
export const cpu = {
interval: 2000,
};
export const gpu = {
interval: 2000,
};
export const memory = {
interval: 5000,
};
export const storage = {
interval: 5000,
};

View file

@ -114,14 +114,5 @@
color: scheme.$red;
font-weight: bold;
font-size: lib.s(16);
&:hover,
&:focus {
background-color: scheme.$surface1;
}
&:active {
background-color: scheme.$surface2;
}
}
}

View file

@ -4,3 +4,4 @@
@forward "networks";
@forward "media";
@forward "sideright";
@forward "sideleft";

131
scss/popdowns/sideleft.scss Normal file
View file

@ -0,0 +1,131 @@
@use "sass:color";
@use "../scheme";
@use "../lib";
@use "../font";
$-accent: scheme.$yellow;
.sideleft {
@include lib.rounded(8);
@include lib.border($-accent, 0.4, 2);
@include lib.shadow;
@include font.mono;
background-color: scheme.$crust;
color: $-accent;
padding: lib.s(12);
font-size: lib.s(14);
@include lib.spacing(10, true);
& > * {
@include lib.rounded(5);
background-color: scheme.$base;
padding: lib.s(16) lib.s(20);
}
.user {
@include lib.spacing(15);
.face {
@include lib.rounded(1000);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
min-width: lib.s(48);
min-height: lib.s(48);
font-size: lib.s(24);
}
.details {
font-size: lib.s(14);
@include lib.spacing(3, true);
.name {
font-size: lib.s(18);
color: scheme.$text;
}
}
.power {
@include lib.element-decel;
@include font.icon;
font-size: lib.s(24);
color: scheme.$red;
&:hover,
&:focus {
color: color.mix(scheme.$red, scheme.$base, 80%);
}
&:active {
color: color.mix(scheme.$red, scheme.$base, 60%);
}
}
}
.locations {
@include lib.spacing(30);
& > * {
@include lib.spacing(8, true);
}
$-colours: scheme.$pink, scheme.$yellow, scheme.$maroon, scheme.$green, scheme.$mauve, scheme.$lavender;
@for $i from 1 through 6 {
.loc#{$i} {
@include lib.element-decel;
color: nth($-colours, $i);
&:hover,
&:focus {
color: color.mix(nth($-colours, $i), scheme.$base, 80%);
}
&:active {
color: color.mix(nth($-colours, $i), scheme.$base, 60%);
}
}
}
}
.hw-resources {
@include lib.spacing(10, true);
.resource {
font-size: lib.s(13);
.inner {
font-size: lib.s(16);
@include lib.spacing(20);
}
.slider {
@include lib.rounded(5);
@include lib.fluent-decel(1000ms);
min-height: lib.s(10);
background-color: scheme.$surface0;
color: scheme.$red;
}
$-names: gpu, cpu, memory, storage;
$-colours: scheme.$green, scheme.$maroon, scheme.$yellow, scheme.$pink;
@for $i from 1 through length($-names) {
&.#{nth($-names, $i)} {
color: nth($-colours, $i);
.slider {
color: nth($-colours, $i);
}
}
}
}
}
}

View file

@ -1,21 +1,21 @@
import type { Monitor } from "@/services/monitors";
import Players from "@/services/players";
import Updates from "@/services/updates";
import { getAppCategoryIcon } from "@/utils/icons";
import { ellipsize } from "@/utils/strings";
import { bindCurrentTime, osIcon } from "@/utils/system";
import { setupCustomTooltip } from "@/utils/widgets";
import type PopupWindow from "@/widgets/popupwindow";
import { execAsync, register, Variable } from "astal";
import { bind, kebabify } from "astal/binding";
import { App, Astal, astalify, Gdk, Gtk, type ConstructProps } from "astal/gtk3";
import { bar as config } from "config";
import AstalBluetooth from "gi://AstalBluetooth";
import AstalHyprland from "gi://AstalHyprland";
import AstalNetwork from "gi://AstalNetwork";
import AstalNotifd from "gi://AstalNotifd";
import AstalTray from "gi://AstalTray";
import AstalWp01 from "gi://AstalWp";
import { bar as config } from "../../config";
import type { Monitor } from "../services/monitors";
import Players from "../services/players";
import Updates from "../services/updates";
import { getAppCategoryIcon } from "../utils/icons";
import { ellipsize } from "../utils/strings";
import { bindCurrentTime, osIcon } from "../utils/system";
import { setupCustomTooltip } from "../utils/widgets";
import type PopupWindow from "../widgets/popupwindow";
const hyprland = AstalHyprland.get_default();
@ -44,7 +44,13 @@ const togglePopup = (self: JSX.Element, event: Astal.ClickEvent, name: string) =
}
};
const OSIcon = () => <label className="module os-icon" label={osIcon} />;
const OSIcon = () => (
<button
className="module os-icon"
label={osIcon}
onClick={(self, event) => event.button === Astal.MouseButton.PRIMARY && togglePopup(self, event, "sideleft")}
/>
);
const ActiveWindow = () => (
<box
@ -453,7 +459,11 @@ const DateTime = () => (
);
const Power = () => (
<button className="module power" label="power_settings_new" onClicked={() => App.toggle_window("session")} />
<button
className="module power"
label="power_settings_new"
onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && App.toggle_window("session")}
/>
);
export default ({ monitor }: { monitor: Monitor }) => (

View file

@ -2,6 +2,7 @@ import BluetoothDevices from "./bluetoothdevices";
import Media from "./media";
import Networks from "./networks";
import Notifications from "./notifications";
import SideLeft from "./sideleft";
import SideRight from "./sideright";
import Updates from "./updates";
@ -12,6 +13,7 @@ export default () => {
<Networks />;
<Media />;
<SideRight />;
<SideLeft />;
return null;
};

View file

@ -0,0 +1,203 @@
import Cpu from "@/services/cpu";
import Gpu from "@/services/gpu";
import Memory from "@/services/memory";
import Storage from "@/services/storage";
import { osId } from "@/utils/system";
import PopupWindow from "@/widgets/popupwindow";
import { bind, execAsync, GLib, type Binding } from "astal";
import { App, Gtk, type Widget } from "astal/gtk3";
import type cairo from "cairo";
const fmt = (bytes: number, pow: number) => +(bytes / 1024 ** pow).toFixed(2);
const format = ({ total, used }: { total: number; used: number }) => {
if (total >= 1024 ** 4) return `${fmt(used, 4)}/${fmt(total, 4)} TiB`;
if (total >= 1024 ** 3) return `${fmt(used, 3)}/${fmt(total, 3)} GiB`;
if (total >= 1024 ** 2) return `${fmt(used, 2)}/${fmt(total, 2)} MiB`;
if (total >= 1024) return `${fmt(used, 1)}/${fmt(total, 1)} KiB`;
return `${used}/${total} B`;
};
const User = () => (
<box className="user">
<box
valign={Gtk.Align.CENTER}
className="face"
css={`
background-image: url("${HOME}/.face");
`}
>
{!GLib.file_test(HOME + "/.face", GLib.FileTest.EXISTS) && (
<label
setup={self => {
const name = GLib.get_real_name();
if (name !== "Unknown")
self.label = name
.split(" ")
.map(s => s[0].toUpperCase())
.join("");
else self.label = "";
}}
/>
)}
</box>
<box vertical hexpand valign={Gtk.Align.CENTER} className="details">
<label xalign={0} className="name" label={GLib.get_user_name()} />
<label xalign={0} label={(GLib.getenv("XDG_CURRENT_DESKTOP") ?? osId).toUpperCase()} />
</box>
<button
valign={Gtk.Align.CENTER}
className="power"
cursor="pointer"
onClicked={() => App.toggle_window("session")}
label="power_settings_new"
/>
</box>
);
const QuickLaunch = () => (
<box className="quick-launch">
<box vertical>
<box>{/* <button */}</box> // TODO
</box>
</box>
);
const Location = ({ home, label, num }: { home?: boolean; label: string; num: number }) => (
<button
className={"loc" + num}
cursor="pointer"
onClicked={self => {
self.get_toplevel().hide();
execAsync(`xdg-open ${HOME}/${home ? "" : label.split(" ").at(-1) + "/"}`).catch(console.error);
}}
>
<label xalign={0} label={label} />
</button>
);
const Locations = () => (
<box className="locations">
<box vertical>
<Location label="󰉍 Downloads" num={1} />
<Location label="󱧶 Documents" num={2} />
<Location label="󱍙 Music" num={3} />
</box>
<box vertical>
<Location label="󰉏 Pictures" num={4} />
<Location label="󰉏 Videos" num={5} />
<Location label="󱂵 Home" num={6} home />
</box>
</box>
);
const Slider = ({ value }: { value: Binding<number> }) => (
<drawingarea
hexpand
valign={Gtk.Align.CENTER}
className="slider"
css={bind(value).as(v => `font-size: ${v}px;`)}
setup={self => {
const halfPi = Math.PI / 2;
const styleContext = self.get_style_context();
self.set_size_request(-1, styleContext.get_property("min-height", Gtk.StateFlags.NORMAL) as number);
self.connect("draw", (_, cr: cairo.Context) => {
const styleContext = self.get_style_context();
const width = self.get_allocated_width();
const height = styleContext.get_property("min-height", Gtk.StateFlags.NORMAL) as number;
self.set_size_request(-1, height);
const progressValue = styleContext.get_property("font-size", Gtk.StateFlags.NORMAL) as number;
let radius = styleContext.get_property("border-radius", Gtk.StateFlags.NORMAL) as number;
const bg = styleContext.get_background_color(Gtk.StateFlags.NORMAL);
cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha);
// Background
cr.arc(radius, radius, radius, -Math.PI, -halfPi); // Top left
cr.arc(width - radius, radius, radius, -halfPi, 0); // Top right
cr.arc(width - radius, height - radius, radius, 0, halfPi); // Bottom right
cr.arc(radius, height - radius, radius, halfPi, Math.PI); // Bottom left
cr.fill();
// Flatten when near 0
radius = Math.min(radius, Math.min(width * progressValue, height) / 2);
const progressPosition = width * progressValue - radius;
const fg = styleContext.get_color(Gtk.StateFlags.NORMAL);
cr.setSourceRGBA(fg.red, fg.green, fg.blue, fg.alpha);
// Foreground
cr.arc(radius, radius, radius, -Math.PI, -halfPi); // Top left
cr.arc(progressPosition, radius, radius, -halfPi, 0); // Top right
cr.arc(progressPosition, height - radius, radius, 0, halfPi); // Bottom right
cr.arc(radius, height - radius, radius, halfPi, Math.PI); // Bottom left
cr.fill();
});
}}
/>
);
const Resource = ({
icon,
name,
value,
labelSetup,
}: {
icon: string;
name: string;
value: Binding<number>;
labelSetup?: (self: Widget.Label) => void;
}) => (
<box vertical className={`resource ${name}`}>
<box className="inner">
<label label={icon} />
<Slider value={value.as(v => v / 100)} />
</box>
<label halign={Gtk.Align.END} label={labelSetup ? "" : value.as(v => `${+v.toFixed(2)}%`)} setup={labelSetup} />
</box>
);
const HwResources = () => (
<box vertical className="hw-resources">
{Gpu.get_default().available && <Resource icon="󰢮" name="gpu" value={bind(Gpu.get_default(), "usage")} />}
<Resource icon="" name="cpu" value={bind(Cpu.get_default(), "usage")} />
<Resource
icon=""
name="memory"
value={bind(Memory.get_default(), "usage")}
labelSetup={self => {
const mem = Memory.get_default();
const update = () => (self.label = format(mem));
self.hook(mem, "notify::used", update);
self.hook(mem, "notify::total", update);
update();
}}
/>
<Resource
icon="󰋊"
name="storage"
value={bind(Storage.get_default(), "usage")}
labelSetup={self => {
const storage = Storage.get_default();
const update = () => (self.label = format(storage));
self.hook(storage, "notify::used", update);
self.hook(storage, "notify::total", update);
update();
}}
/>
</box>
);
export default () => (
<PopupWindow name="sideleft">
<box vertical className="sideleft">
<User />
{/* <QuickLaunch /> */}
<Locations />
<HwResources />
</box>
</PopupWindow>
);

45
src/services/cpu.ts Normal file
View file

@ -0,0 +1,45 @@
import { GObject, interval, property, register } from "astal";
import { cpu as config } from "config";
import GTop from "gi://GTop";
@register({ GTypeName: "Cpu" })
export default class Cpu extends GObject.Object {
static instance: Cpu;
static get_default() {
if (!this.instance) this.instance = new Cpu();
return this.instance;
}
#previous: GTop.glibtop_cpu = new GTop.glibtop_cpu();
#usage: number = 0;
@property(Number)
get usage() {
return this.#usage;
}
calculateUsage() {
const current = new GTop.glibtop_cpu();
GTop.glibtop_get_cpu(current);
// Calculate the differences from the previous to current data
const total = current.total - this.#previous.total;
const idle = current.idle - this.#previous.idle;
this.#previous = current;
return total > 0 ? ((total - idle) / total) * 100 : 0;
}
update() {
this.#usage = this.calculateUsage();
this.notify("usage");
}
constructor() {
super();
interval(config.interval, () => this.update());
}
}

55
src/services/gpu.ts Normal file
View file

@ -0,0 +1,55 @@
import { execAsync, Gio, GObject, interval, property, register } from "astal";
import { gpu as config } from "config";
@register({ GTypeName: "Gpu" })
export default class Gpu extends GObject.Object {
static instance: Gpu;
static get_default() {
if (!this.instance) this.instance = new Gpu();
return this.instance;
}
readonly available: boolean = false;
#usage: number = 0;
@property(Number)
get usage() {
return this.#usage;
}
async calculateUsage() {
const percs = (await execAsync("fish -c 'cat /sys/class/drm/card*/device/gpu_busy_percent'")).split("\n");
return percs.reduce((a, b) => a + parseFloat(b), 0) / percs.length;
}
update() {
this.calculateUsage().then(usage => {
this.#usage = usage;
this.notify("usage");
});
}
constructor() {
super();
let enumerator = null;
try {
enumerator = Gio.File.new_for_path("/sys/class/drm").enumerate_children(
Gio.FILE_ATTRIBUTE_STANDARD_NAME,
Gio.FileQueryInfoFlags.NONE,
null
);
} catch {}
let info: Gio.FileInfo | undefined | null;
while ((info = enumerator?.next_file(null))) {
if (/card[0-9]+/.test(info.get_name())) {
this.available = true;
break;
}
}
if (this.available) interval(config.interval, () => this.update());
}
}

60
src/services/memory.ts Normal file
View file

@ -0,0 +1,60 @@
import { GObject, interval, property, readFileAsync, register } from "astal";
import { memory as config } from "config";
@register({ GTypeName: "Memory" })
export default class Memory extends GObject.Object {
static instance: Memory;
static get_default() {
if (!this.instance) this.instance = new Memory();
return this.instance;
}
#total: number = 0;
#free: number = 0;
#used: number = 0;
#usage: number = 0;
@property(Number)
get total() {
return this.#total;
}
@property(Number)
get free() {
return this.#free;
}
@property(Number)
get used() {
return this.#used;
}
@property(Number)
get usage() {
return this.#usage;
}
async update() {
const info = await readFileAsync("/proc/meminfo");
this.#total = parseInt(info.match(/MemTotal:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
this.#free = parseInt(info.match(/MemAvailable:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
if (isNaN(this.#total)) this.#total = 0;
if (isNaN(this.#free)) this.#free = 0;
this.#used = this.#total - this.#free;
this.#usage = this.#total > 0 ? (this.#used / this.#total) * 100 : 0;
this.notify("total");
this.notify("free");
this.notify("used");
this.notify("usage");
}
constructor() {
super();
interval(config.interval, () => this.update().catch(console.error));
}
}

61
src/services/storage.ts Normal file
View file

@ -0,0 +1,61 @@
import { GObject, interval, property, register } from "astal";
import { storage as config } from "config";
import GTop from "gi://GTop";
@register({ GTypeName: "Storage" })
export default class Storage extends GObject.Object {
static instance: Storage;
static get_default() {
if (!this.instance) this.instance = new Storage();
return this.instance;
}
#total: number = 0;
#free: number = 0;
#used: number = 0;
#usage: number = 0;
@property(Number)
get total() {
return this.#total;
}
@property(Number)
get free() {
return this.#free;
}
@property(Number)
get used() {
return this.#used;
}
@property(Number)
get usage() {
return this.#usage;
}
update() {
const root = new GTop.glibtop_fsusage();
GTop.glibtop_get_fsusage(root, "/");
const home = new GTop.glibtop_fsusage();
GTop.glibtop_get_fsusage(home, "/home");
this.#total = root.blocks * root.block_size + home.blocks * home.block_size;
this.#free = root.bavail * root.block_size + home.bavail * home.block_size;
this.#used = this.#total - this.#free;
this.#usage = this.#total > 0 ? (this.#used / this.#total) * 100 : 0;
this.notify("total");
this.notify("free");
this.notify("used");
this.notify("usage");
}
constructor() {
super();
interval(config.interval, () => this.update());
}
}

View file

@ -12,7 +12,9 @@
"jsxImportSource": "/usr/share/astal/gjs/gtk3",
"paths": {
"astal": ["/usr/share/astal/gjs"],
"astal/*": ["/usr/share/astal/gjs/*"]
"astal/*": ["/usr/share/astal/gjs/*"],
"config": ["./config"],
"@/*": ["./src/*"]
}
}
}