diff --git a/lib/icons.ts b/lib/icons.ts new file mode 100644 index 0000000..f6da697 --- /dev/null +++ b/lib/icons.ts @@ -0,0 +1,145 @@ +export const substitutes = { + "transmission-gtk": "transmission", + "blueberry.py": "blueberry", + "Caprine": "facebook-messenger", + "com.raggesilver.BlackBox-symbolic": "terminal-symbolic", + "org.wezfurlong.wezterm-symbolic": "terminal-symbolic", + "audio-headset-bluetooth": "audio-headphones-symbolic", + "audio-card-analog-usb": "audio-speakers-symbolic", + "audio-card-analog-pci": "audio-card-symbolic", + "preferences-system": "emblem-system-symbolic", + "com.github.Aylur.ags-symbolic": "controls-symbolic", + "com.github.Aylur.ags": "controls-symbolic", +} + +export default { + missing: "image-missing-symbolic", + nix: { + nix: "nix-snowflake-symbolic", + }, + app: { + terminal: "terminal-symbolic", + }, + fallback: { + executable: "application-x-executable", + notification: "dialog-information-symbolic", + video: "video-x-generic-symbolic", + audio: "audio-x-generic-symbolic", + }, + ui: { + close: "window-close-symbolic", + colorpicker: "color-select-symbolic", + info: "info-symbolic", + link: "external-link-symbolic", + lock: "system-lock-screen-symbolic", + menu: "open-menu-symbolic", + refresh: "view-refresh-symbolic", + search: "system-search-symbolic", + settings: "emblem-system-symbolic", + themes: "preferences-desktop-theme-symbolic", + tick: "object-select-symbolic", + time: "hourglass-symbolic", + toolbars: "toolbars-symbolic", + warning: "dialog-warning-symbolic", + avatar: "avatar-default-symbolic", + arrow: { + right: "pan-end-symbolic", + left: "pan-start-symbolic", + down: "pan-down-symbolic", + up: "pan-up-symbolic", + }, + }, + audio: { + mic: { + muted: "microphone-disabled-symbolic", + low: "microphone-sensitivity-low-symbolic", + medium: "microphone-sensitivity-medium-symbolic", + high: "microphone-sensitivity-high-symbolic", + }, + volume: { + muted: "audio-volume-muted-symbolic", + low: "audio-volume-low-symbolic", + medium: "audio-volume-medium-symbolic", + high: "audio-volume-high-symbolic", + overamplified: "audio-volume-overamplified-symbolic", + }, + type: { + headset: "audio-headphones-symbolic", + speaker: "audio-speakers-symbolic", + card: "audio-card-symbolic", + }, + mixer: "mixer-symbolic", + }, + powerprofile: { + balanced: "power-profile-balanced-symbolic", + "power-saver": "power-profile-power-saver-symbolic", + performance: "power-profile-performance-symbolic", + }, + asusctl: { + profile: { + Balanced: "power-profile-balanced-symbolic", + Quiet: "power-profile-power-saver-symbolic", + Performance: "power-profile-performance-symbolic", + }, + mode: { + Integrated: "processor-symbolic", + Hybrid: "controller-symbolic", + }, + }, + battery: { + charging: "battery-flash-symbolic", + warning: "battery-empty-symbolic", + }, + bluetooth: { + enabled: "bluetooth-active-symbolic", + disabled: "bluetooth-disabled-symbolic", + }, + brightness: { + indicator: "display-brightness-symbolic", + keyboard: "keyboard-brightness-symbolic", + screen: "display-brightness-symbolic", + }, + powermenu: { + sleep: "weather-clear-night-symbolic", + reboot: "system-reboot-symbolic", + logout: "system-log-out-symbolic", + shutdown: "system-shutdown-symbolic", + }, + recorder: { + recording: "media-record-symbolic", + }, + notifications: { + noisy: "org.gnome.Settings-notifications-symbolic", + silent: "notifications-disabled-symbolic", + message: "chat-bubbles-symbolic", + }, + trash: { + full: "user-trash-full-symbolic", + empty: "user-trash-symbolic", + }, + mpris: { + shuffle: { + enabled: "media-playlist-shuffle-symbolic", + disabled: "media-playlist-consecutive-symbolic", + }, + loop: { + none: "media-playlist-repeat-symbolic", + track: "media-playlist-repeat-song-symbolic", + playlist: "media-playlist-repeat-symbolic", + }, + playing: "media-playback-pause-symbolic", + paused: "media-playback-start-symbolic", + stopped: "media-playback-start-symbolic", + prev: "media-skip-backward-symbolic", + next: "media-skip-forward-symbolic", + }, + system: { + cpu: "org.gnome.SystemMonitor-symbolic", + ram: "drive-harddisk-solidstate-symbolic", + temp: "temperature-symbolic", + }, + color: { + dark: "dark-mode-symbolic", + light: "light-mode-symbolic", + }, +} diff --git a/lib/option.ts b/lib/option.ts new file mode 100644 index 0000000..2cbfbd7 --- /dev/null +++ b/lib/option.ts @@ -0,0 +1,115 @@ +import { Variable } from "resource:///com/github/Aylur/ags/variable.js" + +type OptProps = { + persistent?: boolean +} + +export class Opt extends Variable { + static { Service.register(this) } + + constructor(initial: T, { persistent = false }: OptProps = {}) { + super(initial) + this.initial = initial + this.persistent = persistent + } + + initial: T + id = "" + persistent: boolean + toString() { return `${this.value}` } + toJSON() { return `opt:${this.value}` } + + getValue = (): T => { + return super.getValue() + } + + init(cacheFile: string) { + const cacheV = JSON.parse(Utils.readFile(cacheFile) || "{}")[this.id] + if (cacheV !== undefined) + this.value = cacheV + + this.connect("changed", () => { + const cache = JSON.parse(Utils.readFile(cacheFile) || "{}") + cache[this.id] = this.value + Utils.writeFileSync(JSON.stringify(cache, null, 2), cacheFile) + }) + } + + reset() { + if (this.persistent) + return + + if (JSON.stringify(this.value) !== JSON.stringify(this.initial)) { + this.value = this.initial + return this.id + } + } +} + +export const opt = (initial: T, opts?: OptProps) => new Opt(initial, opts) + +function getOptions(object: object, path = ""): Opt[] { + return Object.keys(object).flatMap(key => { + const obj: Opt = object[key] + const id = path ? path + "." + key : key + + if (obj instanceof Variable) { + obj.id = id + return obj + } + + if (typeof obj === "object") + return getOptions(obj, id) + + return [] + }) +} + +export function mkOptions(cacheFile: string, object: T) { + for (const opt of getOptions(object)) + opt.init(cacheFile) + + Utils.ensureDirectory(cacheFile.split("/").slice(0, -1).join("/")) + + const configFile = `${TMP}/config.json` + const values = getOptions(object).reduce((obj, { id, value }) => ({ [id]: value, ...obj }), {}) + Utils.writeFileSync(JSON.stringify(values, null, 2), configFile) + Utils.monitorFile(configFile, () => { + const cache = JSON.parse(Utils.readFile(configFile) || "{}") + for (const opt of getOptions(object)) { + if (JSON.stringify(cache[opt.id]) !== JSON.stringify(opt.value)) + opt.value = cache[opt.id] + } + }) + + function sleep(ms = 0): Promise { + return new Promise(r => setTimeout(r, ms)) + } + + async function reset( + [opt, ...list] = getOptions(object), + id = opt?.reset(), + ): Promise> { + if (!opt) + return sleep().then(() => []) + + return id + ? [id, ...(await sleep(50).then(() => reset(list)))] + : await sleep().then(() => reset(list)) + } + + return Object.assign(object, { + configFile, + array: () => getOptions(object), + async reset() { + return (await reset()).join("\n") + }, + handler(deps: string[], callback: () => void) { + for (const opt of getOptions(object)) { + if (deps.some(i => opt.id.startsWith(i))) + opt.connect("changed", callback) + } + }, + }) +} + diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..0e3e0cf --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,16 @@ +import GLib from "gi://GLib?version=2.0" + +declare global { + const OPTIONS: string + const TMP: string + const USER: string +} + +Object.assign(globalThis, { + OPTIONS: `${GLib.get_user_cache_dir()}/ags/options.json`, + TMP: `${GLib.get_tmp_dir()}/asztal`, + USER: GLib.get_user_name(), +}) + +Utils.ensureDirectory(TMP) +App.addIcons(`${App.configDir}/assets`) diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..425f455 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { type Application } from "types/service/applications" +import icons, { substitutes } from "./icons" +import Gtk from "gi://Gtk?version=3.0" +import Gdk from "gi://Gdk" +import GLib from "gi://GLib?version=2.0" + +export type Binding = import("types/service").Binding + +/** + * @returns substitute icon || name || fallback icon + */ +export function icon(name: string | null, fallback = icons.missing) { + if (!name) + return fallback || "" + + if (GLib.file_test(name, GLib.FileTest.EXISTS)) + return name + + const icon = (substitutes[name] || name) + if (Utils.lookUpIcon(icon)) + return icon + + print(`no icon substitute "${icon}" for "${name}", fallback: "${fallback}"`) + return fallback +} + +/** + * @returns execAsync(["bash", "-c", cmd]) + */ +export async function bash(strings: TemplateStringsArray | string, ...values: unknown[]) { + const cmd = typeof strings === "string" ? strings : strings + .flatMap((str, i) => str + `${values[i] ?? ""}`) + .join("") + + return Utils.execAsync(["bash", "-c", cmd]).catch(err => { + console.error(cmd, err) + return "" + }) +} + +/** + * @returns execAsync(cmd) + */ +export async function sh(cmd: string | string[]) { + return Utils.execAsync(cmd).catch(err => { + console.error(typeof cmd === "string" ? cmd : cmd.join(" "), err) + return "" + }) +} + +export function forMonitors(widget: (monitor: number) => Gtk.Window) { + const n = Gdk.Display.get_default()?.get_n_monitors() || 1 + return range(n, 0).flatMap(widget) +} + +/** + * @returns [start...length] + */ +export function range(length: number, start = 1) { + return Array.from({ length }, (_, i) => i + start) +} + +/** + * @returns true if all of the `bins` are found + */ +export function dependencies(...bins: string[]) { + const missing = bins.filter(bin => Utils.exec({ + cmd: `which ${bin}`, + out: () => false, + err: () => true, + })) + + if (missing.length > 0) { + console.warn(Error(`missing dependencies: ${missing.join(", ")}`)) + Utils.notify(`missing dependencies: ${missing.join(", ")}`) + } + + return missing.length === 0 +} + +/** + * run app detached + */ +export function launchApp(app: Application) { + const exe = app.executable + .split(/\s+/) + .filter(str => !str.startsWith("%") && !str.startsWith("@")) + .join(" ") + + bash(`${exe} &`) + app.frequency += 1 +} + +/** + * to use with drag and drop + */ +export function createSurfaceFromWidget(widget: Gtk.Widget) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cairo = imports.gi.cairo as any + const alloc = widget.get_allocation() + const surface = new cairo.ImageSurface( + cairo.Format.ARGB32, + alloc.width, + alloc.height, + ) + const cr = new cairo.Context(surface) + cr.setSourceRGBA(255, 255, 255, 0) + cr.rectangle(0, 0, alloc.width, alloc.height) + cr.fill() + widget.draw(cr) + return surface +} diff --git a/lib/variables.ts b/lib/variables.ts new file mode 100644 index 0000000..71c47b4 --- /dev/null +++ b/lib/variables.ts @@ -0,0 +1,16 @@ +import GLib from "gi://GLib" + +export const clock = Variable(GLib.DateTime.new_now_local(), { + poll: [1000, () => GLib.DateTime.new_now_local()], +}) + +export const uptime = Variable(0, { + poll: [60_000, "cat /proc/uptime", line => + Number.parseInt(line.split(".")[0]) / 60, + ], +}) + +export const distro = { + id: GLib.get_os_info("ID"), + logo: GLib.get_os_info("LOGO"), +} diff --git a/modules/bar/Bar.ts b/modules/bar/Bar.ts new file mode 100644 index 0000000..a43bd67 --- /dev/null +++ b/modules/bar/Bar.ts @@ -0,0 +1,65 @@ +import { Menu } from "./menu/index.js"; +import { Workspaces } from "./workspaces/index.js"; +import { ClientTitle } from "./window_title/index.js"; +import { Media } from "./media/index.js"; +import { Notifications } from "./notifications/index.js"; +import { Volume } from "./volume/index.js"; +import { Network } from "./network/index.js"; +import { Bluetooth } from "./bluetooth/index.js"; +import { BatteryLabel } from "./battery/index.js"; +import { Clock } from "./clock/index.js"; +import { SysTray } from "./systray/index.js"; + +import { BarItemBox as WidgetContainer } from "../shared/barItemBox.js"; +import options from "options" + +const { start, center, end } = options.bar.layout +const { transparent, position } = options.bar + +export type BarWidget = keyof typeof widget + +const widget = { + battery: WidgetContainer(BatteryLabel()), + dashboard: WidgetContainer(Menu()), + workspaces: WidgetContainer(Workspaces()), + windowtitle: WidgetContainer(ClientTitle()), + media: WidgetContainer(Media()), + notifications: WidgetContainer(Notifications()), + volume: WidgetContainer(Volume()), + network: WidgetContainer(Network()), + bluetooth: WidgetContainer(Bluetooth()), + clock: WidgetContainer(Clock()), + systray: WidgetContainer(SysTray()), + // expander: () => Widget.Box({ expand: true }), +} + +export const Bar = (monitor: number) => Widget.Window({ + monitor, + class_name: "bar", + name: `bar${monitor}`, + exclusivity: "exclusive", + anchor: position.bind().as(pos => [pos, "right", "left"]), + child: Widget.CenterBox({ + startWidget: Widget.Box({ + class_name: "box-left", + spacing: 5, + hexpand: true, + children: start.bind().as(s => s.map(w => widget[w])), + }), + centerWidget: Widget.Box({ + class_name: "box-center", + hpack: "center", + spacing: 5, + children: center.bind().as(c => c.map(w => widget[w])), + }), + endWidget: Widget.Box({ + class_name: "box-right", + hexpand: true, + spacing: 5, + children: end.bind().as(e => e.map(w => widget[w])), + }), + }), + setup: self => self.hook(transparent, () => { + self.toggleClassName("transparent", transparent.value) + }), +}) diff --git a/modules/bar/utils.js b/modules/bar/utils.js index ddfbaf7..6adfdfe 100644 --- a/modules/bar/utils.js +++ b/modules/bar/utils.js @@ -1,9 +1,19 @@ export const closeAllMenus = () => { - App.closeWindow("bluetoothmenu"); - App.closeWindow("audiomenu"); - App.closeWindow("networkmenu"); - App.closeWindow("mediamenu"); - App.closeWindow("calendarmenu"); + const menuWindows = App.windows + .filter((w) => { + if (w.name) { + return /.*menu/.test(w.name); + } + + return false; + }) + .map((w) => w.name); + + menuWindows.forEach((w) => { + if (w) { + App.closeWindow(w); + } + }); }; export const openMenu = (clicked, event, window) => { @@ -12,8 +22,8 @@ export const openMenu = (clicked, event, window) => { * to the center of the button clicked. We don't want the menu to spawn * offcenter dependending on which edge of the button you click on. * ------------- - * To fix this, we take the x coordinate of the click within the icon's bounds. - * So if you click the left edge of a 100width button then the x axis will be 0 + * To fix this, we take the x coordinate of the click within the button's bounds. + * If you click the left edge of a 100 width button, then the x axis will be 0 * and if you click the right edge then the x axis will be 100. * ------------- * Then we divide the width of the button by 2 to get the center of the button and then get diff --git a/options.ts b/options.ts new file mode 100644 index 0000000..f19e493 --- /dev/null +++ b/options.ts @@ -0,0 +1,186 @@ +import { opt, mkOptions } from "lib/option" +import { distro } from "lib/variables" +import { icon } from "lib/utils" +import icons from "lib/icons" + +const options = mkOptions(OPTIONS, { + autotheme: opt(false), + + theme: { + dark: { + primary: { + bg: opt("#51a4e7"), + fg: opt("#141414"), + }, + error: { + bg: opt("#e55f86"), + fg: opt("#141414"), + }, + bg: opt("#171717"), + fg: opt("#eeeeee"), + widget: opt("#eeeeee"), + border: opt("#eeeeee"), + }, + light: { + primary: { + bg: opt("#426ede"), + fg: opt("#eeeeee"), + }, + error: { + bg: opt("#b13558"), + fg: opt("#eeeeee"), + }, + bg: opt("#fffffa"), + fg: opt("#080808"), + widget: opt("#080808"), + border: opt("#080808"), + }, + + blur: opt(0), + scheme: opt<"dark" | "light">("dark"), + widget: { opacity: opt(94) }, + border: { + width: opt(1), + opacity: opt(96), + }, + }, + + font: { + size: opt(13), + name: opt("Ubuntu Nerd Font"), + }, + + bar: { + flatButtons: opt(true), + position: opt<"top" | "bottom">("top"), + corners: opt(true), + transparent: opt(false), + layout: { + start: opt>([ + "dashboard", + "workspaces", + "windowtitle" + ]), + center: opt>([ + "media" + ]), + end: opt>([ + "volume", + "network", + "bluetooth", + "systray", + "clock", + "notifications" + ]), + }, + launcher: { + icon: { + colored: opt(true), + icon: opt(icon(distro.logo, icons.ui.search)), + }, + label: { + colored: opt(false), + label: opt(" Applications"), + }, + action: opt(() => App.toggleWindow("launcher")), + }, + date: { + format: opt("%H:%M - %A %e."), + action: opt(() => App.toggleWindow("datemenu")), + }, + battery: { + bar: opt<"hidden" | "regular" | "whole">("regular"), + charging: opt("#00D787"), + percentage: opt(true), + blocks: opt(7), + low: opt(30), + }, + workspaces: { + workspaces: opt(7), + }, + taskbar: { + iconSize: opt(0), + monochrome: opt(true), + exclusive: opt(false), + }, + messages: { + action: opt(() => App.toggleWindow("datemenu")), + }, + systray: { + ignore: opt([ + "KDE Connect Indicator", + "spotify-client", + ]), + }, + media: { + monochrome: opt(true), + preferred: opt("spotify"), + direction: opt<"left" | "right">("right"), + format: opt("{artists} - {title}"), + length: opt(40), + }, + powermenu: { + monochrome: opt(false), + action: opt(() => App.toggleWindow("powermenu")), + }, + }, + + overview: { + scale: opt(9), + workspaces: opt(7), + monochromeIcon: opt(true), + }, + + powermenu: { + sleep: opt("systemctl suspend"), + reboot: opt("systemctl reboot"), + logout: opt("pkill Hyprland"), + shutdown: opt("shutdown now"), + layout: opt<"line" | "box">("line"), + labels: opt(true), + }, + + quicksettings: { + avatar: { + image: opt(`/var/lib/AccountsService/icons/${Utils.USER}`), + size: opt(70), + name: opt("Linux User") + }, + }, + + calendarmenu: { + position: opt<"left" | "center" | "right">("center"), + weather: { + interval: opt(60_000), + unit: opt<"metric" | "imperial" | "standard">("metric"), + key: opt( + JSON.parse(Utils.readFile(`${App.configDir}/.weather`) || "{}")?.key || "", + ), + }, + }, + + osd: { + progress: { + vertical: opt(true), + pack: { + h: opt<"start" | "center" | "end">("end"), + v: opt<"start" | "center" | "end">("center"), + }, + }, + microphone: { + pack: { + h: opt<"start" | "center" | "end">("center"), + v: opt<"start" | "center" | "end">("end"), + }, + }, + }, + + notifications: { + position: opt>(["top", "right"]), + blacklist: opt(["Spotify"]), + }, +}) + +globalThis["options"] = options +export default options +