diff options
Diffstat (limited to 'src/services')
| -rw-r--r-- | src/services/apps.ts | 3 | ||||
| -rw-r--r-- | src/services/math.ts | 151 | ||||
| -rw-r--r-- | src/services/monitors.ts | 127 | ||||
| -rw-r--r-- | src/services/players.ts | 154 | ||||
| -rw-r--r-- | src/services/updates.ts | 148 |
5 files changed, 583 insertions, 0 deletions
diff --git a/src/services/apps.ts b/src/services/apps.ts new file mode 100644 index 0000000..5396ac7 --- /dev/null +++ b/src/services/apps.ts @@ -0,0 +1,3 @@ +import AstalApps from "gi://AstalApps"; + +export const Apps = new AstalApps.Apps(); diff --git a/src/services/math.ts b/src/services/math.ts new file mode 100644 index 0000000..c66798c --- /dev/null +++ b/src/services/math.ts @@ -0,0 +1,151 @@ +import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal"; +import { derivative, evaluate, rationalize, simplify } from "mathjs/number"; + +export interface HistoryItem { + equation: string; + result: string; + icon: string; +} + +@register({ GTypeName: "Math" }) +export default class Math extends GObject.Object { + static instance: Math; + static get_default() { + if (!this.instance) this.instance = new Math(); + + return this.instance; + } + + readonly #maxHistory = 20; + readonly #path = `${CACHE}/math-history.json`; + readonly #history: HistoryItem[] = []; + + #variables: Record<string, string> = {}; + #lastExpression: HistoryItem | null = null; + + @property(Object) + get history() { + return this.#history; + } + + #save() { + writeFileAsync(this.#path, JSON.stringify(this.#history)).catch(console.error); + } + + /** + * Commits the last evaluated expression to the history + */ + commit() { + if (!this.#lastExpression) return; + + // Try select first to prevent duplicates, if it fails, add it + if (!this.select(this.#lastExpression)) { + this.#history.unshift(this.#lastExpression); + if (this.#history.length > this.#maxHistory) this.#history.pop(); + this.notify("history"); + this.#save(); + } + this.#lastExpression = null; + } + + /** + * Moves an item in the history to the top + * @param item The item to select + * @returns If the item was successfully selected + */ + select(item: HistoryItem) { + const idx = this.#history.findIndex(i => i.equation === item.equation && i.result === item.result); + if (idx >= 0) { + this.#history.splice(idx, 1); + this.#history.unshift(item); + this.notify("history"); + this.#save(); + + return true; + } + + return false; + } + + /** + * Clears the history and variables + */ + clear() { + if (this.#history.length > 0) { + this.#history.length = 0; + this.notify("history"); + this.#save(); + } + this.#lastExpression = null; + this.#variables = {}; + } + + /** + * Evaluates an equation and adds it to the history + * @param equation The equation to evaluate + * @returns A {@link HistoryItem} representing the result of the equation + */ + evaluate(equation: string): HistoryItem { + if (equation.startsWith("clear")) + return { + equation: "Clear history", + result: "Delete history and previously set variables", + icon: "delete_forever", + }; + + let result: string, icon: string; + try { + if (equation.startsWith("help")) { + equation = "Help"; + result = + "This is a calculator powered by Math.js.\nAvailable functions:\n\thelp: show help\n\tclear: clear history\n\t<x> = <equation>: sets <x> to <equation>\n\tsimplify <equation>: simplifies <equation>\n\tderive <x> <equation>: derives <equation> with respect to <x>\n\tdd<x> <equation>: short form of derive\n\trationalize <equation>: rationalizes <equation>\n\t<equation>: evaluates <equation>\nSee the documentation for syntax and inbuilt functions."; + icon = "help"; + } else if (equation.includes("=")) { + const [left, right] = equation.split("="); + try { + this.#variables[left.trim()] = simplify(right).toString(); + } catch { + this.#variables[left.trim()] = right.trim(); + } + result = this.#variables[left.trim()]; + icon = "equal"; + } else if (equation.startsWith("simplify")) { + result = simplify(equation.slice(8), this.#variables).toString(); + icon = "function"; + } else if (equation.startsWith("derive") || equation.startsWith("dd")) { + const isShortForm = equation.startsWith("dd"); + const respectTo = isShortForm ? equation.split(" ")[0].slice(2) : equation.split(" ")[1]; + if (!respectTo) throw new Error(`Format: ${isShortForm ? "dd" : "derive "}<respect-to> <equation>`); + result = derivative(equation.slice((isShortForm ? 2 : 7) + respectTo.length), respectTo).toString(); + icon = "function"; + } else if (equation.startsWith("rationalize")) { + result = rationalize(equation.slice(11), this.#variables).toString(); + icon = "function"; + } else { + result = evaluate(equation, this.#variables).toString(); + icon = "calculate"; + } + } catch (e) { + equation = "Invalid equation: " + equation; + result = String(e); + icon = "error"; + } + + return (this.#lastExpression = { equation, result, icon }); + } + + constructor() { + super(); + + // Load history + if (GLib.file_test(this.#path, GLib.FileTest.EXISTS)) { + try { + this.#history = JSON.parse(readFile(this.#path)); + // Init eval to create variables and last expression + for (const item of this.#history) this.evaluate(item.equation); + } catch (e) { + console.error("Math - Unable to load history", e); + } + } + } +} diff --git a/src/services/monitors.ts b/src/services/monitors.ts new file mode 100644 index 0000000..78a0161 --- /dev/null +++ b/src/services/monitors.ts @@ -0,0 +1,127 @@ +import { GObject, execAsync, property, register } from "astal"; +import AstalHyprland from "gi://AstalHyprland"; + +@register({ GTypeName: "Monitor" }) +export class Monitor extends GObject.Object { + readonly monitor: AstalHyprland.Monitor; + readonly width: number; + readonly height: number; + readonly id: number; + readonly serial: string; + readonly name: string; + readonly description: string; + + @property(AstalHyprland.Workspace) + get activeWorkspace() { + return this.monitor.activeWorkspace; + } + + isDdc: boolean = false; + busNum?: string; + + #brightness: number = 0; + + @property(Number) + get brightness() { + return this.#brightness; + } + + set brightness(value) { + value = Math.min(1, Math.max(0, value)); + + this.#brightness = value; + this.notify("brightness"); + execAsync( + this.isDdc + ? `ddcutil -b ${this.busNum} setvcp 10 ${Math.round(value * 100)}` + : `brightnessctl set ${Math.floor(value * 100)}% -q` + ).catch(console.error); + } + + constructor(monitor: AstalHyprland.Monitor) { + super(); + + this.monitor = monitor; + this.width = monitor.width; + this.height = monitor.height; + this.id = monitor.id; + this.serial = monitor.serial; + this.name = monitor.name; + this.description = monitor.description; + + monitor.connect("notify::active-workspace", () => this.notify("active-workspace")); + + execAsync("ddcutil detect --brief") + .then(out => { + this.isDdc = out.split("\n\n").some(display => { + if (!/^Display \d+/.test(display)) return false; + const lines = display.split("\n"); + if (lines[3].split(":")[3] !== monitor.serial) return false; + this.busNum = lines[1].split("/dev/i2c-")[1]; + return true; + }); + }) + .catch(() => (this.isDdc = false)) + .finally(async () => { + if (this.isDdc) { + const info = (await execAsync(`ddcutil -b ${this.busNum} getvcp 10 --brief`)).split(" "); + this.#brightness = Number(info[3]) / Number(info[4]); + } else + this.#brightness = + Number(await execAsync("brightnessctl get")) / Number(await execAsync("brightnessctl max")); + }); + } +} + +@register({ GTypeName: "Monitors" }) +export default class Monitors extends GObject.Object { + static instance: Monitors; + static get_default() { + if (!this.instance) this.instance = new Monitors(); + + return this.instance; + } + + readonly #map: Map<number, Monitor> = new Map(); + + @property(Object) + get map() { + return this.#map; + } + + @property(Object) + get list() { + return Array.from(this.#map.values()); + } + + @property(Monitor) + get active() { + return this.#map.get(AstalHyprland.get_default().focusedMonitor.id)!; + } + + #notify() { + this.notify("map"); + this.notify("list"); + } + + forEach(fn: (monitor: Monitor) => void) { + for (const monitor of this.#map.values()) fn(monitor); + } + + constructor() { + super(); + + const hyprland = AstalHyprland.get_default(); + + for (const monitor of hyprland.monitors) this.#map.set(monitor.id, new Monitor(monitor)); + if (this.#map.size > 0) this.#notify(); + + hyprland.connect("monitor-added", (_, monitor) => { + this.#map.set(monitor.id, new Monitor(monitor)); + this.#notify(); + }); + hyprland.connect("monitor-removed", (_, id) => this.#map.delete(id) && this.#notify()); + + hyprland.connect("notify::focused-monitor", () => this.notify("active")); + } +} diff --git a/src/services/players.ts b/src/services/players.ts new file mode 100644 index 0000000..b81d4b5 --- /dev/null +++ b/src/services/players.ts @@ -0,0 +1,154 @@ +import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal"; +import AstalMpris from "gi://AstalMpris"; +import { isRealPlayer } from "../utils/mpris"; + +@register({ GTypeName: "Players" }) +export default class Players extends GObject.Object { + static instance: Players; + static get_default() { + if (!this.instance) this.instance = new Players(); + + return this.instance; + } + + readonly #path = `${CACHE}/players.txt`; + readonly #players: AstalMpris.Player[] = []; + readonly #subs = new Map< + JSX.Element, + { signals: string[]; callback: () => void; ids: number[]; player: AstalMpris.Player | null } + >(); + + @property(AstalMpris.Player) + get lastPlayer(): AstalMpris.Player | null { + return this.#players.length > 0 && this.#players[0].identity !== null ? this.#players[0] : null; + } + + /** + * List of real players. + */ + @property(Object) + get list() { + return this.#players; + } + + hookLastPlayer(widget: JSX.Element, signal: string, callback: () => void): this; + hookLastPlayer(widget: JSX.Element, signals: string[], callback: () => void): this; + hookLastPlayer(widget: JSX.Element, signals: string | string[], callback: () => void) { + if (!Array.isArray(signals)) signals = [signals]; + // Add subscription + if (this.lastPlayer) + this.#subs.set(widget, { + signals, + callback, + ids: signals.map(s => this.lastPlayer!.connect(s, callback)), + player: this.lastPlayer, + }); + else this.#subs.set(widget, { signals, callback, ids: [], player: null }); + + // Remove subscription on widget destroyed + widget.connect("destroy", () => { + const sub = this.#subs.get(widget); + if (sub?.player) sub.ids.forEach(id => sub.player!.disconnect(id)); + this.#subs.delete(widget); + }); + + // Initial run of callback + callback(); + + // For chaining + return this; + } + + makeCurrent(player: AstalMpris.Player) { + const index = this.#players.indexOf(player); + // Ignore if already current + if (index === 0) return; + // Remove if present + else if (index > 0) this.#players.splice(index, 1); + // Connect signals if not already in list (i.e. new player) + else this.#connectPlayerSignals(player); + + // Add to front + this.#players.unshift(player); + this.#updatePlayer(); + + // Save to file + this.#save(); + } + + #updatePlayer() { + this.notify("last-player"); + + for (const sub of this.#subs.values()) { + sub.callback(); + if (sub.player) sub.ids.forEach(id => sub.player!.disconnect(id)); + sub.ids = this.lastPlayer ? sub.signals.map(s => this.lastPlayer!.connect(s, sub.callback)) : []; + sub.player = this.lastPlayer; + } + } + + #save() { + writeFileAsync(this.#path, this.#players.map(p => p.busName).join("\n")).catch(console.error); + } + + #connectPlayerSignals(player: AstalMpris.Player) { + // Change order on attribute change + for (const signal of [ + "notify::playback-status", + "notify::shuffle-status", + "notify::loop-status", + "notify::volume", + "notify::rate", + ]) + player.connect(signal, () => this.makeCurrent(player)); + } + + constructor() { + super(); + + const mpris = AstalMpris.get_default(); + + // Load players + if (GLib.file_test(this.#path, GLib.FileTest.EXISTS)) { + this.#players = readFile(this.#path) + .split("\n") + .map(p => mpris.players.find(p2 => p2.busName === p)) + .filter(isRealPlayer) as AstalMpris.Player[]; + // Add new players from in between sessions + for (const player of mpris.players) + if (!this.#players.includes(player) && isRealPlayer(player)) this.#players.push(player); + } else { + const sortOrder = [ + AstalMpris.PlaybackStatus.PLAYING, + AstalMpris.PlaybackStatus.PAUSED, + AstalMpris.PlaybackStatus.STOPPED, + ]; + this.#players = mpris.players + .filter(isRealPlayer) + .sort((a, b) => sortOrder.indexOf(a.playbackStatus) - sortOrder.indexOf(b.playbackStatus)); + } + this.#updatePlayer(); + this.#save(); + // Connect signals to loaded players + for (const player of this.#players) this.#connectPlayerSignals(player); + + // Add and connect signals when added + mpris.connect("player-added", (_, player) => { + if (isRealPlayer(player)) { + this.makeCurrent(player); + this.notify("list"); + } + }); + + // Remove when closed + mpris.connect("player-closed", (_, player) => { + const index = this.#players.indexOf(player); + if (index >= 0) { + this.#players.splice(index, 1); + this.notify("list"); + if (index === 0) this.#updatePlayer(); + this.#save(); + } + }); + } +} diff --git a/src/services/updates.ts b/src/services/updates.ts new file mode 100644 index 0000000..5bb6bd1 --- /dev/null +++ b/src/services/updates.ts @@ -0,0 +1,148 @@ +import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal"; +import { updates as config } from "../../config"; + +interface Update { + name: string; + full: string; +} + +interface Repo { + repo?: string[]; + updates: Update[]; + icon: string; + name: string; +} + +interface Data { + cached?: boolean; + repos: Repo[]; + errors: string[]; +} + +@register({ GTypeName: "Updates" }) +export default class Updates extends GObject.Object { + static instance: Updates; + static get_default() { + if (!this.instance) this.instance = new Updates(); + + return this.instance; + } + + readonly #cachePath = `${CACHE}/updates.txt`; + + #timeout?: GLib.Source; + #loading = false; + #data: Data = { cached: true, repos: [], errors: [] }; + + @property(Boolean) + get loading() { + return this.#loading; + } + + @property(Object) + get data() { + return this.#data; + } + + @property(Object) + get list() { + return this.#data.repos.map(r => r.updates).flat(); + } + + @property(Number) + get numUpdates() { + return this.#data.repos.reduce((acc, repo) => acc + repo.updates.length, 0); + } + + async #updateFromCache() { + this.#data = JSON.parse(await readFileAsync(this.#cachePath)); + this.notify("data"); + this.notify("list"); + this.notify("num-updates"); + } + + async #getRepo(repo: string) { + return (await execAsync(`bash -c "comm -12 <(pacman -Qq | sort) <(pacman -Slq '${repo}' | sort)"`)).split("\n"); + } + + getUpdates() { + // Return if already getting updates + if (this.#loading) return; + + this.#loading = true; + this.notify("loading"); + + // Get new updates + Promise.allSettled([execAsync("checkupdates"), execAsync("yay -Qua")]) + .then(async ([pacman, yay]) => { + const data: Data = { repos: [], errors: [] }; + + // Pacman updates (checkupdates) + if (pacman.status === "fulfilled") { + const repos: Repo[] = [ + { repo: await this.#getRepo("core"), updates: [], icon: "hub", name: "Core repository" }, + { + repo: await this.#getRepo("extra"), + updates: [], + icon: "add_circle", + name: "Extra repository", + }, + { + repo: await this.#getRepo("multilib"), + updates: [], + icon: "account_tree", + name: "Multilib repository", + }, + ]; + + for (const update of pacman.value.split("\n")) { + const pkg = update.split(" ")[0]; + for (const repo of repos) + if (repo.repo?.includes(pkg)) repo.updates.push({ name: pkg, full: update }); + } + + for (const repo of repos) if (repo.updates.length > 0) data.repos.push(repo); + } + + // AUR and devel updates (yay -Qua) + if (yay.status === "fulfilled") { + const aur: Repo = { updates: [], icon: "deployed_code_account", name: "AUR" }; + + for (const update of yay.value.split("\n")) { + if (/^\s*->/.test(update)) data.errors.push(update); // Error + else aur.updates.push({ name: update.split(" ")[0], full: update }); + } + + if (aur.updates.length > 0) data.repos.push(aur); + } + + if (data.errors.length > 0 && data.repos.length === 0) { + this.#updateFromCache().catch(console.error); + } else { + // Cache and set + writeFileAsync(this.#cachePath, JSON.stringify({ cached: true, ...data })).catch(console.error); + this.#data = data; + this.notify("data"); + this.notify("list"); + this.notify("num-updates"); + } + + this.#loading = false; + this.notify("loading"); + + this.#timeout?.destroy(); + this.#timeout = setTimeout(() => this.getUpdates(), config.interval); + }) + .catch(console.error); + } + + constructor() { + super(); + + // Initial update from cache, if fail then write valid data to cache so future reads don't fail + this.#updateFromCache().catch(() => + writeFileAsync(this.#cachePath, JSON.stringify(this.#data)).catch(console.error) + ); + this.getUpdates(); + } +} |