diff options
Diffstat (limited to 'src')
62 files changed, 0 insertions, 8270 deletions
diff --git a/src/config/defaults.ts b/src/config/defaults.ts deleted file mode 100644 index a5ebbbc..0000000 --- a/src/config/defaults.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Astal } from "astal/gtk3"; - -export default { - style: { - transparency: "normal", // One of "off", "low", "normal", "high" - borders: true, - vibrant: false, // Extra saturation - }, - config: { - notifyOnError: true, - }, - // Modules - bar: { - vertical: true, - style: "gaps", // One of "gaps", "panel", "embedded" - layout: { - type: "centerbox", // One of "centerbox", "flowbox" - centerbox: { - start: ["osIcon", "activeWindow", "mediaPlaying", "brightnessSpacer"], - center: ["workspaces"], - end: [ - "volumeSpacer", - "tray", - "statusIcons", - "pkgUpdates", - "notifCount", - "battery", - "dateTime", - "power", - ], - }, - flowbox: [ - "osIcon", - "workspaces", - "brightnessSpacer", - "activeWindow", - "volumeSpacer", - "dateTime", - "tray", - "battery", - "statusIcons", - "notifCount", - "power", - ], - }, - modules: { - workspaces: { - shown: 5, - showLabels: false, - labels: ["", "", "", "", ""], - xalign: -1, - showWindows: false, - }, - dateTime: { - format: "%d/%m/%y %R", - detailedFormat: "%c", - }, - }, - }, - launcher: { - style: "lines", // One of "lines", "round" - actionPrefix: ">", // Prefix for launcher actions - apps: { - maxResults: 30, // Actual max results, -1 for infinite - }, - files: { - maxResults: 40, // Actual max results, -1 for infinite - fdOpts: ["-a", "-t", "f"], // Options to pass to `fd` - shortenThreshold: 30, // Threshold to shorten paths in characters - }, - math: { - maxResults: 40, // Actual max results, -1 for infinite - }, - todo: { - notify: true, - }, - wallpaper: { - maxResults: 20, // Actual max results, -1 for infinite - showAllEmpty: true, // Show all wallpapers when search is empty - style: "medium", // One of "compact", "medium", "large" - }, - disabledActions: ["logout", "shutdown", "reboot", "hibernate"], // Actions to hide, see launcher/actions.tsx for available actions - }, - notifpopups: { - maxPopups: -1, - expire: false, - agoTime: true, // Whether to show time in ago format, e.g. 10 mins ago, or raw time, e.g. 10:42 - }, - osds: { - volume: { - position: Astal.WindowAnchor.RIGHT, // Top = 2, Right = 4, Left = 8, Bottom = 16 - margin: 20, - hideDelay: 1500, - showValue: true, - }, - brightness: { - position: Astal.WindowAnchor.LEFT, // Top = 2, Right = 4, Left = 8, Bottom = 16 - margin: 20, - hideDelay: 1500, - showValue: true, - }, - lock: { - spacing: 5, - caps: { - hideDelay: 1000, - }, - num: { - hideDelay: 1000, - }, - }, - }, - sidebar: { - showOnStartup: false, - modules: { - headlines: { - enabled: true, - }, - }, - }, - navbar: { - persistent: false, // Whether to show all the time or only on hover - appearWidth: 10, // The width in pixels of the hover area for the navbar to show up - showLabels: false, // Whether to show labels for active buttons - }, - // Services - math: { - maxHistory: 100, - }, - updates: { - interval: 900000, - }, - weather: { - interval: 600000, - apiKey: "", // An API key from https://weatherapi.com for accessing weather data - location: "", // Location as a string or empty to autodetect - imperial: false, - }, - cpu: { - interval: 2000, - }, - gpu: { - interval: 2000, - }, - memory: { - interval: 5000, - }, - storage: { - interval: 5000, - }, - wallpapers: { - paths: [ - { - recursive: true, // Whether to search recursively - path: "~/Pictures/Wallpapers", // Path to search - threshold: 0.8, // The threshold to filter wallpapers by size (e.g. 0.8 means wallpaper must be at least 80% of the screen size), 0 to disable - }, - ], - }, - calendar: { - webcals: [] as string[], // An array of urls to ICS files which you can curl - upcomingDays: 7, // Number of days which count as upcoming - notify: true, - }, - thumbnailer: { - maxAttempts: 5, - timeBetweenAttempts: 300, - defaults: { - width: 100, - height: 100, - exact: true, - }, - }, - news: { - apiKey: "", // An API key from https://newsdata.io for accessing news - countries: ["current"], // A list of country codes or "current" for the current location - categories: ["business", "top", "technology", "world"], // A list of news categories to filter by - languages: ["en"], // A list of languages codes to filter by - domains: [] as string[], // A list of news domains to pull from, see https://newsdata.io/news-sources for available domains - excludeDomains: ["news.google.com"], // A list of news domains to exclude, e.g. bbc.co.uk - timezone: "", // A timezone to filter by, e.g. "America/New_York", see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones - pages: 3, // Number of pages to pull (each page is 10 articles) - }, -}; diff --git a/src/config/funcs.ts b/src/config/funcs.ts deleted file mode 100644 index 77ee8dd..0000000 --- a/src/config/funcs.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { notify } from "@/utils/system"; -import { Gio, GLib, monitorFile, readFileAsync, Variable, writeFileAsync } from "astal"; -import config from "."; -import { loadStyleAsync } from "../../app"; -import defaults from "./defaults"; -import types from "./types"; - -type Settings<T> = { [P in keyof T]: T[P] extends object & { length?: never } ? Settings<T[P]> : Variable<T[P]> }; - -const CONFIG = `${GLib.get_user_config_dir()}/caelestia/shell.json`; - -const warn = (msg: string) => { - console.warn(`[CONFIG] ${msg}`); - if (config.config.notifyOnError.get()) - notify({ - summary: "Invalid config", - body: msg, - icon: "dialog-error-symbolic", - urgency: "critical", - }); -}; - -const isObject = (o: any): o is object => typeof o === "object" && o !== null && !Array.isArray(o); - -const isCorrectType = (v: any, type: string | string[] | number[], path: string) => { - if (Array.isArray(type)) { - // type is array of valid values - if (!type.includes(v as never)) { - warn(`Invalid value for ${path}: ${v} != ${type.map(v => `"${v}"`).join(" | ")}`); - return false; - } - } else if (type.startsWith("array of ")) { - // Array of ... - if (Array.isArray(v)) { - // Remove invalid items but always return true - const arrType = type.slice(9); - try { - // Recursively check type - const type = JSON.parse(arrType); - if (Array.isArray(type)) { - v.splice(0, v.length, ...v.filter((item, i) => isCorrectType(item, type, `${path}[${i}]`))); - } else { - const valid = v.filter((item, i) => - Object.entries(type).every(([k, t]) => { - if (!item.hasOwnProperty(k)) { - warn(`Invalid shape for ${path}[${i}]: ${JSON.stringify(item)} != ${arrType}`); - return false; - } - return isCorrectType(item[k], t as any, `${path}[${i}].${k}`); - }) - ); - v.splice(0, v.length, ...valid); // In-place filter - } - } catch { - const valid = v.filter((item, i) => { - if (typeof item !== arrType) { - warn(`Invalid type for ${path}[${i}]: ${typeof item} != ${arrType}`); - return false; - } - return true; - }); - v.splice(0, v.length, ...valid); // In-place filter - } - } else { - // Type is array but value is not - warn(`Invalid type for ${path}: ${typeof v} != ${type}`); - return false; - } - } else if (typeof v !== type) { - // Value is not correct type - warn(`Invalid type for ${path}: ${typeof v} != ${type}`); - return false; - } - - return true; -}; - -const deepMerge = <T extends object, U extends object>(a: T, b: U, path = ""): T & U => { - const merged: { [k: string]: any } = { ...b }; - for (const [k, v] of Object.entries(a)) { - if (b.hasOwnProperty(k)) { - const bv = b[k as keyof U]; - if (isObject(v) && isObject(bv)) merged[k] = deepMerge(v, bv, `${path}${k}.`); - else if (!isCorrectType(bv, types[path + k], path + k)) merged[k] = v; - } else merged[k] = v; - } - return merged as any; -}; - -export const convertSettings = <T extends object>(obj: T): Settings<T> => - Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, isObject(v) ? convertSettings(v) : Variable(v)])) as any; - -const updateSection = (from: { [k: string]: any }, to: { [k: string]: any }, path = "") => { - for (const [k, v] of Object.entries(from)) { - if (to.hasOwnProperty(k)) { - if (isObject(v)) updateSection(v, to[k], `${path}${k}.`); - else if (!Array.isArray(v) || JSON.stringify(to[k].get()) !== JSON.stringify(v)) to[k].set(v); - } else warn(`Unknown config key: ${path}${k}`); - } -}; - -export const updateConfig = async () => { - if (GLib.file_test(CONFIG, GLib.FileTest.EXISTS)) - updateSection(deepMerge(defaults, JSON.parse(await readFileAsync(CONFIG))), config); - else updateSection(defaults, config); - await loadStyleAsync(); - console.log("[LOG] Config updated"); -}; - -export const initConfig = async () => { - monitorFile(CONFIG, (_, e) => { - if (e === Gio.FileMonitorEvent.CHANGES_DONE_HINT || e === Gio.FileMonitorEvent.DELETED) - updateConfig().catch(warn); - }); - await updateConfig().catch(warn); -}; - -export const setConfig = async (path: string, value: any) => { - const conf = JSON.parse(await readFileAsync(CONFIG)); - let obj = conf; - for (const p of path.split(".").slice(0, -1)) obj = obj[p]; - obj[path.split(".").at(-1)!] = value; - await writeFileAsync(CONFIG, JSON.stringify(conf, null, 4)); -}; diff --git a/src/config/index.ts b/src/config/index.ts deleted file mode 100644 index 80b4dc4..0000000 --- a/src/config/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import defaults from "./defaults"; -import { convertSettings } from "./funcs"; - -const config = convertSettings(defaults); - -export const { - style, - bar, - launcher, - notifpopups, - osds, - sidebar, - navbar, - math, - updates, - weather, - cpu, - gpu, - memory, - storage, - wallpapers, - calendar, - thumbnailer, - news, -} = config; -export default config; diff --git a/src/config/literals.ts b/src/config/literals.ts deleted file mode 100644 index 1908c71..0000000 --- a/src/config/literals.ts +++ /dev/null @@ -1,336 +0,0 @@ -export const BAR_MODULES = [ - "osIcon", - "activeWindow", - "mediaPlaying", - "brightnessSpacer", - "workspaces", - "volumeSpacer", - "tray", - "statusIcons", - "pkgUpdates", - "notifCount", - "battery", - "dateTime", - "power", -]; - -export const NEWS_COUNTRIES = [ - "af", // Afghanistan - "al", // Albania - "dz", // Algeria - "ad", // Andorra - "ao", // Angola - "ar", // Argentina - "am", // Armenia - "au", // Australia - "at", // Austria - "az", // Azerbaijan - "bs", // Bahamas - "bh", // Bahrain - "bd", // Bangladesh - "bb", // Barbados - "by", // Belarus - "be", // Belgium - "bz", // Belize - "bj", // Benin - "bm", // Bermuda - "bt", // Bhutan - "bo", // Bolivia - "ba", // Bosnia And Herzegovina - "bw", // Botswana - "br", // Brazil - "bn", // Brunei - "bg", // Bulgaria - "bf", // Burkina fasco - "bi", // Burundi - "kh", // Cambodia - "cm", // Cameroon - "ca", // Canada - "cv", // Cape Verde - "ky", // Cayman Islands - "cf", // Central African Republic - "td", // Chad - "cl", // Chile - "cn", // China - "co", // Colombia - "km", // Comoros - "cg", // Congo - "ck", // Cook islands - "cr", // Costa Rica - "hr", // Croatia - "cu", // Cuba - "cw", // Curaçao - "cy", // Cyprus - "cz", // Czech republic - "dk", // Denmark - "dj", // Djibouti - "dm", // Dominica - "do", // Dominican republic - "cd", // DR Congo - "ec", // Ecuador - "eg", // Egypt - "sv", // El Salvador - "gq", // Equatorial Guinea - "er", // Eritrea - "ee", // Estonia - "sz", // Eswatini - "et", // Ethiopia - "fj", // Fiji - "fi", // Finland - "fr", // France - "pf", // French polynesia - "ga", // Gabon - "gm", // Gambia - "ge", // Georgia - "de", // Germany - "gh", // Ghana - "gi", // Gibraltar - "gr", // Greece - "gd", // Grenada - "gt", // Guatemala - "gn", // Guinea - "gy", // Guyana - "ht", // Haiti - "hn", // Honduras - "hk", // Hong kong - "hu", // Hungary - "is", // Iceland - "in", // India - "id", // Indonesia - "ir", // Iran - "iq", // Iraq - "ie", // Ireland - "il", // Israel - "it", // Italy - "ci", // Ivory Coast - "jm", // Jamaica - "jp", // Japan - "je", // Jersey - "jo", // Jordan - "kz", // Kazakhstan - "ke", // Kenya - "ki", // Kiribati - "xk", // Kosovo - "kw", // Kuwait - "kg", // Kyrgyzstan - "la", // Laos - "lv", // Latvia - "lb", // Lebanon - "ls", // Lesotho - "lr", // Liberia - "ly", // Libya - "li", // Liechtenstein - "lt", // Lithuania - "lu", // Luxembourg - "mo", // Macau - "mk", // Macedonia - "mg", // Madagascar - "mw", // Malawi - "my", // Malaysia - "mv", // Maldives - "ml", // Mali - "mt", // Malta - "mh", // Marshall Islands - "mr", // Mauritania - "mu", // Mauritius - "mx", // Mexico - "fm", // Micronesia - "md", // Moldova - "mc", // Monaco - "mn", // Mongolia - "me", // Montenegro - "ma", // Morocco - "mz", // Mozambique - "mm", // Myanmar - "na", // Namibia - "nr", // Nauru - "np", // Nepal - "nl", // Netherland - "nc", // New caledonia - "nz", // New zealand - "ni", // Nicaragua - "ne", // Niger - "ng", // Nigeria - "kp", // North korea - "no", // Norway - "om", // Oman - "pk", // Pakistan - "pw", // Palau - "ps", // Palestine - "pa", // Panama - "pg", // Papua New Guinea - "py", // Paraguay - "pe", // Peru - "ph", // Philippines - "pl", // Poland - "pt", // Portugal - "pr", // Puerto rico - "qa", // Qatar - "ro", // Romania - "ru", // Russia - "rw", // Rwanda - "lc", // Saint lucia - "sx", // Saint martin(dutch) - "ws", // Samoa - "sm", // San Marino - "st", // Sao tome and principe - "sa", // Saudi arabia - "sn", // Senegal - "rs", // Serbia - "sc", // Seychelles - "sl", // Sierra Leone - "sg", // Singapore - "sk", // Slovakia - "si", // Slovenia - "sb", // Solomon Islands - "so", // Somalia - "za", // South africa - "kr", // South korea - "es", // Spain - "lk", // Sri Lanka - "sd", // Sudan - "sr", // Suriname - "se", // Sweden - "ch", // Switzerland - "sy", // Syria - "tw", // Taiwan - "tj", // Tajikistan - "tz", // Tanzania - "th", // Thailand - "tl", // Timor-Leste - "tg", // Togo - "to", // Tonga - "tt", // Trinidad and tobago - "tn", // Tunisia - "tr", // Turkey - "tm", // Turkmenistan - "tv", // Tuvalu - "ug", // Uganda - "ua", // Ukraine - "ae", // United arab emirates - "gb", // United kingdom - "us", // United states of america - "uy", // Uruguay - "uz", // Uzbekistan - "vu", // Vanuatu - "va", // Vatican - "ve", // Venezuela - "vi", // Vietnam - "vg", // Virgin Islands (British) - "wo", // World - "ye", // Yemen - "zm", // Zambia - "zw", // Zimbabwe -]; - -export const NEWS_CATEGORIES = [ - "business", - "crime", - "domestic", - "education", - "entertainment", - "environment", - "food", - "health", - "lifestyle", - "other", - "politics", - "science", - "sports", - "technology", - "top", - "tourism", - "world", -]; - -export const NEWS_LANGUAGES = [ - "af", // Afrikaans - "sq", // Albanian - "am", // Amharic - "ar", // Arabic - "hy", // Armenian - "as", // Assamese - "az", // Azerbaijani - "bm", // Bambara - "eu", // Basque - "be", // Belarusian - "bn", // Bengali - "bs", // Bosnian - "bg", // Bulgarian - "my", // Burmese - "ca", // Catalan - "ckb", // Central Kurdish - "zh", // Chinese - "hr", // Croatian - "cs", // Czech - "da", // Danish - "nl", // Dutch - "en", // English - "et", // Estonian - "pi", // Filipino - "fi", // Finnish - "fr", // French - "gl", // Galician - "ka", // Georgian - "de", // German - "el", // Greek - "gu", // Gujarati - "ha", // Hausa - "he", // Hebrew - "hi", // Hindi - "hu", // Hungarian - "is", // Icelandic - "id", // Indonesian - "it", // Italian - "jp", // Japanese - "kn", // Kannada - "kz", // Kazakh - "kh", // Khmer - "rw", // Kinyarwanda - "ko", // Korean - "ku", // Kurdish - "lv", // Latvian - "lt", // Lithuanian - "lb", // Luxembourgish - "mk", // Macedonian - "ms", // Malay - "ml", // Malayalam - "mt", // Maltese - "mi", // Maori - "mr", // Marathi - "mn", // Mongolian - "ne", // Nepali - "no", // Norwegian - "or", // Oriya - "ps", // Pashto - "fa", // Persian - "pl", // Polish - "pt", // Portuguese - "pa", // Punjabi - "ro", // Romanian - "ru", // Russian - "sm", // Samoan - "sr", // Serbian - "sn", // Shona - "sd", // Sindhi - "si", // Sinhala - "sk", // Slovak - "sl", // Slovenian - "so", // Somali - "es", // Spanish - "sw", // Swahili - "sv", // Swedish - "tg", // Tajik - "ta", // Tamil - "te", // Telugu - "th", // Thai - "zht", // Traditional chinese - "tr", // Turkish - "tk", // Turkmen - "uk", // Ukrainian - "ur", // Urdu - "uz", // Uzbek - "vi", // Vietnamese - "cy", // Welsh - "zu", // Zulu -]; diff --git a/src/config/types.ts b/src/config/types.ts deleted file mode 100644 index c8fb9b4..0000000 --- a/src/config/types.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { BAR_MODULES, NEWS_CATEGORIES, NEWS_COUNTRIES, NEWS_LANGUAGES } from "./literals"; - -const BOOL = "boolean"; -const STR = "string"; -const NUM = "number"; -const ARR = (type: string | string[]) => `array of ${typeof type === "string" ? type : JSON.stringify(type)}`; -const OBJ_ARR = (shape: object) => ARR(JSON.stringify(shape)); - -export default { - "style.transparency": ["off", "low", "normal", "high"], - "style.borders": BOOL, - "style.vibrant": BOOL, - "config.notifyOnError": BOOL, - // Bar - "bar.vertical": BOOL, - "bar.style": ["gaps", "panel", "embedded"], - "bar.layout.type": ["centerbox", "flowbox"], - "bar.layout.centerbox.start": ARR(BAR_MODULES), - "bar.layout.centerbox.center": ARR(BAR_MODULES), - "bar.layout.centerbox.end": ARR(BAR_MODULES), - "bar.layout.flowbox": ARR(BAR_MODULES), - "bar.modules.workspaces.shown": NUM, - "bar.modules.workspaces.showLabels": BOOL, - "bar.modules.workspaces.labels": ARR(STR), - "bar.modules.workspaces.xalign": NUM, - "bar.modules.workspaces.showWindows": BOOL, - "bar.modules.dateTime.format": STR, - "bar.modules.dateTime.detailedFormat": STR, - // Launcher - "launcher.style": ["lines", "round"], - "launcher.actionPrefix": STR, - "launcher.apps.maxResults": NUM, - "launcher.files.maxResults": NUM, - "launcher.files.fdOpts": ARR(STR), - "launcher.files.shortenThreshold": NUM, - "launcher.math.maxResults": NUM, - "launcher.todo.notify": BOOL, - "launcher.wallpaper.maxResults": NUM, - "launcher.wallpaper.showAllEmpty": BOOL, - "launcher.wallpaper.style": ["compact", "medium", "large"], - "launcher.disabledActions": ARR(STR), - // Notif popups - "notifpopups.maxPopups": NUM, - "notifpopups.expire": BOOL, - "notifpopups.agoTime": BOOL, - // OSDs - "osds.volume.position": [2, 4, 8, 16], - "osds.volume.margin": NUM, - "osds.volume.hideDelay": NUM, - "osds.volume.showValue": BOOL, - "osds.brightness.position": [2, 4, 8, 16], - "osds.brightness.margin": NUM, - "osds.brightness.hideDelay": NUM, - "osds.brightness.showValue": BOOL, - "osds.lock.spacing": NUM, - "osds.lock.caps.hideDelay": NUM, - "osds.lock.num.hideDelay": NUM, - // Sidebar - "sidebar.showOnStartup": BOOL, - "sidebar.modules.headlines.enabled": BOOL, - // Navbar - "navbar.persistent": BOOL, - "navbar.appearWidth": NUM, - "navbar.showLabels": BOOL, - // Services - "math.maxHistory": NUM, - "updates.interval": NUM, - "weather.interval": NUM, - "weather.apiKey": STR, - "weather.location": STR, - "weather.imperial": BOOL, - "cpu.interval": NUM, - "gpu.interval": NUM, - "memory.interval": NUM, - "storage.interval": NUM, - "wallpapers.paths": OBJ_ARR({ recursive: BOOL, path: STR, threshold: NUM }), - "calendar.webcals": ARR(STR), - "calendar.upcomingDays": NUM, - "calendar.notify": BOOL, - "thumbnailer.maxAttempts": NUM, - "thumbnailer.timeBetweenAttempts": NUM, - "thumbnailer.defaults.width": NUM, - "thumbnailer.defaults.height": NUM, - "thumbnailer.defaults.exact": BOOL, - "news.apiKey": STR, - "news.countries": ARR(NEWS_COUNTRIES), - "news.categories": ARR(NEWS_CATEGORIES), - "news.languages": ARR(NEWS_LANGUAGES), - "news.domains": ARR(STR), - "news.excludeDomains": ARR(STR), - "news.timezone": STR, - "news.pages": NUM, -} as { [k: string]: string | string[] | number[] }; diff --git a/src/modules/bar.tsx b/src/modules/bar.tsx deleted file mode 100644 index c131029..0000000 --- a/src/modules/bar.tsx +++ /dev/null @@ -1,703 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import Players from "@/services/players"; -import Updates from "@/services/updates"; -import { getAppCategoryIcon } from "@/utils/icons"; -import { bindCurrentTime, osIcon } from "@/utils/system"; -import type { AstalWidget } from "@/utils/types"; -import { setupCustomTooltip } from "@/utils/widgets"; -import ScreenCorner from "@/widgets/screencorner"; -import { execAsync, GLib, Variable } from "astal"; -import { bind, kebabify } from "astal/binding"; -import { App, Astal, Gtk, Widget } from "astal/gtk3"; -import { bar as config } from "config"; -import AstalBattery from "gi://AstalBattery"; -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 AstalWp from "gi://AstalWp"; -import { switchPane } from "./sidebar"; - -interface ClassNameProps { - beforeSpacer: boolean; - afterSpacer: boolean; - first: boolean; - last: boolean; -} - -interface ModuleProps extends ClassNameProps { - monitor: Monitor; -} - -const hyprland = AstalHyprland.get_default(); - -const getBatteryIcon = (perc: number) => { - if (perc < 0.1) return ""; - if (perc < 0.2) return ""; - if (perc < 0.3) return ""; - if (perc < 0.4) return ""; - if (perc < 0.5) return ""; - if (perc < 0.6) return ""; - if (perc < 0.7) return ""; - if (perc < 0.8) return ""; - if (perc < 0.9) return ""; - return ""; -}; - -const formatSeconds = (sec: number) => { - if (sec >= 3600) { - const hours = Math.floor(sec / 3600); - let str = `${hours} hour${hours === 1 ? "" : "s"}`; - const mins = Math.floor((sec % 3600) / 60); - if (mins > 0) str += ` ${mins} minute${mins === 1 ? "" : "s"}`; - return str; - } else if (sec >= 60) { - const mins = Math.floor(sec / 60); - return `${mins} minute${mins === 1 ? "" : "s"}`; - } else return `${sec} second${sec === 1 ? "" : "s"}`; -}; - -const hookFocusedClientProp = ( - self: AstalWidget, - prop: keyof AstalHyprland.Client, - callback: (c: AstalHyprland.Client | null) => void -) => { - let id: number | null = null; - let lastClient: AstalHyprland.Client | null = null; - self.hook(hyprland, "notify::focused-client", () => { - if (id) lastClient?.disconnect(id); - lastClient = hyprland.focusedClient; // Can be null - id = lastClient?.connect(`notify::${kebabify(prop)}`, () => callback(lastClient)); - callback(lastClient); - }); - self.connect("destroy", () => id && lastClient?.disconnect(id)); - callback(lastClient); -}; - -const getClassName = ({ beforeSpacer, afterSpacer, first, last }: ClassNameProps) => - `${beforeSpacer ? "before-spacer" : ""} ${afterSpacer ? "after-spacer" : ""}` + - ` ${first ? "first" : ""} ${last ? "last" : ""}`; - -const getModule = (module: string) => { - module = module.toLowerCase(); - if (module === "osicon") return OSIcon; - if (module === "activewindow") return ActiveWindow; - if (module === "mediaplaying") return MediaPlaying; - if (module === "workspaces") return Workspaces; - if (module === "tray") return Tray; - if (module === "statusicons") return StatusIcons; - if (module === "pkgupdates") return PkgUpdates; - if (module === "notifcount") return NotifCount; - if (module === "battery") return Battery; - if (module === "datetime") return DateTime; - if (module === "power") return Power; - if (module === "brightnessspacer") return BrightnessSpacer; - if (module === "volumespacer") return VolumeSpacer; - return () => null; -}; - -const isSpacer = (module?: string) => module?.toLowerCase().endsWith("spacer") ?? false; - -const OSIcon = ({ monitor, ...props }: ModuleProps) => ( - <button - className={`module os-icon ${getClassName(props)}`} - onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane(monitor, "dashboard")} - > - {osIcon} - </button> -); - -const ActiveWindow = ({ monitor, ...props }: ModuleProps) => ( - <box - vertical={bind(config.vertical)} - className={`module active-window ${getClassName(props)}`} - setup={self => { - const title = Variable(""); - const updateTooltip = (c: AstalHyprland.Client | null) => - title.set(c?.class && c?.title ? `${c.class}: ${c.title}` : ""); - hookFocusedClientProp(self, "class", updateTooltip); - hookFocusedClientProp(self, "title", updateTooltip); - updateTooltip(hyprland.focusedClient); - - const window = setupCustomTooltip(self, bind(title)); - if (window) { - self.hook(title, (_, v) => !v && window.hide()); - self.hook(window, "map", () => !title.get() && window.hide()); - } - }} - > - <label - className="icon" - setup={self => - hookFocusedClientProp(self, "class", c => { - self.label = c?.class ? getAppCategoryIcon(c.class) : "desktop_windows"; - }) - } - /> - <label - truncate - angle={bind(config.vertical).as(v => (v ? 270 : 0))} - setup={self => { - const update = () => - (self.label = hyprland.focusedClient?.title ? hyprland.focusedClient.title : "Desktop"); - hookFocusedClientProp(self, "title", update); - self.hook(config.vertical, update); - }} - /> - </box> -); - -const MediaPlaying = ({ monitor, ...props }: ModuleProps) => { - const players = Players.get_default(); - const getLabel = (fallback = "") => - players.lastPlayer ? `${players.lastPlayer.title} - ${players.lastPlayer.artist}` : fallback; - return ( - <button - onClick={(_, event) => { - if (event.button === Astal.MouseButton.PRIMARY) switchPane(monitor, "audio"); - else if (event.button === Astal.MouseButton.SECONDARY) players.lastPlayer?.play_pause(); - else if (event.button === Astal.MouseButton.MIDDLE) players.lastPlayer?.raise(); - }} - setup={self => { - const label = Variable(getLabel()); - players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => label.set(getLabel())); - setupCustomTooltip(self, bind(label)); - }} - > - <box vertical={bind(config.vertical)} className={`module media-playing ${getClassName(props)}`}> - <icon - setup={self => - players.hookLastPlayer(self, "notify::identity", () => { - const icon = `caelestia-${players.lastPlayer?.identity - .toLowerCase() - .replaceAll(" ", "-")}-symbolic`; - self.icon = players.lastPlayer - ? Astal.Icon.lookup_icon(icon) - ? icon - : "caelestia-media-generic-symbolic" - : "caelestia-media-none-symbolic"; - }) - } - /> - <label - truncate - angle={bind(config.vertical).as(v => (v ? 270 : 0))} - setup={self => { - const update = () => (self.label = getLabel("No media")); - players.hookLastPlayer(self, ["notify::title", "notify::artist"], update); - self.hook(config.vertical, update); - }} - /> - </box> - </button> - ); -}; - -const Workspace = ({ idx }: { idx: number }) => { - const wsId = Variable.derive([bind(hyprland, "focusedWorkspace"), config.modules.workspaces.shown], (f, s) => - f ? Math.floor((f.id - 1) / s) * s + idx : idx - ); - - const label = ( - <label - css={bind(config.modules.workspaces.xalign).as(a => `margin-left: ${a}px; margin-right: ${-a}px;`)} - label={bind(config.modules.workspaces.labels).as(l => l[idx - 1] ?? String(idx))} - /> - ); - - return ( - <button - halign={Gtk.Align.CENTER} - valign={Gtk.Align.CENTER} - onClicked={() => hyprland.dispatch("workspace", String(wsId.get()))} - setup={self => { - const updateOccupied = () => { - const occupied = hyprland.clients.some(c => c.workspace?.id === wsId.get()); - self.toggleClassName("occupied", occupied); - }; - const updateFocused = () => { - self.toggleClassName("focused", hyprland.focusedWorkspace?.id === wsId.get()); - updateOccupied(); - }; - - self.hook(hyprland, "client-added", updateOccupied); - self.hook(hyprland, "client-moved", updateOccupied); - self.hook(hyprland, "client-removed", updateOccupied); - self.hook(hyprland, "notify::focused-workspace", updateFocused); - updateFocused(); - }} - onDestroy={() => wsId.drop()} - > - <box - visible={bind(config.modules.workspaces.showLabels)} - vertical={bind(config.vertical)} - setup={self => { - const update = () => { - if (config.modules.workspaces.showWindows.get()) { - const clients = hyprland.clients.filter(c => c.workspace?.id === wsId.get()); - self.children = [ - label, - ...clients.map(c => ( - <label className="icon" label={bind(c, "class").as(getAppCategoryIcon)} /> - )), - ]; - } else self.children = [label]; - }; - self.hook(wsId, update); - self.hook(hyprland, "client-added", update); - self.hook(hyprland, "client-moved", update); - self.hook(hyprland, "client-removed", update); - update(); - }} - /> - </button> - ); -}; - -const Workspaces = ({ monitor, ...props }: ModuleProps) => { - const className = Variable.derive( - [config.modules.workspaces.shown, config.modules.workspaces.showLabels], - (s, l) => `module workspaces ${s % 2 === 0 ? "even" : "odd"} ${l ? "labels-shown" : ""} ${getClassName(props)}` - ); - - return ( - <eventbox - onScroll={(_, event) => { - const activeWs = hyprland.focusedClient?.workspace.name; - if (activeWs?.startsWith("special:")) hyprland.dispatch("togglespecialworkspace", activeWs.slice(8)); - else if (event.delta_y > 0 || hyprland.focusedWorkspace?.id > 1) - hyprland.dispatch("workspace", (event.delta_y < 0 ? "-" : "+") + 1); - }} - > - <box vertical={bind(config.vertical)} className={bind(className)} onDestroy={() => className.drop()}> - {bind(config.modules.workspaces.shown).as( - n => Array.from({ length: n }).map((_, idx) => <Workspace idx={idx + 1} />) // Start from 1 - )} - </box> - </eventbox> - ); -}; - -const TrayItem = (item: AstalTray.TrayItem) => ( - <menubutton - onButtonPressEvent={(_, event) => event.get_button()[1] === Astal.MouseButton.SECONDARY && item.activate(0, 0)} - usePopover={false} - direction={bind(config.vertical).as(v => (v ? Gtk.ArrowType.RIGHT : Gtk.ArrowType.DOWN))} - menuModel={bind(item, "menuModel")} - actionGroup={bind(item, "actionGroup").as(a => ["dbusmenu", a])} - setup={self => setupCustomTooltip(self, bind(item, "tooltipMarkup"))} - > - <icon halign={Gtk.Align.CENTER} gicon={bind(item, "gicon")} /> - </menubutton> -); - -const Tray = ({ monitor, ...props }: ModuleProps) => ( - <box - visible={bind(AstalTray.get_default(), "items").as(i => i.length > 0)} - vertical={bind(config.vertical)} - className={`module tray ${getClassName(props)}`} - > - {bind(AstalTray.get_default(), "items").as(i => i.map(TrayItem))} - </box> -); - -const Network = ({ monitor }: { monitor: Monitor }) => ( - <button - onClick={(_, event) => { - const network = AstalNetwork.get_default(); - if (event.button === Astal.MouseButton.PRIMARY) switchPane(monitor, "connectivity"); - else if (event.button === Astal.MouseButton.SECONDARY) network.wifi.enabled = !network.wifi.enabled; - else if (event.button === Astal.MouseButton.MIDDLE) { - if (GLib.find_program_in_path("gnome-control-center")) - execAsync("app2unit -- gnome-control-center wifi").catch(console.error); - else { - network.wifi.scan(); - execAsync( - "app2unit -- foot -T nmtui -- fish -c 'sleep .1; set -e COLORTERM; TERM=xterm-old nmtui connect'" - ).catch(() => {}); // Ignore errors - } - } - }} - setup={self => { - const network = AstalNetwork.get_default(); - const tooltipText = Variable(""); - const update = () => { - if (network.primary === AstalNetwork.Primary.WIFI) { - if (network.wifi.internet === AstalNetwork.Internet.CONNECTED) - tooltipText.set(`${network.wifi.ssid} | Strength: ${network.wifi.strength}/100`); - else if (network.wifi.internet === AstalNetwork.Internet.CONNECTING) - tooltipText.set(`Connecting to ${network.wifi.ssid}`); - else tooltipText.set("Disconnected"); - } else if (network.primary === AstalNetwork.Primary.WIRED) { - if (network.wired.internet === AstalNetwork.Internet.CONNECTED) - tooltipText.set(`Speed: ${network.wired.speed}`); - else if (network.wired.internet === AstalNetwork.Internet.CONNECTING) tooltipText.set("Connecting"); - else tooltipText.set("Disconnected"); - } else { - tooltipText.set("Unknown"); - } - }; - self.hook(network, "notify::primary", update); - self.hook(network.wifi, "notify::internet", update); - self.hook(network.wifi, "notify::ssid", update); - self.hook(network.wifi, "notify::strength", update); - if (network.wired) { - self.hook(network.wired, "notify::internet", update); - self.hook(network.wired, "notify::speed", update); - } - update(); - setupCustomTooltip(self, bind(tooltipText)); - }} - > - <stack - transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN} - transitionDuration={120} - shown={bind(AstalNetwork.get_default(), "primary").as(p => - p === AstalNetwork.Primary.WIFI ? "wifi" : "wired" - )} - > - <stack - name="wifi" - transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN} - transitionDuration={120} - setup={self => { - const network = AstalNetwork.get_default(); - const update = () => { - if (network.wifi.internet === AstalNetwork.Internet.CONNECTED) - self.shown = String(Math.ceil(network.wifi.strength / 25)); - else if (network.wifi.internet === AstalNetwork.Internet.CONNECTING) self.shown = "connecting"; - else self.shown = "disconnected"; - }; - self.hook(network.wifi, "notify::internet", update); - self.hook(network.wifi, "notify::strength", update); - update(); - }} - > - <label className="icon" label="wifi_off" name="disconnected" /> - <label className="icon" label="settings_ethernet" name="connecting" /> - <label className="icon" label="signal_wifi_0_bar" name="0" /> - <label className="icon" label="network_wifi_1_bar" name="1" /> - <label className="icon" label="network_wifi_2_bar" name="2" /> - <label className="icon" label="network_wifi_3_bar" name="3" /> - <label className="icon" label="signal_wifi_4_bar" name="4" /> - </stack> - <stack - name="wired" - transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN} - transitionDuration={120} - setup={self => { - const network = AstalNetwork.get_default(); - const update = () => { - if (network.primary !== AstalNetwork.Primary.WIRED) return; - - if (network.wired.internet === AstalNetwork.Internet.CONNECTED) self.shown = "connected"; - else if (network.wired.internet === AstalNetwork.Internet.CONNECTING) self.shown = "connecting"; - else self.shown = "disconnected"; - }; - self.hook(network, "notify::primary", update); - if (network.wired) self.hook(network.wired, "notify::internet", update); - update(); - }} - > - <label className="icon" label="wifi_off" name="disconnected" /> - <label className="icon" label="settings_ethernet" name="connecting" /> - <label className="icon" label="lan" name="connected" /> - </stack> - </stack> - </button> -); - -const BluetoothDevice = ({ monitor, device }: { monitor: Monitor; device: AstalBluetooth.Device }) => ( - <button - visible={bind(device, "connected")} - onClick={(_, event) => { - if (event.button === Astal.MouseButton.PRIMARY) switchPane(monitor, "connectivity"); - else if (event.button === Astal.MouseButton.SECONDARY) - device.disconnect_device((_, res) => device.disconnect_device_finish(res)); - else if (event.button === Astal.MouseButton.MIDDLE) - execAsync("app2unit -- blueman-manager").catch(console.error); - }} - setup={self => setupCustomTooltip(self, bind(device, "alias"))} - > - <icon - icon={bind(device, "icon").as(i => - Astal.Icon.lookup_icon(`${i}-symbolic`) ? `${i}-symbolic` : "caelestia-bluetooth-device-symbolic" - )} - /> - </button> -); - -const Bluetooth = ({ monitor }: { monitor: Monitor }) => ( - <box vertical={bind(config.vertical)} className="bluetooth"> - <button - onClick={(_, event) => { - if (event.button === Astal.MouseButton.PRIMARY) switchPane(monitor, "connectivity"); - else if (event.button === Astal.MouseButton.SECONDARY) AstalBluetooth.get_default().toggle(); - else if (event.button === Astal.MouseButton.MIDDLE) - execAsync("app2unit -- blueman-manager").catch(console.error); - }} - setup={self => { - const bluetooth = AstalBluetooth.get_default(); - const tooltipText = Variable(""); - const update = () => { - const devices = bluetooth.get_devices().filter(d => d.connected); - tooltipText.set( - devices.length > 0 - ? `Connected devices: ${devices.map(d => d.alias).join(", ")}` - : "No connected devices" - ); - }; - const hookDevice = (device: AstalBluetooth.Device) => { - self.hook(device, "notify::connected", update); - self.hook(device, "notify::alias", update); - }; - bluetooth.get_devices().forEach(hookDevice); - self.hook(bluetooth, "device-added", (_, device) => { - hookDevice(device); - update(); - }); - update(); - setupCustomTooltip(self, bind(tooltipText)); - }} - > - <stack - transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN} - transitionDuration={120} - shown={bind(AstalBluetooth.get_default(), "isPowered").as(p => (p ? "enabled" : "disabled"))} - > - <label className="icon" label="bluetooth" name="enabled" /> - <label className="icon" label="bluetooth_disabled" name="disabled" /> - </stack> - </button> - {bind(AstalBluetooth.get_default(), "devices").as(d => - d.map(d => <BluetoothDevice monitor={monitor} device={d} />) - )} - </box> -); - -const StatusIcons = ({ monitor, ...props }: ModuleProps) => ( - <box vertical={bind(config.vertical)} className={`module status-icons ${getClassName(props)}`}> - <Network monitor={monitor} /> - <Bluetooth monitor={monitor} /> - </box> -); - -const PkgUpdates = ({ monitor, ...props }: ModuleProps) => ( - <button - onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane(monitor, "packages")} - setup={self => - setupCustomTooltip( - self, - bind(Updates.get_default(), "numUpdates").as(n => `${n} update${n === 1 ? "" : "s"} available`) - ) - } - > - <box vertical={bind(config.vertical)} className={`module pkg-updates ${getClassName(props)}`}> - <label className="icon" label="download" /> - <label label={bind(Updates.get_default(), "numUpdates").as(String)} /> - </box> - </button> -); - -const NotifCount = ({ monitor, ...props }: ModuleProps) => ( - <button - onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane(monitor, "alerts")} - setup={self => - setupCustomTooltip( - self, - bind(AstalNotifd.get_default(), "notifications").as( - n => `${n.length} notification${n.length === 1 ? "" : "s"}` - ) - ) - } - > - <box vertical={bind(config.vertical)} className={`module notif-count ${getClassName(props)}`}> - <label - className="icon" - label={bind(AstalNotifd.get_default(), "dontDisturb").as(d => (d ? "notifications_off" : "info"))} - /> - <revealer - transitionType={bind(config.vertical).as(v => - v ? Gtk.RevealerTransitionType.SLIDE_DOWN : Gtk.RevealerTransitionType.SLIDE_RIGHT - )} - transitionDuration={120} - revealChild={bind(AstalNotifd.get_default(), "dontDisturb").as(d => !d)} - > - <label label={bind(AstalNotifd.get_default(), "notifications").as(n => String(n.length))} /> - </revealer> - </box> - </button> -); - -const Battery = ({ monitor, ...props }: ModuleProps) => { - const className = Variable.derive( - [bind(AstalBattery.get_default(), "percentage"), bind(AstalBattery.get_default(), "charging")], - (p, c) => `module battery ${c ? "charging" : p < 0.2 ? "low" : ""} ${getClassName(props)}` - ); - const tooltip = Variable.derive( - [bind(AstalBattery.get_default(), "timeToEmpty"), bind(AstalBattery.get_default(), "timeToFull")], - (e, f) => (f > 0 ? `${formatSeconds(f)} until full` : `${formatSeconds(e)} remaining`) - ); - - return ( - <box - visible={bind(AstalBattery.get_default(), "isBattery")} - vertical={bind(config.vertical)} - className={bind(className)} - setup={self => setupCustomTooltip(self, bind(tooltip))} - onDestroy={() => { - className.drop(); - tooltip.drop(); - }} - > - <label className="icon" label={bind(AstalBattery.get_default(), "percentage").as(getBatteryIcon)} /> - <label label={bind(AstalBattery.get_default(), "percentage").as(p => `${Math.round(p * 100)}%`)} /> - </box> - ); -}; - -const DateTimeHoriz = (props: ClassNameProps) => ( - <box className={`module date-time ${getClassName(props)}`}> - <label className="icon" label="calendar_month" /> - <label - setup={self => { - const time = bindCurrentTime(bind(config.modules.dateTime.format), undefined, self); - self.label = time.get(); - self.hook(time, (_, t) => (self.label = t)); - }} - /> - </box> -); - -const DateTimeVertical = (props: ClassNameProps) => ( - <box vertical className={`module date-time ${getClassName(props)}`}> - <label className="icon" label="calendar_month" /> - <label label={bindCurrentTime("%H")} /> - <label label={bindCurrentTime("%M")} /> - </box> -); - -const DateTime = ({ monitor, ...props }: ModuleProps) => ( - <button - onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane(monitor, "time")} - setup={self => - setupCustomTooltip(self, bindCurrentTime(bind(config.modules.dateTime.detailedFormat), undefined, self)) - } - > - {bind(config.vertical).as(v => (v ? <DateTimeVertical {...props} /> : <DateTimeHoriz {...props} />))} - </button> -); - -const Power = ({ monitor, ...props }: ModuleProps) => ( - <button - className={`module power ${getClassName(props)}`} - label="power_settings_new" - onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && App.toggle_window("session")} - /> -); - -const Spacer = ({ onScroll }: { onScroll: (self: Widget.EventBox, event: Astal.ScrollEvent) => void }) => ( - <eventbox onScroll={onScroll}> - <box vertical={bind(config.vertical)}> - <ScreenCorner place="topleft" /> - <box expand /> - <ScreenCorner place={bind(config.vertical).as(v => (v ? "bottomleft" : "topright"))} /> - </box> - </eventbox> -); - -const BrightnessSpacer = ({ monitor }: { monitor: Monitor }) => ( - <Spacer onScroll={(_, event) => (event.delta_y > 0 ? (monitor.brightness -= 0.1) : (monitor.brightness += 0.1))} /> -); - -const VolumeSpacer = () => ( - <Spacer - onScroll={(_, event) => { - const speaker = AstalWp.get_default()?.audio.defaultSpeaker; - if (!speaker) return console.error("Unable to connect to WirePlumber."); - speaker.mute = false; - if (event.delta_y > 0) speaker.volume -= 0.1; - else speaker.volume += 0.1; - }} - /> -); - -const Bar = ({ monitor, layout }: { monitor: Monitor; layout: string }) => { - const className = Variable.derive( - [bind(config.vertical), bind(config.style)], - (v, s) => `bar ${v ? "vertical" : " horizontal"} ${s}` - ); - const modules = - layout === "centerbox" - ? Variable.derive(Object.values(config.layout.centerbox)) - : bind(config.layout.flowbox).as(m => [m]); - - const Layout = layout === "centerbox" ? Widget.CenterBox : Widget.Box; - return ( - <Layout - vertical={bind(config.vertical)} - className={bind(className)} - onDestroy={() => { - className.drop(); - if (modules instanceof Variable) modules.drop(); - }} - > - {bind(modules).as(modules => - modules.map((m, i) => ( - <box vertical={bind(config.vertical)}> - {m.map((n, j) => { - let beforeSpacer = false; - if (j < m.length - 1) beforeSpacer = isSpacer(m[j + 1]); - else if (i < modules.length - 1) beforeSpacer = isSpacer(modules[i + 1][0]); - let afterSpacer = false; - if (j > 0) afterSpacer = isSpacer(m[j - 1]); - else if (i > 0) afterSpacer = isSpacer(modules[i - 1].at(-1)); - const M = getModule(n); - return ( - <M - monitor={monitor} - beforeSpacer={beforeSpacer} - afterSpacer={afterSpacer} - first={i === 0 && j === 0} - last={i === modules.length - 1 && j === m.length - 1} - /> - ); - })} - </box> - )) - )} - </Layout> - ); -}; - -export default ({ monitor }: { monitor: Monitor }) => ( - <window - namespace="caelestia-bar" - monitor={monitor.id} - anchor={bind(config.vertical).as( - v => - Astal.WindowAnchor.TOP | - Astal.WindowAnchor.LEFT | - (v ? Astal.WindowAnchor.BOTTOM : Astal.WindowAnchor.RIGHT) - )} - exclusivity={Astal.Exclusivity.EXCLUSIVE} - > - <overlay - passThrough - overlays={[ - <ScreenCorner visible={bind(config.style).as(s => s !== "embedded")} place="topleft" />, - <ScreenCorner - visible={bind(config.style).as(s => s !== "embedded")} - halign={bind(config.vertical).as(v => (v ? undefined : Gtk.Align.END))} - valign={bind(config.vertical).as(v => (v ? Gtk.Align.END : undefined))} - place={bind(config.vertical).as(v => (v ? "bottomleft" : "topright"))} - />, - ]} - > - {bind(config.layout.type).as(l => ( - <Bar monitor={monitor} layout={l} /> - ))} - </overlay> - </window> -); diff --git a/src/modules/launcher/actions.tsx b/src/modules/launcher/actions.tsx deleted file mode 100644 index 40d37b5..0000000 --- a/src/modules/launcher/actions.tsx +++ /dev/null @@ -1,522 +0,0 @@ -import { Apps } from "@/services/apps"; -import Palette from "@/services/palette"; -import Schemes, { type Colours } from "@/services/schemes"; -import Wallpapers, { type ICategory, type IWallpaper } from "@/services/wallpapers"; -import { basename } from "@/utils/strings"; -import { notify } from "@/utils/system"; -import { setupCustomTooltip, type FlowBox } from "@/utils/widgets"; -import { bind, execAsync, GLib, readFile, register, type Variable } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import { launcher as config } from "config"; -import { setConfig } from "config/funcs"; -import fuzzysort from "fuzzysort"; -import AstalHyprland from "gi://AstalHyprland"; -import { close, ContentBox, type LauncherContent, type Mode } from "./util"; - -interface IAction { - icon: string; - name: string; - description: string; - action: (...args: string[]) => void; - available?: () => boolean; -} - -interface ActionMap { - [k: string]: IAction; -} - -const variantActions = { - vibrant: { - icon: "sentiment_very_dissatisfied", - name: "Vibrant", - description: "A high chroma palette. The primary palette's chroma is at maximum.", - }, - tonalspot: { - icon: "android", - name: "Tonal Spot", - description: "Default for Material theme colours. A pastel palette with a low chroma.", - }, - expressive: { - icon: "compare_arrows", - name: "Expressive", - description: - "A medium chroma palette. The primary palette's hue is different from the seed colour, for variety.", - }, - fidelity: { - icon: "compare", - name: "Fidelity", - description: "Matches the seed colour, even if the seed colour is very bright (high chroma).", - }, - content: { - icon: "sentiment_calm", - name: "Content", - description: "Almost identical to fidelity.", - }, - fruitsalad: { - icon: "nutrition", - name: "Fruit Salad", - description: "A playful theme - the seed colour's hue does not appear in the theme.", - }, - rainbow: { - icon: "looks", - name: "Rainbow", - description: "A playful theme - the seed colour's hue does not appear in the theme.", - }, - neutral: { - icon: "contrast", - name: "Neutral", - description: "Close to grayscale, a hint of chroma.", - }, - monochrome: { - icon: "filter_b_and_w", - name: "Monochrome", - description: "All colours are grayscale, no chroma.", - }, -}; - -const transparencyActions = { - off: { - icon: "blur_off", - name: "Off", - description: "Completely opaque", - }, - low: { - icon: "blur_circular", - name: "Low", - description: "Less transparent", - }, - normal: { - icon: "blur_linear", - name: "Normal", - description: "Somewhat transparent", - }, - high: { - icon: "blur_on", - name: "High", - description: "Extremely transparent", - }, -}; - -const autocomplete = (entry: Widget.Entry, action: string) => { - entry.set_text(`${config.actionPrefix.get()}${action} `); - entry.set_position(-1); -}; - -const actions = (mode: Variable<Mode>, entry: Widget.Entry): ActionMap => ({ - apps: { - icon: "apps", - name: "Apps", - description: "Search for apps", - action: () => { - mode.set("apps"); - entry.set_text(""); - }, - }, - files: { - icon: "folder", - name: "Files", - description: "Search for files", - action: () => { - mode.set("files"); - entry.set_text(""); - }, - }, - math: { - icon: "calculate", - name: "Math", - description: "Do math calculations", - action: () => { - mode.set("math"); - entry.set_text(""); - }, - }, - light: { - icon: "light_mode", - name: "Light", - description: "Change scheme to light mode", - action: () => { - Palette.get_default().switchMode("light"); - close(); - }, - available: () => Palette.get_default().hasMode("light"), - }, - dark: { - icon: "dark_mode", - name: "Dark", - description: "Change scheme to dark mode", - action: () => { - Palette.get_default().switchMode("dark"); - close(); - }, - available: () => Palette.get_default().hasMode("dark"), - }, - scheme: { - icon: "palette", - name: "Scheme", - description: "Change the current colour scheme", - action: () => autocomplete(entry, "scheme"), - }, - variant: { - icon: "colors", - name: "Variant", - description: "Change the current scheme variant", - action: () => autocomplete(entry, "variant"), - available: () => Palette.get_default().scheme === "dynamic", - }, - wallpaper: { - icon: "image", - name: "Wallpaper", - description: "Change the current wallpaper", - action: () => autocomplete(entry, "wallpaper"), - }, - transparency: { - icon: "opacity", - name: "Transparency", - description: "Change shell transparency", - action: () => autocomplete(entry, "transparency"), - }, - todo: { - icon: "checklist", - name: "Todo", - description: "Create a todo in Todoist", - action: (...args) => { - // If no args, autocomplete cmd - if (args.length === 0) return autocomplete(entry, "todo"); - - // If tod not configured, notify - let token = null; - try { - token = JSON.parse(readFile(GLib.get_user_config_dir() + "/tod.cfg")).token; - } catch {} // Ignore - if (!token) { - notify({ - summary: "Tod not configured", - body: "You need to configure tod first. Run any tod command to do this.", - icon: "dialog-warning-symbolic", - urgency: "critical", - }); - } else { - // Create todo and notify if configured - execAsync(`tod t q -c ${args.join(" ")}`).catch(console.error); - if (config.todo.notify.get()) - notify({ - summary: "Todo created", - body: `Created todo with content: ${args.join(" ")}`, - icon: "view-list-bullet-symbolic", - urgency: "low", - transient: true, - actions: { - "Copy content": () => execAsync(`wl-copy -- ${args.join(" ")}`).catch(console.error), - View: () => { - const client = AstalHyprland.get_default().clients.find(c => c.class === "Todoist"); - if (client) client.focus(); - else execAsync("app2unit -- todoist").catch(console.error); - }, - }, - }); - } - - close(); - }, - available: () => !!GLib.find_program_in_path("tod"), - }, - reload: { - icon: "refresh", - name: "Reload", - description: "Reload app list", - action: () => { - Apps.reload(); - entry.set_text(""); - }, - }, - lock: { - icon: "lock", - name: "Lock", - description: "Lock the current session", - action: () => { - execAsync("loginctl lock-session").catch(console.error); - close(); - }, - }, - logout: { - icon: "logout", - name: "Logout", - description: "End the current session", - action: () => { - execAsync("uwsm stop").catch(console.error); - close(); - }, - }, - sleep: { - icon: "bedtime", - name: "Sleep", - description: "Suspend then hibernate", - action: () => { - execAsync("systemctl suspend-then-hibernate").catch(console.error); - close(); - }, - }, - reboot: { - icon: "cached", - name: "Reboot", - description: "Restart the machine", - action: () => { - execAsync("systemctl reboot").catch(console.error); - close(); - }, - }, - hibernate: { - icon: "downloading", - name: "Hibernate", - description: "Suspend to RAM", - action: () => { - execAsync("systemctl hibernate").catch(console.error); - close(); - }, - }, - shutdown: { - icon: "power_settings_new", - name: "Shutdown", - description: "Suspend to disk", - action: () => { - execAsync("systemctl poweroff").catch(console.error); - close(); - }, - }, -}); - -const Action = ({ args, icon, name, description, action }: IAction & { args: string[] }) => ( - <Gtk.FlowBoxChild visible canFocus={false}> - <button - className="result" - cursor="pointer" - onClicked={() => action(...args)} - setup={self => setupCustomTooltip(self, description)} - > - <box> - <label className="icon" label={icon} /> - <box vertical className="has-sublabel"> - <label truncate xalign={0} label={name} /> - <label truncate xalign={0} label={description} className="sublabel" /> - </box> - </box> - </button> - </Gtk.FlowBoxChild> -); - -const Swatch = ({ colour }: { colour: string }) => <box className="swatch" css={"background-color: " + colour + ";"} />; - -const Scheme = ({ scheme, name, colours }: { scheme?: string; name: string; colours?: Colours }) => { - const palette = colours![Palette.get_default().mode] ?? colours!.light ?? colours!.dark!; - return ( - <Gtk.FlowBoxChild visible canFocus={false}> - <button - className="result" - cursor="pointer" - onClicked={() => { - execAsync(`caelestia scheme ${scheme ?? ""} ${name}`).catch(console.error); - close(); - }} - > - <box> - <box valign={Gtk.Align.CENTER}> - <box className="swatch big left" css={"background-color: " + palette.base + ";"} /> - <box className="swatch big right" css={"background-color: " + palette.primary + ";"} /> - </box> - <box vertical className="has-sublabel"> - <label truncate xalign={0} label={scheme ? `${scheme} (${name})` : name} /> - <box className="swatches"> - <Swatch colour={palette.rosewater} /> - <Swatch colour={palette.flamingo} /> - <Swatch colour={palette.pink} /> - <Swatch colour={palette.mauve} /> - <Swatch colour={palette.red} /> - <Swatch colour={palette.maroon} /> - <Swatch colour={palette.peach} /> - <Swatch colour={palette.yellow} /> - <Swatch colour={palette.green} /> - <Swatch colour={palette.teal} /> - <Swatch colour={palette.sky} /> - <Swatch colour={palette.sapphire} /> - <Swatch colour={palette.blue} /> - <Swatch colour={palette.lavender} /> - </box> - </box> - </box> - </button> - </Gtk.FlowBoxChild> - ); -}; - -const Variant = ({ name }: { name: keyof typeof variantActions }) => ( - <Action - {...variantActions[name]} - args={[]} - action={() => { - execAsync(`caelestia variant ${name}`).catch(console.error); - close(); - }} - /> -); - -const Wallpaper = ({ path, thumbnails }: IWallpaper) => ( - <Gtk.FlowBoxChild visible canFocus={false}> - <button - className="result wallpaper-container" - cursor="pointer" - onClicked={() => { - execAsync(`caelestia wallpaper -f ${path}`).catch(console.error); - close(); - }} - setup={self => setupCustomTooltip(self, path.replace(HOME, "~"))} - > - <box - vertical={config.wallpaper.style.get() !== "compact"} - className={`wallpaper ${config.wallpaper.style.get()}`} - > - <box - className="thumbnail" - css={bind(config.wallpaper.style).as( - s => "background-image: url('" + thumbnails[s as keyof typeof thumbnails] + "');" - )} - /> - <label truncate label={basename(path)} /> - </box> - </button> - </Gtk.FlowBoxChild> -); - -const CategoryThumbnail = ({ style, wallpapers }: { style: string; wallpapers: IWallpaper[] }) => ( - <box className="thumbnail"> - {wallpapers.slice(0, 3).map(w => ( - <box hexpand css={"background-image: url('" + w.thumbnails[style as keyof typeof w.thumbnails] + "');"} /> - ))} - </box> -); - -const Category = ({ path, wallpapers }: ICategory) => ( - <Gtk.FlowBoxChild visible canFocus={false}> - <button - className="result wallpaper-container" - cursor="pointer" - onClicked={() => { - execAsync(`caelestia wallpaper -d ${path}`).catch(console.error); - close(); - }} - setup={self => setupCustomTooltip(self, path.replace(HOME, "~"))} - > - <box - vertical={config.wallpaper.style.get() !== "compact"} - className={`wallpaper ${config.wallpaper.style.get()}`} - > - {bind(config.wallpaper.style).as(s => - s === "compact" ? ( - <box - className="thumbnail" - css={"background-image: url('" + wallpapers[0].thumbnails.compact + "');"} - /> - ) : ( - <CategoryThumbnail style={s} wallpapers={wallpapers} /> - ) - )} - <label truncate label={basename(path)} /> - </box> - </button> - </Gtk.FlowBoxChild> -); - -const Transparency = ({ amount }: { amount: keyof typeof transparencyActions }) => ( - <Action - {...transparencyActions[amount]} - args={[]} - action={() => { - setConfig("style.transparency", amount).catch(console.error); - close(); - }} - /> -); - -@register() -export default class Actions extends Widget.Box implements LauncherContent { - #map: ActionMap; - #list: string[]; - - #content: FlowBox; - - constructor(mode: Variable<Mode>, entry: Widget.Entry) { - super({ name: "actions", className: "actions" }); - - this.#map = actions(mode, entry); - this.#list = Object.keys(this.#map); - - this.#content = (<ContentBox />) as FlowBox; - - this.add( - <scrollable expand hscroll={Gtk.PolicyType.NEVER}> - {this.#content} - </scrollable> - ); - } - - updateContent(search: string): void { - this.#content.foreach(c => c.destroy()); - const args = search.split(" "); - const action = args[0].slice(1).toLowerCase(); - - if (action === "scheme") { - const scheme = args[1] ?? ""; - const schemes = Object.values(Schemes.get_default().map) - .flatMap(s => (s.colours ? s.name : Object.values(s.flavours!).map(f => `${f.scheme}-${f.name}`))) - .filter(s => s !== undefined) - .sort(); - for (const { target } of fuzzysort.go(scheme, schemes, { all: true })) { - if (Schemes.get_default().map.hasOwnProperty(target)) - this.#content.add(<Scheme {...Schemes.get_default().map[target]} />); - else { - const [scheme, flavour] = target.split("-"); - this.#content.add(<Scheme {...Schemes.get_default().map[scheme].flavours![flavour]} />); - } - } - } else if (action === "variant") { - const list = Object.keys(variantActions); - - for (const { target } of fuzzysort.go(args[1], list, { all: true })) - this.#content.add(<Variant name={target as keyof typeof variantActions} />); - } else if (action === "wallpaper") { - if (args[1]?.toLowerCase() === "random") { - const list = Wallpapers.get_default().categories; - for (const { obj } of fuzzysort.go(args[2] ?? "", list, { all: true, key: "path" })) - this.#content.add(<Category {...obj} />); - } else { - const list = Wallpapers.get_default().list; - let limit = undefined; - if ((args[1] || !config.wallpaper.showAllEmpty.get()) && config.wallpaper.maxResults.get() > 0) - limit = config.wallpaper.maxResults.get(); - - for (const { obj } of fuzzysort.go(args[1] ?? "", list, { all: true, key: "path", limit })) - this.#content.add(<Wallpaper {...obj} />); - } - } else if (action === "transparency") { - const list = Object.keys(transparencyActions); - - for (const { target } of fuzzysort.go(args[1], list, { all: true })) - this.#content.add(<Transparency amount={target as keyof typeof transparencyActions} />); - } else { - const list = this.#list.filter( - a => this.#map[a].available?.() ?? !config.disabledActions.get().includes(a) - ); - for (const { target } of fuzzysort.go(action, list, { all: true })) - this.#content.add(<Action {...this.#map[target]} args={args.slice(1)} />); - } - } - - handleActivate(search: string): void { - const args = search.split(" "); - const action = args[0].slice(1).toLowerCase(); - - if (action === "scheme" && args[1]?.toLowerCase() === "random") { - execAsync(`caelestia scheme`).catch(console.error); - close(); - } else this.#content.get_child_at_index(0)?.get_child()?.activate(); - } -} diff --git a/src/modules/launcher/index.tsx b/src/modules/launcher/index.tsx deleted file mode 100644 index b75ecce..0000000 --- a/src/modules/launcher/index.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import PopupWindow from "@/widgets/popupwindow"; -import { bind, register, Variable } from "astal"; -import { Astal, Gtk, Widget } from "astal/gtk3"; -import { launcher as config } from "config"; -import Actions from "./actions"; -import Modes from "./modes"; -import type { Mode } from "./util"; - -const getModeIcon = (mode: Mode) => { - if (mode === "apps") return "apps"; - if (mode === "files") return "folder"; - if (mode === "math") return "calculate"; - return "search"; -}; - -const getPrettyMode = (mode: Mode) => { - if (mode === "apps") return "Apps"; - if (mode === "files") return "Files"; - if (mode === "math") return "Math"; - return mode; -}; - -const isAction = (text: string, action: string = "") => text.startsWith(config.actionPrefix.get() + action); - -const SearchBar = ({ mode, entry }: { mode: Variable<Mode>; entry: Widget.Entry }) => ( - <box className="search-bar"> - <box className="mode"> - <label className="icon" label={bind(mode).as(getModeIcon)} /> - <label label={bind(mode).as(getPrettyMode)} /> - </box> - {entry} - </box> -); - -const ModeSwitcher = ({ mode, modes }: { mode: Variable<Mode>; modes: Mode[] }) => ( - <box homogeneous hexpand className="mode-switcher"> - {modes.map(m => ( - <button - className={bind(mode).as(c => `mode ${c === m ? "selected" : ""}`)} - cursor="pointer" - onClicked={() => mode.set(m)} - > - <box halign={Gtk.Align.CENTER}> - <label className="icon" label={getModeIcon(m)} /> - <label label={getPrettyMode(m)} /> - </box> - </button> - ))} - </box> -); - -@register() -export default class Launcher extends PopupWindow { - readonly mode: Variable<Mode>; - - constructor() { - const entry = ( - <entry - hexpand - className="entry" - placeholderText={bind(config.actionPrefix).as(p => `Type "${p}" for subcommands`)} - /> - ) as Widget.Entry; - const mode = Variable<Mode>("apps"); - const content = Modes(); - const actions = new Actions(mode, entry); - const className = Variable.derive([mode, config.style], (m, s) => `launcher ${m} ${s}`); - - super({ - name: "launcher", - anchor: - Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT, - keymode: Astal.Keymode.EXCLUSIVE, - exclusivity: Astal.Exclusivity.IGNORE, - borderWidth: 0, - onKeyPressEvent(_, event) { - const keyval = event.get_keyval()[1]; - // Focus entry on typing - if (!entry.isFocus && keyval >= 32 && keyval <= 126) { - entry.text += String.fromCharCode(keyval); - entry.grab_focus(); - entry.set_position(-1); - - // Consume event, if not consumed it will duplicate character in entry - return true; - } - }, - child: ( - <box - vertical - halign={Gtk.Align.CENTER} - valign={Gtk.Align.CENTER} - className={bind(className)} - onDestroy={() => className.drop()} - > - <SearchBar mode={mode} entry={entry} /> - <stack - expand - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={100} - shown={bind(entry, "text").as(t => (isAction(t) ? "actions" : "content"))} - > - <stack - name="content" - transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT} - transitionDuration={200} - shown={bind(mode)} - > - {Object.values(content)} - </stack> - {actions} - </stack> - <ModeSwitcher mode={mode} modes={Object.keys(content) as Mode[]} /> - </box> - ), - }); - - this.mode = mode; - - content[mode.get()].updateContent(entry.get_text()); - this.hook(mode, (_, v: Mode) => { - entry.set_text(""); - content[v].updateContent(entry.get_text()); - }); - this.hook(entry, "changed", () => - (isAction(entry.get_text()) ? actions : content[mode.get()]).updateContent(entry.get_text()) - ); - this.hook(entry, "activate", () => { - (isAction(entry.get_text()) ? actions : content[mode.get()]).handleActivate(entry.get_text()); - if (mode.get() === "math" && !isAction(entry.get_text())) entry.set_text(""); // Cause math mode doesn't auto clear - }); - - // Clear search on hide if not in math mode or creating a todo - this.connect("hide", () => { - if ((mode.get() !== "math" || isAction(entry.get_text())) && !isAction(entry.get_text(), "todo")) - entry.set_text(""); - }); - } - - open(mode: Mode) { - this.mode.set(mode); - this.show(); - } -} diff --git a/src/modules/launcher/modes.tsx b/src/modules/launcher/modes.tsx deleted file mode 100644 index e278779..0000000 --- a/src/modules/launcher/modes.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { Apps as AppsService } from "@/services/apps"; -import MathService, { type HistoryItem } from "@/services/math"; -import { getAppCategoryIcon } from "@/utils/icons"; -import { launch } from "@/utils/system"; -import { type FlowBox, setupCustomTooltip } from "@/utils/widgets"; -import { bind, execAsync, Gio, register, Variable } from "astal"; -import { Astal, Gtk, Widget } from "astal/gtk3"; -import { launcher as config } from "config"; -import type AstalApps from "gi://AstalApps"; -import { close, ContentBox, type LauncherContent, limitLength } from "./util"; - -const AppResult = ({ app }: { app: AstalApps.Application }) => ( - <Gtk.FlowBoxChild visible canFocus={false}> - <button - className="result" - cursor="pointer" - onClicked={() => { - launch(app); - close(); - }} - setup={self => setupCustomTooltip(self, app.description ? `${app.name}: ${app.description}` : app.name)} - > - <box> - {app.iconName && Astal.Icon.lookup_icon(app.iconName) ? ( - <icon className="icon" icon={app.iconName} /> - ) : ( - <label className="icon" label={getAppCategoryIcon(app)} /> - )} - <label truncate label={app.name} /> - </box> - </button> - </Gtk.FlowBoxChild> -); - -const FileResult = ({ path }: { path: string }) => ( - <Gtk.FlowBoxChild visible canFocus={false}> - <button - className="result" - cursor="pointer" - onClicked={() => { - execAsync([ - "bash", - "-c", - `dbus-send --session --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems array:string:'file://${path}' string:'' || app2unit -O '${path}'`, - ]).catch(console.error); - close(); - }} - > - <box setup={self => setupCustomTooltip(self, path.replace(HOME, "~"))}> - <icon - className="icon" - gicon={ - Gio.File.new_for_path(path) - .query_info(Gio.FILE_ATTRIBUTE_STANDARD_ICON, Gio.FileQueryInfoFlags.NONE, null) - .get_icon()! - } - /> - <label - truncate - label={ - path.replace(HOME, "~").length > config.files.shortenThreshold.get() - ? path - .replace(HOME, "~") - .split("/") - .map((n, i, arr) => (i === 0 || i === arr.length - 1 ? n : n.slice(0, 1))) - .join("/") - : path.replace(HOME, "~") - } - /> - </box> - </button> - </Gtk.FlowBoxChild> -); - -const MathResult = ({ icon, equation, result }: HistoryItem) => ( - <Gtk.FlowBoxChild visible canFocus={false}> - <button - className="result" - cursor="pointer" - onClicked={() => { - execAsync(["wl-copy", "--", result]).catch(console.error); - close(); - }} - setup={self => setupCustomTooltip(self, `${equation} -> ${result}`)} - > - <box> - <label className="icon" label={icon} /> - <box vertical className="has-sublabel"> - <label truncate xalign={0} label={equation} /> - <label truncate xalign={0} label={result} className="sublabel" /> - </box> - </box> - </button> - </Gtk.FlowBoxChild> -); - -@register() -class Apps extends Widget.Box implements LauncherContent { - #content: FlowBox; - - constructor() { - super({ name: "apps", className: "apps" }); - - this.#content = (<ContentBox />) as FlowBox; - - this.add( - <scrollable expand hscroll={Gtk.PolicyType.NEVER}> - {this.#content} - </scrollable> - ); - } - - updateContent(search: string): void { - this.#content.foreach(c => c.destroy()); - for (const app of limitLength(AppsService.fuzzy_query(search), config.apps)) - this.#content.add(<AppResult app={app} />); - } - - handleActivate(): void { - this.#content.get_child_at_index(0)?.get_child()?.grab_focus(); - this.#content.get_child_at_index(0)?.get_child()?.activate(); - } -} - -@register() -class Files extends Widget.Box implements LauncherContent { - #content: FlowBox; - - constructor() { - super({ name: "files", className: "files" }); - - this.#content = (<ContentBox />) as FlowBox; - - this.add( - <scrollable expand hscroll={Gtk.PolicyType.NEVER}> - {this.#content} - </scrollable> - ); - } - - updateContent(search: string): void { - execAsync(["fd", ...config.files.fdOpts.get(), search, HOME]) - .then(out => { - this.#content.foreach(c => c.destroy()); - const paths = out.split("\n").filter(path => path); - for (const path of limitLength(paths, config.files)) this.#content.add(<FileResult path={path} />); - }) - .catch(() => {}); // Ignore errors - } - - handleActivate(): void { - this.#content.get_child_at_index(0)?.get_child()?.grab_focus(); - this.#content.get_child_at_index(0)?.get_child()?.activate(); - } -} - -@register() -class Math extends Widget.Box implements LauncherContent { - #showResult: Variable<boolean>; - #result: Variable<HistoryItem>; - #content: FlowBox; - - constructor() { - super({ name: "math", className: "math", vertical: true }); - - this.#showResult = Variable(false); - this.#result = Variable({ equation: "", result: "", icon: "" }); - this.#content = (<ContentBox />) as FlowBox; - - this.add( - <revealer - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={150} - revealChild={bind(this.#showResult)} - > - <box vertical className="preview"> - <box className="result"> - <label className="icon" label={bind(this.#result).as(r => r.icon)} /> - <box vertical> - <label xalign={0} label="Result" /> - <label - truncate - xalign={0} - className="sublabel" - label={bind(this.#result).as(r => r.result)} - /> - </box> - </box> - <box visible={bind(config.style).as(s => s === "lines")} className="separator" /> - </box> - </revealer> - ); - this.add( - <scrollable expand hscroll={Gtk.PolicyType.NEVER}> - {this.#content} - </scrollable> - ); - } - - updateContent(search: string): void { - this.#showResult.set(search.length > 0); - this.#result.set(MathService.get_default().evaluate(search)); - - this.#content.foreach(c => c.destroy()); - for (const item of limitLength(MathService.get_default().history, config.math)) - this.#content.add(<MathResult {...item} />); - } - - handleActivate(search: string): void { - if (!search) return; - MathService.get_default().commit(); - const res = this.#result.get(); - // Copy and close if not assignment, help or error - if (!["equal", "help", "error"].includes(res.icon)) { - execAsync(["wl-copy", "--", res.result]).catch(console.error); - close(); - } - } -} - -export default () => ({ - apps: new Apps(), - files: new Files(), - math: new Math(), -}); diff --git a/src/modules/launcher/util.tsx b/src/modules/launcher/util.tsx deleted file mode 100644 index 8288588..0000000 --- a/src/modules/launcher/util.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { FlowBox } from "@/utils/widgets"; -import type { Variable } from "astal"; -import { App, Gtk } from "astal/gtk3"; - -export type Mode = "apps" | "files" | "math"; - -export interface LauncherContent { - updateContent(search: string): void; - handleActivate(search: string): void; -} - -export const close = () => App.get_window("launcher")?.hide(); - -export const limitLength = <T,>(arr: T[], cfg: { maxResults: Variable<number> }) => - cfg.maxResults.get() > 0 && arr.length > cfg.maxResults.get() ? arr.slice(0, cfg.maxResults.get()) : arr; - -export const ContentBox = () => ( - <FlowBox homogeneous valign={Gtk.Align.START} minChildrenPerLine={2} maxChildrenPerLine={2} /> -); diff --git a/src/modules/mediadisplay/index.tsx b/src/modules/mediadisplay/index.tsx deleted file mode 100644 index 307087c..0000000 --- a/src/modules/mediadisplay/index.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import Players from "@/services/players"; -import { lengthStr } from "@/utils/strings"; -import { bind, Variable } from "astal"; -import { App, Astal, Gtk } from "astal/gtk3"; -import AstalMpris from "gi://AstalMpris"; -import Visualiser from "./visualiser"; - -type Selected = Variable<AstalMpris.Player | null>; - -const bindIcon = (player: AstalMpris.Player) => - bind(player, "identity").as(i => { - const icon = `caelestia-${i?.toLowerCase().replaceAll(" ", "-")}-symbolic`; - return Astal.Icon.lookup_icon(icon) ? icon : "caelestia-media-generic-symbolic"; - }); - -const PlayerButton = ({ - player, - selected, - showDropdown, -}: { - player: AstalMpris.Player; - selected: Selected; - showDropdown: Variable<boolean>; -}) => ( - <button - cursor="pointer" - onClicked={() => { - showDropdown.set(false); - selected.set(player); - }} - > - <box className="identity" halign={Gtk.Align.CENTER}> - <label label={bind(player, "identity").as(i => i ?? "-")} /> - <label label="•" /> - <label label={bind(player, "title").as(t => t ?? "-")} /> - </box> - </button> -); - -const Selector = ({ player, selected }: { player?: AstalMpris.Player; selected: Selected }) => { - const showDropdown = Variable(false); - - return ( - <box vertical valign={Gtk.Align.START} className="selector"> - <button - sensitive={bind(Players.get_default(), "list").as(ps => ps.length > 1)} - cursor="pointer" - onClicked={() => showDropdown.set(!showDropdown.get())} - > - <box className="identity" halign={Gtk.Align.CENTER}> - <icon icon={player ? bindIcon(player) : "caelestia-media-none-symbolic"} /> - <label label={player ? bind(player, "identity").as(i => i ?? "") : "No media"} /> - </box> - </button> - <revealer - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={150} - revealChild={bind(showDropdown)} - > - <box vertical className="list"> - {bind(Players.get_default(), "list").as(ps => - ps - .filter(p => p !== player) - .map(p => <PlayerButton player={p} selected={selected} showDropdown={showDropdown} />) - )} - </box> - </revealer> - </box> - ); -}; - -const NoMedia = ({ selected }: { selected: Selected }) => ( - <box> - <box homogeneous halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="cover-art"> - <label xalign={0.36} label="" /> - </box> - <box> - <box vertical className="details"> - <label truncate xalign={0} className="title" label="No media" /> - <label truncate xalign={0} className="artist" label="Try play something!" /> - <box halign={Gtk.Align.START} className="controls"> - <button sensitive={false} label="skip_previous" /> - <button sensitive={false} label="play_arrow" /> - <button sensitive={false} label="skip_next" /> - </box> - </box> - <box className="center-module"> - <overlay - expand - overlay={<label halign={Gtk.Align.CENTER} valign={Gtk.Align.END} className="time" label="-1:-1" />} - > - <Visualiser /> - </overlay> - </box> - <Selector selected={selected} /> - </box> - </box> -); - -const Player = ({ player, selected }: { player: AstalMpris.Player; selected: Selected }) => { - const time = Variable.derive( - [bind(player, "position"), bind(player, "length")], - (p, l) => lengthStr(p) + " / " + lengthStr(l) - ); - - return ( - <box> - <box - homogeneous - halign={Gtk.Align.CENTER} - valign={Gtk.Align.CENTER} - className="cover-art" - css={bind(player, "coverArt").as(a => `background-image: url("${a}");`)} - > - {bind(player, "coverArt").as(a => (a ? <box visible={false} /> : <label xalign={0.36} label="" />))} - </box> - <box> - <box vertical className="details"> - <label truncate xalign={0} className="title" label={bind(player, "title").as(t => t ?? "-")} /> - <label truncate xalign={0} className="artist" label={bind(player, "artist").as(t => t ?? "-")} /> - <box halign={Gtk.Align.START} className="controls"> - <button - sensitive={bind(player, "canGoPrevious")} - cursor="pointer" - onClicked={() => player.next()} - label="skip_previous" - /> - <button - sensitive={bind(player, "canControl")} - cursor="pointer" - onClicked={() => player.play_pause()} - label={bind(player, "playbackStatus").as(s => - s === AstalMpris.PlaybackStatus.PLAYING ? "pause" : "play_arrow" - )} - /> - <button - sensitive={bind(player, "canGoNext")} - cursor="pointer" - onClicked={() => player.next()} - label="skip_next" - /> - </box> - </box> - <box className="center-module"> - <overlay - expand - overlay={ - <label - halign={Gtk.Align.CENTER} - valign={Gtk.Align.END} - className="time" - label={bind(time)} - onDestroy={() => time.drop()} - /> - } - > - <Visualiser /> - </overlay> - </box> - <Selector player={player} selected={selected} /> - </box> - </box> - ); -}; - -export default ({ monitor }: { monitor: Monitor }) => { - const selected = Variable(Players.get_default().lastPlayer); - selected.observe(Players.get_default(), "notify::last-player", () => Players.get_default().lastPlayer); - - return ( - <window - application={App} - name={`mediadisplay${monitor.id}`} - namespace="caelestia-mediadisplay" - monitor={monitor.id} - anchor={Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.BOTTOM} - exclusivity={Astal.Exclusivity.EXCLUSIVE} - visible={false} - > - <box className="mediadisplay" onDestroy={() => selected.drop()}> - {bind(selected).as(p => - p ? <Player player={p} selected={selected} /> : <NoMedia selected={selected} /> - )} - </box> - </window> - ); -}; diff --git a/src/modules/mediadisplay/visualiser.tsx b/src/modules/mediadisplay/visualiser.tsx deleted file mode 100644 index d788e7b..0000000 --- a/src/modules/mediadisplay/visualiser.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Gtk } from "astal/gtk3"; -import cairo from "cairo"; -import AstalCava from "gi://AstalCava"; -import PangoCairo from "gi://PangoCairo"; - -export default () => ( - <drawingarea - className="visualiser" - setup={self => { - const cava = AstalCava.get_default(); - - if (cava) { - cava.set_stereo(true); - cava.set_noise_reduction(0.77); - cava.set_input(AstalCava.Input.PIPEWIRE); - - self.hook(cava, "notify::values", () => self.queue_draw()); - self.connect("size-allocate", () => { - const width = self.get_allocated_width(); - const barWidth = self - .get_style_context() - .get_property("min-width", Gtk.StateFlags.NORMAL) as number; - const gaps = self.get_style_context().get_margin(Gtk.StateFlags.NORMAL).right; - const bars = Math.floor((width - gaps) / (barWidth + gaps)); - if (bars > 0) cava.set_bars(bars % 2 ? bars : bars - 1); - }); - } - - self.connect("draw", (_, cr: cairo.Context) => { - const { width, height } = self.get_allocation(); - - if (!cava) { - // Show error text if cava unavailable - const fg = self.get_style_context().get_color(Gtk.StateFlags.NORMAL); - cr.setSourceRGBA(fg.red, fg.green, fg.blue, fg.alpha); - const layout = self.create_pango_layout("Visualiser module requires Cava"); - const [w, h] = layout.get_pixel_size(); - cr.moveTo((width - w) / 2, (height - h) / 2); - cr.setAntialias(cairo.Antialias.BEST); - PangoCairo.show_layout(cr, layout); - - return; - } - - const bg = self.get_style_context().get_background_color(Gtk.StateFlags.NORMAL); - cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha); - const barWidth = self.get_style_context().get_property("min-width", Gtk.StateFlags.NORMAL) as number; - const gaps = self.get_style_context().get_margin(Gtk.StateFlags.NORMAL).right; - - const values = cava.get_values(); - const len = values.length - 1; - const radius = barWidth / 2; - const xOff = (width - len * (barWidth + gaps) - gaps) / 2 - radius; - const center = height / 2; - const half = len / 2; - - const renderPill = (x: number, value: number) => { - x = x * (barWidth + gaps) + xOff; - value *= center; - cr.arc(x, center + value, radius, 0, Math.PI); - cr.arc(x, center - value, radius, Math.PI, Math.PI * 2); - cr.fill(); - }; - - // Render channels facing each other - for (let i = half - 1; i >= 0; i--) renderPill(half - i, values[i]); - for (let i = half; i < len; i++) renderPill(i + 1, values[i]); - }); - }} - /> -); diff --git a/src/modules/navbar.tsx b/src/modules/navbar.tsx deleted file mode 100644 index 35d3900..0000000 --- a/src/modules/navbar.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import { capitalize } from "@/utils/strings"; -import type { AstalWidget } from "@/utils/types"; -import { bind, execAsync, Variable } from "astal"; -import { App, Astal, Gtk } from "astal/gtk3"; -import { navbar as config } from "config"; -import AstalHyprland from "gi://AstalHyprland"; -import Pango from "gi://Pango"; -import SideBar, { awaitSidebar, paneNames, switchPane, type PaneName } from "./sidebar"; - -const layerNames = ["mediadisplay"] as const; -type LayerName = `${(typeof layerNames)[number]}${number}`; - -const specialWsNames = ["sysmon", "communication", "music", "todo"] as const; -type SpecialWsName = (typeof specialWsNames)[number]; - -const getPaneIcon = (name: PaneName) => { - if (name === "dashboard") return "dashboard"; - if (name === "audio") return "tune"; - if (name === "connectivity") return "settings_ethernet"; - if (name === "packages") return "package_2"; - if (name === "alerts") return "notifications"; - return "date_range"; -}; - -const getLayerIcon = (name: LayerName) => { - return "graphic_eq"; -}; - -const getSpecialWsIcon = (name: SpecialWsName) => { - if (name === "sysmon") return "speed"; - if (name === "communication") return "communication"; - if (name === "music") return "music_note"; - return "checklist"; -}; - -const hookIsCurrent = ( - self: AstalWidget, - sidebar: Variable<SideBar | null>, - name: PaneName, - callback: (isCurrent: boolean) => void -) => { - const unsub = sidebar.subscribe(s => { - if (!s) return; - self.hook(s.shown, (_, v) => callback(s.visible && v === name)); - self.hook(s, "notify::visible", () => callback(s.visible && s.shown.get() === name)); - callback(s.visible && s.shown.get() === name); - unsub(); - }); -}; - -const PaneButton = ({ - monitor, - name, - sidebar, -}: { - monitor: Monitor; - name: PaneName; - sidebar: Variable<SideBar | null>; -}) => ( - <button - cursor="pointer" - onClicked={() => switchPane(monitor, name)} - setup={self => hookIsCurrent(self, sidebar, name, c => self.toggleClassName("current", c))} - > - <box vertical className="nav-button"> - <label className="icon" label={getPaneIcon(name)} /> - <revealer - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={150} - setup={self => { - let isCurrent = false; - hookIsCurrent(self, sidebar, name, c => { - isCurrent = c; - self.set_reveal_child(config.showLabels.get() && c); - }); - self.hook(config.showLabels, (_, v) => self.set_reveal_child(v && isCurrent)); - }} - > - <label truncate wrapMode={Pango.WrapMode.WORD_CHAR} className="label" label={capitalize(name)} /> - </revealer> - </box> - </button> -); - -const LayerButton = ({ name }: { name: LayerName }) => ( - <button - cursor="pointer" - onClicked={() => App.toggle_window(name)} - setup={self => - self.hook(App, "window-toggled", (_, window) => { - if (window.name === name) self.toggleClassName("current", window.visible); - }) - } - > - <box vertical className="nav-button"> - <label className="icon" label={getLayerIcon(name)} /> - <revealer - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={150} - setup={self => { - let visible = false; - self.hook(config.showLabels, (_, v) => self.toggleClassName(v && visible)); - self.hook(App, "window-toggled", (_, window) => { - if (window.name === name) - self.toggleClassName("current", config.showLabels.get() && window.visible); - }); - }} - > - <label truncate wrapMode={Pango.WrapMode.WORD_CHAR} className="label" label={capitalize(name)} /> - </revealer> - </box> - </button> -); - -const SpecialWsButton = ({ name }: { name: SpecialWsName }) => { - const revealChild = Variable.derive( - [config.showLabels, bind(AstalHyprland.get_default(), "focusedClient")], - (l, c) => l && c?.get_workspace().get_name() === `special:${name}` - ); - - return ( - <button - className={bind(AstalHyprland.get_default(), "focusedClient").as(c => - c?.get_workspace().get_name() === `special:${name}` ? "current" : "" - )} - cursor="pointer" - onClicked={() => execAsync(`caelestia toggle ${name}`).catch(console.error)} - > - <box vertical className="nav-button"> - <label className="icon" label={getSpecialWsIcon(name)} /> - <revealer - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={150} - revealChild={bind(revealChild)} - onDestroy={() => revealChild.drop()} - > - <label truncate wrapMode={Pango.WrapMode.WORD_CHAR} className="label" label={capitalize(name)} /> - </revealer> - </box> - </button> - ); -}; - -export default ({ monitor }: { monitor: Monitor }) => { - const sidebar = Variable<SideBar | null>(null); - awaitSidebar(monitor).then(s => sidebar.set(s)); - - return ( - <window - namespace="caelestia-navbar" - monitor={monitor.id} - anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM} - exclusivity={Astal.Exclusivity.EXCLUSIVE} - visible={config.persistent.get()} - setup={self => { - const hyprland = AstalHyprland.get_default(); - const visible = Variable(config.persistent.get()); - - visible.poll(100, () => { - const width = self.visible - ? Math.max(config.appearWidth.get(), self.get_allocated_width()) - : config.appearWidth.get(); - return hyprland.get_cursor_position().x < width; - }); - if (config.persistent.get()) visible.stopPoll(); - - self.hook(config.persistent, (_, v) => { - if (v) { - visible.stopPoll(); - visible.set(true); - } else visible.startPoll(); - }); - - self.hook(visible, (_, v) => self.set_visible(v)); - self.connect("destroy", () => visible.drop()); - }} - > - <eventbox - onScroll={(_, event) => { - const shown = sidebar.get()?.shown; - if (!shown) return; - const idx = paneNames.indexOf(shown.get()); - if (event.delta_y > 0) shown.set(paneNames[Math.min(paneNames.length - 1, idx + 1)]); - else shown.set(paneNames[Math.max(0, idx - 1)]); - }} - > - <box vertical className="navbar"> - {paneNames.map(n => ( - <PaneButton monitor={monitor} name={n} sidebar={sidebar} /> - ))} - {layerNames.map(n => ( - <LayerButton name={`${n}${monitor.id}`} /> - ))} - <box vexpand /> - {specialWsNames.map(n => ( - <SpecialWsButton name={n} /> - ))} - </box> - </eventbox> - </window> - ); -}; diff --git a/src/modules/notifpopups.tsx b/src/modules/notifpopups.tsx deleted file mode 100644 index cb5984d..0000000 --- a/src/modules/notifpopups.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import { setupChildClickthrough } from "@/utils/widgets"; -import Notification from "@/widgets/notification"; -import { Astal, Gtk } from "astal/gtk3"; -import { notifpopups as config } from "config"; -import AstalNotifd from "gi://AstalNotifd"; -import type SideBar from "./sidebar"; -import { awaitSidebar } from "./sidebar"; - -export default ({ monitor }: { monitor: Monitor }) => ( - <window - monitor={monitor.id} - namespace="caelestia-notifpopups" - anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.BOTTOM} - > - <box - vertical - valign={Gtk.Align.START} - className="notifpopups" - setup={self => { - const notifd = AstalNotifd.get_default(); - const map = new Map<number, Notification>(); - - self.hook(notifd, "notified", (self, id) => { - if (notifd.dontDisturb) return; - - const notification = notifd.get_notification(id); - - const popup = (<Notification popup notification={notification} />) as Notification; - popup.connect("destroy", () => map.get(notification.id) === popup && map.delete(notification.id)); - map.get(notification.id)?.destroyWithAnims(); - map.set(notification.id, popup); - - self.add( - <eventbox - onClick={(_, event) => { - // Activate notif or go to notif center on primary click - if (event.button === Astal.MouseButton.PRIMARY) { - if (notification.actions.length === 1) - notification.invoke(notification.actions[0].id); - else { - sidebar?.shown.set("alerts"); - sidebar?.show(); - popup.destroyWithAnims(); - } - } - // Dismiss on middle click - else if (event.button === Astal.MouseButton.MIDDLE) notification.dismiss(); - }} - // Close on hover lost - onHoverLost={() => popup.destroyWithAnims()} - setup={self => self.hook(popup, "destroy", () => self.destroy())} - > - {popup} - </eventbox> - ); - - // Limit number of popups - if (config.maxPopups.get() > 0 && self.children.length > config.maxPopups.get()) - map.values().next().value?.destroyWithAnims(); - }); - self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims()); - - let sidebar: SideBar | null = null; - awaitSidebar(monitor).then(s => (sidebar = s)); - - // Change input region to child region so can click through empty space - setupChildClickthrough(self); - }} - /> - </window> -); diff --git a/src/modules/osds.tsx b/src/modules/osds.tsx deleted file mode 100644 index 0f38823..0000000 --- a/src/modules/osds.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import Monitors, { type Monitor } from "@/services/monitors"; -import { capitalize } from "@/utils/strings"; -import PopupWindow from "@/widgets/popupwindow"; -import { bind, execAsync, register, timeout, Variable, type Time } from "astal"; -import { App, Astal, Gtk, Widget } from "astal/gtk3"; -import cairo from "cairo"; -import { osds as config } from "config"; -import AstalWp from "gi://AstalWp"; -import Cairo from "gi://cairo"; -import Pango from "gi://Pango"; -import PangoCairo from "gi://PangoCairo"; - -const getStyle = (context: Gtk.StyleContext, prop: string) => context.get_property(prop, Gtk.StateFlags.NORMAL); -const getNumStyle = (context: Gtk.StyleContext, prop: string) => getStyle(context, prop) as number; - -const mix = (a: number, b: number, r: number) => a * r + b * (1 - r); - -const pangoWeightToStr = (weight: Pango.Weight) => { - switch (weight) { - case Pango.Weight.ULTRALIGHT: - return "UltraLight"; - case Pango.Weight.LIGHT: - return "Light"; - case Pango.Weight.BOLD: - return "Bold"; - case Pango.Weight.ULTRABOLD: - return "UltraBold"; - case Pango.Weight.HEAVY: - return "Heavy"; - default: - return "Normal"; - } -}; - -const SliderOsd = ({ - fillIcons, - monitor, - type, - windowSetup, - className = "", - initValue, - drawAreaSetup, -}: { - fillIcons?: boolean; - monitor?: Monitor; - type: "volume" | "brightness"; - windowSetup: (self: Widget.Window, show: () => void) => void; - className?: string; - initValue: number; - drawAreaSetup: (self: Widget.DrawingArea, icon: Variable<string>) => void; -}) => ( - <PopupWindow - name={type} - monitor={monitor?.id} - keymode={Astal.Keymode.NONE} - anchor={bind(config[type].position)} - margin={bind(config[type].margin)} - setup={self => { - let time: Time | null = null; - const hideAfterTimeout = () => { - time?.cancel(); - time = timeout(config[type].hideDelay.get(), () => self.hide()); - }; - self.connect("show", hideAfterTimeout); - windowSetup(self, () => { - self.show(); - hideAfterTimeout(); - }); - }} - > - <box className={type}> - <drawingarea - className={`inner ${className}`} - css={"font-size: " + initValue + "px;"} - setup={self => { - const halfPi = Math.PI / 2; - const vertical = - config[type].position.get() === Astal.WindowAnchor.LEFT || - config[type].position.get() === Astal.WindowAnchor.RIGHT; - - const icon = Variable(""); - drawAreaSetup(self, icon); - self.hook(icon, () => self.queue_draw()); - - // Init size - const styleContext = self.get_style_context(); - const width = getNumStyle(styleContext, "min-width"); - const height = getNumStyle(styleContext, "min-height"); - if (vertical) self.set_size_request(height, width); - else self.set_size_request(width, height); - - let fontDesc: Pango.FontDescription | null = null; - - self.connect("draw", (_, cr: cairo.Context) => { - const parent = self.get_parent(); - if (!parent) return; - - const styleContext = self.get_style_context(); - const pContext = parent.get_style_context(); - - let width = getNumStyle(styleContext, "min-width"); - let height = getNumStyle(styleContext, "min-height"); - - const progressValue = getNumStyle(styleContext, "font-size"); - let radius = getNumStyle(pContext, "border-radius"); - // Flatten when near 0, do before swap cause its simpler - radius = Math.min(radius, Math.min(width * progressValue, height) / 2); - - if (vertical) [width, height] = [height, width]; // Swap if vertical - self.set_size_request(width, height); - - const progressPosition = vertical - ? height * (1 - progressValue) + radius // Top is 0, but we want it to start from the bottom - : width * progressValue - radius; - - const bg = styleContext.get_background_color(Gtk.StateFlags.NORMAL); - cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha); - - // Background - if (vertical) { - cr.arc(radius, progressPosition, radius, -Math.PI, -halfPi); // Top left - cr.arc(width - radius, progressPosition, radius, -halfPi, 0); // Top right - cr.arc(width - radius, height - radius, radius, 0, halfPi); // Bottom right - } else { - 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 fg = pContext.get_background_color(Gtk.StateFlags.NORMAL); - cr.setAntialias(cairo.Antialias.BEST); - - // Progress number, at top/right - let nw = 0; - let nh = 0; - if (config[type].showValue.get()) { - const numLayout = parent.create_pango_layout(String(Math.round(progressValue * 100))); - [nw, nh] = numLayout.get_pixel_size(); - let diff; - if (vertical) { - diff = ((1 - progressValue) * height) / nh; - cr.moveTo((width - nw) / 2, radius / 2); - } else { - diff = ((1 - progressValue) * width) / nw; - cr.moveTo(width - nw - radius, (height - nh) / 2); - } - diff = Math.max(0, Math.min(1, diff)); - - cr.setSourceRGBA( - mix(bg.red, fg.red, diff), - mix(bg.green, fg.green, diff), - mix(bg.blue, fg.blue, diff), - mix(bg.alpha, fg.alpha, diff) - ); - - PangoCairo.show_layout(cr, numLayout); - } - - // Progress icon, follows progress - if (fontDesc === null) { - const weight = pangoWeightToStr(getStyle(pContext, "font-weight") as Pango.Weight); - const size = getNumStyle(pContext, "font-size") * 1.5; - fontDesc = Pango.font_description_from_string( - `Material Symbols Rounded ${weight} ${size}px` - ); - // Ugh GTK CSS doesn't support font-variations, so you need to manually create the layout and font desc instead of using Gtk.Widget#create_pango_layout - if (fillIcons) fontDesc.set_variations("FILL=1"); - } - - const iconLayout = PangoCairo.create_layout(cr); - iconLayout.set_font_description(fontDesc); - iconLayout.set_text(icon.get(), -1); - - const [iw, ih] = iconLayout.get_pixel_size(); - let diff; - if (vertical) { - diff = (progressValue * height) / ih; - cr.moveTo( - (width - iw) / 2, - Math.max(nh, Math.min(height - ih, progressPosition - ih / 2 + radius)) - ); - } else { - diff = (progressValue * width) / iw; - cr.moveTo( - Math.min( - width - nw * 1.1 - iw - radius, - Math.max(0, progressPosition - iw / 2 - radius) - ), - (height - ih) / 2 - ); - } - diff = Math.max(0, Math.min(1, diff)); - - cr.setSourceRGBA( - mix(fg.red, bg.red, diff), - mix(fg.green, bg.green, diff), - mix(fg.blue, bg.blue, diff), - mix(fg.alpha, bg.alpha, diff) - ); - - PangoCairo.show_layout(cr, iconLayout); - }); - }} - /> - </box> - </PopupWindow> -); - -const Volume = ({ audio }: { audio: AstalWp.Audio }) => ( - <SliderOsd - fillIcons - type="volume" - windowSetup={(self, show) => { - self.hook(audio.defaultSpeaker, "notify::volume", show); - self.hook(audio.defaultSpeaker, "notify::mute", show); - }} - className={audio.defaultSpeaker.mute ? "mute" : ""} - initValue={audio.defaultSpeaker.volume} - drawAreaSetup={(self, icon) => { - const updateIcon = () => { - if (/head(phone|set)/i.test(audio.defaultSpeaker.icon)) icon.set("headphones"); - else if (audio.defaultSpeaker.mute) icon.set("no_sound"); - else if (audio.defaultSpeaker.volume === 0) icon.set("volume_mute"); - else if (audio.defaultSpeaker.volume <= 0.5) icon.set("volume_down"); - else icon.set("volume_up"); - }; - updateIcon(); - self.hook(audio.defaultSpeaker, "notify::icon", updateIcon); - self.hook(audio.defaultSpeaker, "notify::mute", () => { - updateIcon(); - self.toggleClassName("mute", audio.defaultSpeaker.mute); - }); - self.hook(audio.defaultSpeaker, "notify::volume", () => { - updateIcon(); - self.css = `font-size: ${audio.defaultSpeaker.volume}px`; - }); - }} - /> -); - -const Brightness = ({ monitor }: { monitor: Monitor }) => ( - <SliderOsd - monitor={monitor} - type="brightness" - windowSetup={(self, show) => self.hook(monitor, "notify::brightness", show)} - initValue={monitor.brightness} - drawAreaSetup={(self, icon) => { - const update = () => { - if (monitor.brightness > 0.66) icon.set("brightness_high"); - else if (monitor.brightness > 0.33) icon.set("brightness_medium"); - else if (monitor.brightness > 0) icon.set("brightness_low"); - else icon.set("brightness_empty"); - self.css = `font-size: ${monitor.brightness}px`; - }; - self.hook(monitor, "notify::brightness", update); - update(); - }} - /> -); - -@register() -class LockOsd extends Widget.Window { - readonly lockType: "caps" | "num"; - - #timeout: Time | null = null; - - constructor({ type, icon, right }: { type: "caps" | "num"; icon: string; right?: boolean }) { - super({ - visible: false, - name: `lock-${type}`, - application: App, - namespace: `caelestia-lock-${type}`, - anchor: - Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT, - exclusivity: Astal.Exclusivity.IGNORE, - }); - - this.lockType = type; - this.#update(); - - this.add( - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className={`lock ${type}`}> - <label vexpand className="icon" label={icon} /> - <label vexpand className="text" label={capitalize(type) + "lock"} /> - </box> - ); - - // Clickthrough - this.connect("size-allocate", () => this.input_shape_combine_region(new Cairo.Region())); - - // Move over when other indicator opens/closes - this.hook(App, "window-toggled", (_, window) => { - if (window !== this && window instanceof LockOsd) { - const child = this.get_child(); - if (!child) return; - this[right ? "marginLeft" : "marginRight"] = window.visible - ? child.get_preferred_width()[1] + config.lock.spacing.get() - : 0; - } - }); - } - - #update() { - execAsync(`fish -c 'cat /sys/class/leds/input*::${this.lockType}lock/brightness'`) - .then(out => (this.get_child() as Widget.Box | null)?.toggleClassName("enabled", out.includes("1"))) - .catch(console.error); - } - - show() { - super.show(); - this.#update(); - this.#timeout?.cancel(); - this.#timeout = timeout(config.lock[this.lockType].hideDelay.get(), () => this.hide()); - } -} - -export default () => { - if (AstalWp.get_default()) <Volume audio={AstalWp.get_default()!.audio} />; - Monitors.get_default().forEach(monitor => <Brightness monitor={monitor} />); - - <LockOsd type="caps" icon="keyboard_capslock" />; - <LockOsd right type="num" icon="filter_1" />; - - return null; -}; diff --git a/src/modules/screencorners.tsx b/src/modules/screencorners.tsx deleted file mode 100644 index 4368b87..0000000 --- a/src/modules/screencorners.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import ScreenCorner from "@/widgets/screencorner"; -import { bind } from "astal/binding"; -import { Astal } from "astal/gtk3"; -import { bar } from "config"; -import Cairo from "gi://cairo"; - -export default ({ monitor }: { monitor: Monitor }) => ( - <window - namespace="caelestia-screencorners" - monitor={monitor.id} - anchor={bind(bar.vertical).as( - v => - Astal.WindowAnchor.BOTTOM | - Astal.WindowAnchor.RIGHT | - (v ? Astal.WindowAnchor.TOP : Astal.WindowAnchor.LEFT) - )} - setup={self => - self.connect("size-allocate", () => self.get_window()?.input_shape_combine_region(new Cairo.Region(), 0, 0)) - } - > - <box vertical={bind(bar.vertical)}> - <ScreenCorner place={bind(bar.vertical).as(v => (v ? "topright" : "bottomleft"))} /> - <box expand /> - <ScreenCorner place="bottomright" /> - </box> - </window> -); - -export const BarScreenCorners = ({ monitor }: { monitor: Monitor }) => ( - <window - namespace="caelestia-screencorners" - monitor={monitor.id} - anchor={bind(bar.vertical).as( - v => - Astal.WindowAnchor.TOP | - Astal.WindowAnchor.LEFT | - (v ? Astal.WindowAnchor.BOTTOM : Astal.WindowAnchor.RIGHT) - )} - visible={bind(bar.style).as(s => s === "embedded")} - setup={self => - self.connect("size-allocate", () => self.get_window()?.input_shape_combine_region(new Cairo.Region(), 0, 0)) - } - > - <box vertical={bind(bar.vertical)}> - <ScreenCorner place="topleft" /> - <box expand /> - <ScreenCorner place={bind(bar.vertical).as(v => (v ? "bottomleft" : "topright"))} /> - </box> - </window> -); diff --git a/src/modules/session.tsx b/src/modules/session.tsx deleted file mode 100644 index 40d3b31..0000000 --- a/src/modules/session.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import PopupWindow from "@/widgets/popupwindow"; -import { execAsync } from "astal"; -import { App, Astal, Gtk } from "astal/gtk3"; - -const Item = ({ icon, label, cmd, isDefault }: { icon: string; label: string; cmd: string; isDefault?: boolean }) => ( - <box vertical className="item"> - <button - cursor="pointer" - onClicked={() => execAsync(cmd).catch(console.error)} - setup={self => - isDefault && - self.hook(App, "window-toggled", (_, window) => { - if (window.name === "session" && window.visible) self.grab_focus(); - }) - } - > - <label className="icon" label={icon} /> - </button> - <label className="label" label={label} /> - </box> -); - -export default () => ( - <PopupWindow - className="session" - name="session" - anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT} - exclusivity={Astal.Exclusivity.IGNORE} - keymode={Astal.Keymode.EXCLUSIVE} - layer={Astal.Layer.OVERLAY} - borderWidth={0} // Don't need border width cause takes up entire screen - > - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="inner"> - <box> - <Item icon="logout" label="Logout" cmd="uwsm stop" isDefault /> - <Item icon="cached" label="Reboot" cmd="systemctl reboot" /> - </box> - <box> - <Item icon="downloading" label="Hibernate" cmd="systemctl hibernate" /> - <Item icon="power_settings_new" label="Shutdown" cmd="systemctl poweroff" /> - </box> - </box> - </PopupWindow> -); diff --git a/src/modules/sidebar/alerts.tsx b/src/modules/sidebar/alerts.tsx deleted file mode 100644 index 9599aff..0000000 --- a/src/modules/sidebar/alerts.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import Headlines from "./modules/headlines"; -import Notifications from "./modules/notifications"; - -export default ({ monitor }: { monitor: Monitor }) => ( - <box vertical className="pane alerts" name="alerts"> - <Notifications /> - <box className="separator" /> - <Headlines monitor={monitor} /> - </box> -); diff --git a/src/modules/sidebar/audio.tsx b/src/modules/sidebar/audio.tsx deleted file mode 100644 index 20a6551..0000000 --- a/src/modules/sidebar/audio.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import DeviceSelector from "./modules/deviceselector"; -import Media from "./modules/media"; -import Streams from "./modules/streams"; - -export default () => ( - <box vertical className="pane audio" name="audio"> - <Media /> - <box className="separator" /> - <Streams /> - <box className="separator" /> - <DeviceSelector /> - </box> -); diff --git a/src/modules/sidebar/connectivity.tsx b/src/modules/sidebar/connectivity.tsx deleted file mode 100644 index 2962b56..0000000 --- a/src/modules/sidebar/connectivity.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Bluetooth from "./modules/bluetooth"; -import Networks from "./modules/networks"; - -export default () => ( - <box vertical className="pane connectivity" name="connectivity"> - <Networks /> - <box className="separator" /> - <Bluetooth /> - </box> -); diff --git a/src/modules/sidebar/dashboard.tsx b/src/modules/sidebar/dashboard.tsx deleted file mode 100644 index 1a8626f..0000000 --- a/src/modules/sidebar/dashboard.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import Players from "@/services/players"; -import { lengthStr } from "@/utils/strings"; -import { bindCurrentTime, osIcon } from "@/utils/system"; -import Slider from "@/widgets/slider"; -import { bind, GLib, monitorFile, Variable } from "astal"; -import { Gtk } from "astal/gtk3"; -import AstalMpris from "gi://AstalMpris"; -import Notifications from "./modules/notifications"; -import Upcoming from "./modules/upcoming"; - -const noNull = (s: string | null) => s ?? "-"; - -const FaceFallback = () => ( - <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 = ""; - self.xalign = 0.44; - } - }} - /> -); - -const User = () => { - const uptime = Variable("").poll(5000, "uptime -p"); - const hasFace = Variable(GLib.file_test(HOME + "/.face", GLib.FileTest.EXISTS)); - - return ( - <box className="user"> - <box - homogeneous - className="face" - setup={self => { - self.css = `background-image: url("${HOME}/.face");`; - const monitor = monitorFile(HOME + "/.face", () => { - hasFace.set(GLib.file_test(HOME + "/.face", GLib.FileTest.EXISTS)); - self.css = `background-image: url("${HOME}/.face");`; - }); - self.connect("destroy", () => monitor.cancel()); - }} - > - {bind(hasFace).as(h => (h ? <box visible={false} /> : <FaceFallback />))} - </box> - <box vertical hexpand valign={Gtk.Align.CENTER} className="details"> - <label truncate xalign={0} className="name" label={`${osIcon} ${GLib.get_user_name()}`} /> - <label truncate xalign={0} label={bind(uptime)} onDestroy={() => uptime.drop()} /> - <label truncate xalign={0} label={bindCurrentTime("%A, %e %B")} /> - </box> - </box> - ); -}; - -const Media = ({ player }: { player: AstalMpris.Player | null }) => { - const position = player - ? Variable.derive([bind(player, "position"), bind(player, "length")], (p, l) => p / l) - : Variable(0); - - return ( - <box className="media" onDestroy={() => position.drop()}> - <box - homogeneous - className="cover-art" - css={player ? bind(player, "coverArt").as(a => `background-image: url("${a}");`) : ""} - > - {player ? ( - bind(player, "coverArt").as(a => (a ? <box visible={false} /> : <label xalign={0.31} label="" />)) - ) : ( - <label xalign={0.31} label="" /> - )} - </box> - <box vertical className="details"> - <label truncate className="title" label={player ? bind(player, "title").as(noNull) : ""} /> - <label truncate className="artist" label={player ? bind(player, "artist").as(noNull) : "No media"} /> - <box hexpand className="controls"> - <button - hexpand - sensitive={player ? bind(player, "canGoPrevious") : false} - cursor="pointer" - onClicked={() => player?.next()} - label="" - /> - <button - hexpand - sensitive={player ? bind(player, "canControl") : false} - cursor="pointer" - onClicked={() => player?.play_pause()} - label={ - player - ? bind(player, "playbackStatus").as(s => - s === AstalMpris.PlaybackStatus.PLAYING ? "" : "" - ) - : "" - } - /> - <button - hexpand - sensitive={player ? bind(player, "canGoNext") : false} - cursor="pointer" - onClicked={() => player?.next()} - label="" - /> - </box> - <Slider value={bind(position)} onChange={(_, v) => player?.set_position(v * player.length)} /> - <box className="time"> - <label label={player ? bind(player, "position").as(lengthStr) : "-1:-1"} /> - <box hexpand /> - <label label={player ? bind(player, "length").as(lengthStr) : "-1:-1"} /> - </box> - </box> - </box> - ); -}; - -export default () => ( - <box vertical className="pane dashboard" name="dashboard"> - <User /> - <box className="separator" /> - {bind(Players.get_default(), "lastPlayer").as(p => ( - <Media player={p} /> - ))} - <box className="separator" /> - <Notifications compact /> - <box className="separator" /> - <Upcoming /> - </box> -); diff --git a/src/modules/sidebar/index.tsx b/src/modules/sidebar/index.tsx deleted file mode 100644 index 7570283..0000000 --- a/src/modules/sidebar/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import { bind, idle, register, Variable } from "astal"; -import { App, Astal, Gdk, Gtk, Widget } from "astal/gtk3"; -import { sidebar as config } from "config"; -import Alerts from "./alerts"; -import Audio from "./audio"; -import Connectivity from "./connectivity"; -import Dashboard from "./dashboard"; -import Packages from "./packages"; -import Time from "./time"; - -export const paneNames = ["dashboard", "audio", "connectivity", "packages", "alerts", "time"] as const; -export type PaneName = (typeof paneNames)[number]; - -export const switchPane = (monitor: Monitor, name: PaneName) => { - const sidebar = App.get_window(`sidebar${monitor.id}`) as SideBar | null; - if (sidebar) { - if (sidebar.visible && sidebar.shown.get() === name) sidebar.hide(); - else sidebar.show(); - sidebar.shown.set(name); - } -}; - -export const awaitSidebar = (monitor: Monitor) => - new Promise<SideBar>(resolve => { - let sidebar: SideBar | null = null; - - const awaitSidebar = () => { - sidebar = App.get_window(`sidebar${monitor.id}`) as SideBar | null; - if (sidebar) resolve(sidebar); - else idle(awaitSidebar); - }; - idle(awaitSidebar); - }); - -const getPane = (monitor: Monitor, name: PaneName) => { - if (name === "dashboard") return <Dashboard />; - if (name === "audio") return <Audio />; - if (name === "connectivity") return <Connectivity />; - if (name === "packages") return <Packages monitor={monitor} />; - if (name === "alerts") return <Alerts monitor={monitor} />; - return <Time />; -}; - -@register() -export default class SideBar extends Widget.Window { - readonly shown: Variable<PaneName>; - - constructor({ monitor }: { monitor: Monitor }) { - super({ - application: App, - name: `sidebar${monitor.id}`, - namespace: "caelestia-sidebar", - monitor: monitor.id, - anchor: Astal.WindowAnchor.LEFT | Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM, - exclusivity: Astal.Exclusivity.EXCLUSIVE, - visible: false, - }); - - this.shown = Variable(paneNames[0]); - - this.add( - <eventbox - onScroll={(_, event) => { - if (event.modifier & Gdk.ModifierType.BUTTON1_MASK) { - const index = paneNames.indexOf(this.shown.get()) + (event.delta_y < 0 ? -1 : 1); - if (index < 0 || index >= paneNames.length) return; - this.shown.set(paneNames[index]); - } - }} - > - <box vertical className="sidebar"> - <stack - vexpand - transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN} - transitionDuration={200} - shown={bind(this.shown)} - > - {paneNames.map(n => getPane(monitor, n))} - </stack> - </box> - </eventbox> - ); - - if (config.showOnStartup.get()) idle(() => this.show()); - } -} diff --git a/src/modules/sidebar/modules/bluetooth.tsx b/src/modules/sidebar/modules/bluetooth.tsx deleted file mode 100644 index 89d0cb7..0000000 --- a/src/modules/sidebar/modules/bluetooth.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { bind, Variable } from "astal"; -import { Astal, Gtk } from "astal/gtk3"; -import AstalBluetooth from "gi://AstalBluetooth"; - -const sortDevices = (a: AstalBluetooth.Device, b: AstalBluetooth.Device) => { - if (a.connected || b.connected) return a.connected ? -1 : 1; - if (a.paired || b.paired) return a.paired ? -1 : 1; - return 0; -}; - -const BluetoothDevice = (device: AstalBluetooth.Device) => ( - <box className={bind(device, "connected").as(c => `device ${c ? "connected" : ""}`)}> - <icon - className="icon" - icon={bind(device, "icon").as(i => - Astal.Icon.lookup_icon(`${i}-symbolic`) ? `${i}-symbolic` : "bluetooth-symbolic" - )} - /> - <box vertical hexpand> - <label truncate xalign={0} label={bind(device, "alias")} /> - <label - truncate - className="sublabel" - xalign={0} - setup={self => { - const update = () => { - self.label = - (device.connected ? "Connected" : "Paired") + - (device.batteryPercentage >= 0 ? ` (${device.batteryPercentage * 100}%)` : ""); - self.visible = device.connected || device.paired; - }; - self.hook(device, "notify::connected", update); - self.hook(device, "notify::paired", update); - self.hook(device, "notify::battery-percentage", update); - update(); - }} - /> - </box> - <button - valign={Gtk.Align.CENTER} - visible={bind(device, "paired")} - cursor="pointer" - onClicked={() => AstalBluetooth.get_default().adapter.remove_device(device)} - label="delete" - /> - <button - valign={Gtk.Align.CENTER} - cursor="pointer" - onClicked={self => { - if (device.connected) - device.disconnect_device((_, res) => { - self.sensitive = true; - device.disconnect_device_finish(res); - }); - else - device.connect_device((_, res) => { - self.sensitive = true; - device.connect_device_finish(res); - }); - self.sensitive = false; - }} - label={bind(device, "connected").as(c => (c ? "bluetooth_disabled" : "bluetooth_searching"))} - /> - </box> -); - -const List = ({ devNotify }: { devNotify: Variable<boolean> }) => ( - <box vertical valign={Gtk.Align.START} className="list"> - {bind(devNotify).as(() => AstalBluetooth.get_default().devices.sort(sortDevices).map(BluetoothDevice))} - </box> -); - -const NoDevices = () => ( - <box homogeneous name="empty"> - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label="bluetooth_searching" /> - <label label="No Bluetooth devices" /> - </box> - </box> -); - -export default () => { - const bluetooth = AstalBluetooth.get_default(); - const devNotify = Variable(false); // Aggregator for device state changes (connected/paired) - - const update = () => devNotify.set(!devNotify.get()); - const connectSignals = (device: AstalBluetooth.Device) => { - device.connect("notify::connected", update); - device.connect("notify::paired", update); - }; - bluetooth.get_devices().forEach(connectSignals); - bluetooth.connect("device-added", (_, device) => connectSignals(device)); - bluetooth.connect("notify::devices", update); - - return ( - <box vertical className="bluetooth"> - <box className="header-bar"> - <label - label={bind(devNotify).as(() => { - const nConnected = bluetooth.get_devices().filter(d => d.connected).length; - return `${nConnected} connected device${nConnected === 1 ? "" : "s"}`; - })} - /> - <box hexpand /> - <button - className={bind(bluetooth.adapter, "discovering").as(d => (d ? "enabled" : ""))} - cursor="pointer" - onClicked={() => { - if (bluetooth.adapter.discovering) bluetooth.adapter.start_discovery(); - else bluetooth.adapter.stop_discovery(); - }} - label=" Discovery" - /> - </box> - <stack - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={200} - shown={bind(bluetooth, "devices").as(d => (d.length > 0 ? "list" : "empty"))} - > - <NoDevices /> - <scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list"> - <List devNotify={devNotify} /> - </scrollable> - </stack> - </box> - ); -}; diff --git a/src/modules/sidebar/modules/calendar.tsx b/src/modules/sidebar/modules/calendar.tsx deleted file mode 100644 index bb36909..0000000 --- a/src/modules/sidebar/modules/calendar.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import Calendar, { type IEvent } from "@/services/calendar"; -import { setupCustomTooltip } from "@/utils/widgets"; -import { bind, GLib, Variable } from "astal"; -import { Gtk } from "astal/gtk3"; -import ical from "ical.js"; - -const isLeapYear = (year: number) => year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0); - -const getMonthDays = (month: number, year: number) => { - const leapYear = isLeapYear(year); - if (month === 2 && leapYear) return leapYear ? 29 : 28; - if ((month <= 7 && month % 2 === 1) || (month >= 8 && month % 2 === 0)) return 31; - return 30; -}; - -const getNextMonthDays = (month: number, year: number) => { - if (month === 12) return 31; - return getMonthDays(month + 1, year); -}; - -const getPrevMonthDays = (month: number, year: number) => { - if (month === 1) return 31; - return getMonthDays(month - 1, year); -}; - -export function getCalendarLayout(date: ical.Time) { - const weekdayOfMonthFirst = date.startOfMonth().dayOfWeek(ical.Time.MONDAY); - const daysInMonth = getMonthDays(date.month, date.year); - const daysInPrevMonth = getPrevMonthDays(date.month, date.year); - - const calendar: ical.Time[][] = []; - let idx = -weekdayOfMonthFirst + 2; - - for (let i = 0; i < 6; i++) { - calendar.push([]); - - for (let j = 0; j < 7; j++) { - let cDay = idx++; - let cMonth = date.month; - let cYear = date.year; - - if (idx < 0) { - cDay = daysInPrevMonth + cDay; - cMonth--; - - if (cMonth < 0) { - cMonth += 12; - cYear--; - } - } else if (idx > daysInMonth) { - cDay -= daysInMonth; - cMonth++; - - if (cMonth > 12) { - cMonth -= 12; - cYear++; - } - } - - calendar[i].push(ical.Time.fromData({ day: cDay, month: cMonth, year: cYear })); - } - } - - return calendar; -} - -const dateToMonthYear = (date: ical.Time) => { - const months = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ]; - return `${months[date.month - 1]} ${date.year}`; -}; - -const addMonths = (date: ical.Time, num: number) => { - date = date.clone(); - if (num > 0) for (let i = 0; i < num; i++) date.adjust(getNextMonthDays(date.month, date.year), 0, 0, 0); - else for (let i = 0; i > num; i--) date.adjust(-getPrevMonthDays(date.month, date.year), 0, 0, 0); - return date; -}; - -const getDayClassName = (day: ical.Time, current: Variable<ical.Time>) => { - const isToday = day.toJSDate().toDateString() === new Date().toDateString() ? "today" : ""; - const numEvents = Math.min(5, Calendar.get_default().getEventsForDay(day).length); - return `day ${isToday} ${day.month !== current.get().month ? "dim" : ""} events-${numEvents}`; -}; - -const getDayTooltip = (day: ical.Time) => { - const events = Calendar.get_default().getEventsForDay(day); - if (!events.length) return ""; - const eventsStr = events - .map(e => { - const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime()); - const end = GLib.DateTime.new_from_unix_local(e.endDate.toUnixTime()); - const sameAmPm = start.format("%P") === end.format("%P"); - const time = `${start.format(`%-I:%M${sameAmPm ? "" : "%P"}`)} — ${end.format("%-I:%M%P")}`; - return `<b>${e.event.summary.replaceAll("&", "&")}</b> • ${time}`; - }) - .join("\n"); - return `${events.length} event${events.length === 1 ? "" : "s"}\n${eventsStr}`; -}; - -const getEventsHeader = (current: ical.Time) => { - const events = Calendar.get_default().getEventsForDay(current); - const isToday = current.toJSDate().toDateString() === new Date().toDateString(); - return ( - (isToday ? "Today • " : "") + - GLib.DateTime.new_from_unix_local(current.toUnixTime()).format("%B %-d • %A") + - ` • ${events.length} event${events.length === 1 ? "" : "s"}` - ); -}; - -const getEventHeader = (e: IEvent) => { - const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime()); - const time = `${start.format("%-I")}${start.get_minute() > 0 ? `:${start.get_minute()}` : ""}${start.format("%P")}`; - return `${time} <b>${e.event.summary.replaceAll("&", "&")}</b>`; -}; - -const getEventTooltip = (e: IEvent) => { - const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime()); - const end = GLib.DateTime.new_from_unix_local(e.event.endDate.toUnixTime()); - const sameAmPm = start.format("%P") === end.format("%P"); - const time = `${start.format(`%A, %-d %B • %-I:%M${sameAmPm ? "" : "%P"}`)} — ${end.format("%-I:%M%P")}`; - const locIfExists = e.event.location ? ` ${e.event.location}\n` : ""; - const descIfExists = e.event.description ? ` ${e.event.description}\n` : ""; - return `<b>${e.event.summary}</b>\n${time}\n${locIfExists}${descIfExists} ${e.calendar}`.replaceAll("&", "&"); -}; - -const Day = ({ day, shown, current }: { day: ical.Time; shown: Variable<string>; current: Variable<ical.Time> }) => ( - <button - className={bind(Calendar.get_default(), "calendars").as(() => getDayClassName(day, current))} - cursor="pointer" - onClicked={() => { - shown.set("events"); - current.set(day); - }} - setup={self => - setupCustomTooltip( - self, - bind(Calendar.get_default(), "calendars").as(() => getDayTooltip(day)), - { useMarkup: true } - ) - } - > - <box vertical> - <label label={day.day.toString()} /> - <box className="indicator" /> - </box> - </button> -); - -const CalendarView = ({ shown, current }: { shown: Variable<string>; current: Variable<ical.Time> }) => ( - <box vertical className="calendar-view" name="calendar"> - <box className="header"> - <button - cursor="pointer" - onClicked={() => current.set(ical.Time.now())} - label={bind(current).as(dateToMonthYear)} - /> - <box hexpand /> - <button cursor="pointer" onClicked={() => current.set(addMonths(current.get(), -1))} label="" /> - <button cursor="pointer" onClicked={() => current.set(addMonths(current.get(), 1))} label="" /> - </box> - <box halign={Gtk.Align.CENTER} className="weekdays"> - {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(d => ( - <label label={d} /> - ))} - </box> - <box vertical halign={Gtk.Align.CENTER} className="month"> - {bind(current).as(c => - getCalendarLayout(c).map(r => ( - <box className="week"> - {r.map(d => ( - <Day day={d} shown={shown} current={current} /> - ))} - </box> - )) - )} - </box> - </box> -); - -const Event = (event: IEvent) => ( - <box className="event" setup={self => setupCustomTooltip(self, getEventTooltip(event), { useMarkup: true })}> - <box className={`calendar-indicator calendar-${Calendar.get_default().getCalendarIndex(event.calendar)}`} /> - <box vertical> - <label truncate useMarkup xalign={0} label={getEventHeader(event)} /> - {event.event.location && <label truncate xalign={0} label={event.event.location} className="sublabel" />} - {event.event.description && ( - <label truncate useMarkup xalign={0} label={event.event.description} className="sublabel" /> - )} - </box> - </box> -); - -const List = ({ current }: { current: Variable<ical.Time> }) => ( - <box vertical valign={Gtk.Align.START} className="list"> - {bind(current).as(c => Calendar.get_default().getEventsForDay(c).map(Event))} - </box> -); - -const NoEvents = () => ( - <box homogeneous name="empty"> - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label="calendar_month" /> - <label label="Day all clear!" /> - </box> - </box> -); - -const Events = ({ shown, current }: { shown: Variable<string>; current: Variable<ical.Time> }) => ( - <box vertical className="events" name="events"> - <box className="header"> - <button cursor="pointer" onClicked={() => shown.set("calendar")} label="" /> - <label hexpand truncate xalign={0} label={bind(current).as(getEventsHeader)} /> - </box> - <stack shown={bind(current).as(c => (Calendar.get_default().getEventsForDay(c).length > 0 ? "list" : "empty"))}> - <NoEvents /> - <scrollable hscroll={Gtk.PolicyType.NEVER} name="list"> - <List current={current} /> - </scrollable> - </stack> - </box> -); - -export default () => { - const shown = Variable<"calendar" | "events">("calendar"); - const current = Variable(ical.Time.now()); - - return ( - <box vertical className="calendar"> - <stack - transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT} - transitionDuration={150} - shown={bind(shown)} - > - <CalendarView shown={shown} current={current} /> - <Events shown={shown} current={current} /> - </stack> - </box> - ); -}; diff --git a/src/modules/sidebar/modules/deviceselector.tsx b/src/modules/sidebar/modules/deviceselector.tsx deleted file mode 100644 index e74e6f5..0000000 --- a/src/modules/sidebar/modules/deviceselector.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { bind, execAsync, Variable, type Binding } from "astal"; -import { Astal, Gtk } from "astal/gtk3"; -import AstalWp from "gi://AstalWp"; - -const Device = ({ - input, - defaultDevice, - showDropdown, - device, -}: { - input?: boolean; - defaultDevice: Binding<AstalWp.Endpoint>; - showDropdown: Variable<boolean>; - device: AstalWp.Endpoint; -}) => ( - <button - visible={defaultDevice.get().id !== device.id} - cursor="pointer" - onClicked={() => { - execAsync(`wpctl set-default ${device.id}`).catch(console.error); - showDropdown.set(false); - }} - setup={self => { - let last: { d: AstalWp.Endpoint; id: number } | null = { - d: defaultDevice.get(), - id: defaultDevice - .get() - .connect("notify::id", () => self.set_visible(defaultDevice.get().id !== device.id)), - }; - self.hook(defaultDevice, (_, d) => { - last?.d.disconnect(last.id); - self.set_visible(d.id !== device.id); - last = { - d, - id: d.connect("notify::id", () => self.set_visible(d.id !== device.id)), - }; - }); - self.connect("destroy", () => last?.d.disconnect(last.id)); - }} - > - <box className="device"> - {bind(device, "icon").as(i => - Astal.Icon.lookup_icon(i) ? ( - <icon className="icon" icon={device.icon} /> - ) : ( - <label className="icon" label={input ? "mic" : "media_output"} /> - ) - )} - <label truncate label={bind(device, "description")} /> - </box> - </button> -); - -const DefaultDevice = ({ input, device }: { input?: boolean; device: AstalWp.Endpoint }) => ( - <box className="selected"> - <label className="icon" label={input ? "mic" : "media_output"} /> - <box vertical> - <label - truncate - xalign={0} - label={bind(device, "description").as(d => (input ? "[In] " : "[Out] ") + (d ?? "Unknown"))} - /> - <label - xalign={0} - className="sublabel" - label={bind(device, "volume").as(v => `Volume ${Math.round(v * 100)}%`)} - /> - </box> - </box> -); - -const Selector = ({ input, audio }: { input?: boolean; audio: AstalWp.Audio }) => { - const showDropdown = Variable(false); - const defaultDevice = bind(audio, input ? "defaultMicrophone" : "defaultSpeaker"); - - return ( - <box vertical className="selector"> - <revealer - transitionType={Gtk.RevealerTransitionType.SLIDE_UP} - transitionDuration={150} - revealChild={bind(showDropdown)} - > - <box vertical className="list"> - {bind(audio, input ? "microphones" : "speakers").as(ds => - ds.map(d => ( - <Device - input={input} - defaultDevice={defaultDevice} - showDropdown={showDropdown} - device={d} - /> - )) - )} - <box className="separator" /> - </box> - </revealer> - <button cursor="pointer" onClick={() => showDropdown.set(!showDropdown.get())}> - {defaultDevice.as(d => ( - <DefaultDevice input={input} device={d} /> - ))} - </button> - </box> - ); -}; - -const NoWp = () => ( - <box homogeneous> - <box vertical valign={Gtk.Align.CENTER}> - <label label="Device selector unavailable" /> - <label className="no-wp-prompt" label="WirePlumber is required for this module" /> - </box> - </box> -); - -export default () => { - const audio = AstalWp.get_default()?.get_audio(); - - if (!audio) return <NoWp />; - - return ( - <box vertical className="device-selector"> - <Selector input audio={audio} /> - <Selector audio={audio} /> - </box> - ); -}; diff --git a/src/modules/sidebar/modules/headlines.tsx b/src/modules/sidebar/modules/headlines.tsx deleted file mode 100644 index 40d468b..0000000 --- a/src/modules/sidebar/modules/headlines.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import News, { type IArticle } from "@/services/news"; -import Palette, { type IPalette } from "@/services/palette"; -import { capitalize } from "@/utils/strings"; -import { bind, execAsync, Variable } from "astal"; -import { Gtk } from "astal/gtk3"; -import { sidebar } from "config"; -import { setConfig } from "config/funcs"; - -const fixGoogleNews = (colours: IPalette, title: string, desc: string) => { - // Add separator, bold and split at domain (domain is at the end of each headline) - const domain = title.split(" - ").at(-1); - if (domain) desc = desc.replaceAll(domain, `— <span foreground="${colours.subtext0}">${domain}</span>\n\n`); - // Split headlines - desc = desc.replace(/(( |\.)[^A-Z][a-z]+)([A-Z])/g, "$1\n\n$3"); - desc = desc.replace(/( [A-Z]+)([A-Z](?![s])[a-z])/g, "$1\n\n$2"); - // Add separator and bold domains - desc = desc.replace(/ ([a-zA-Z.]+)\n\n/g, ` — <span foreground="${colours.subtext0}">$1</span>\n\n`); - desc = desc.replace(/ ([a-zA-Z.]+)$/, ` — <span foreground="${colours.subtext0}">$1</span>`); // Last domain - return desc.trim(); -}; - -const fixNews = (colours: IPalette, title: string, desc: string, source: string) => { - // Add spaces between sentences - desc = desc.replace(/\.([A-Z])/g, ". $1"); - // Google News needs some other fixes - if (source === "Google News") desc = fixGoogleNews(colours, title, desc); - return desc.replaceAll("&", "&"); -}; - -const getCategoryIcon = (category: string) => { - if (category === "business") return "monitoring"; - if (category === "crime") return "speed_camera"; - if (category === "domestic") return "home"; - if (category === "education") return "school"; - if (category === "entertainment") return "tv"; - if (category === "environment") return "eco"; - if (category === "food") return "restaurant"; - if (category === "health") return "health_and_safety"; - if (category === "lifestyle") return "digital_wellbeing"; - if (category === "politics") return "account_balance"; - if (category === "science") return "science"; - if (category === "sports") return "sports_basketball"; - if (category === "technology") return "account_tree"; - if (category === "top") return "breaking_news"; - if (category === "tourism") return "travel"; - if (category === "world") return "public"; - return "newsmode"; -}; - -const Article = ({ title, description, creator, pubDate, source_name, link }: IArticle) => { - const expanded = Variable(false); - - return ( - <box vertical className="article"> - <button className="wrapper" cursor="pointer" onClicked={() => expanded.set(!expanded.get())}> - <box hexpand className="header"> - <box vertical> - <label truncate xalign={0} label={title} /> - <label - truncate - xalign={0} - className="sublabel" - label={source_name + (creator ? ` (${creator.join(", ")})` : "")} - /> - </box> - </box> - </button> - <revealer - revealChild={bind(expanded)} - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={200} - > - <button onClicked={() => execAsync(`app2unit -O -- ${link}`)}> - <box vertical className="article-body"> - <label wrap className="title" xalign={0} label={title} /> - <label wrap xalign={0} label={`Published on ${new Date(pubDate).toLocaleString()}`} /> - <label - wrap - xalign={0} - className="sublabel" - label={`By ${ - creator?.join(", ") ?? - (source_name === "Google News" ? title.split(" - ").at(-1) : source_name) - }`} - /> - {description && ( - <label - wrap - useMarkup - xalign={0} - label={bind(Palette.get_default(), "colours").as(c => - fixNews(c, title, description, source_name) - )} - /> - )} - </box> - </button> - </revealer> - </box> - ); -}; - -const Category = ({ title, articles }: { title: string; articles: IArticle[] }) => { - const expanded = Variable(false); - - return ( - <box vertical className="category"> - <button className="wrapper" cursor="pointer" onClicked={() => expanded.set(!expanded.get())}> - <box className="header"> - <label className="icon" label={getCategoryIcon(title)} /> - <label label={`${capitalize(title)} (${articles.length})`} /> - <box hexpand /> - <label className="icon" label={bind(expanded).as(e => (e ? "expand_less" : "expand_more"))} /> - </box> - </button> - <revealer - revealChild={bind(expanded)} - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={200} - > - <box vertical className="body"> - {articles - .sort((a, b) => a.source_priority - b.source_priority) - .map(a => ( - <Article {...a} /> - ))} - </box> - </revealer> - </box> - ); -}; - -const List = () => ( - <box vertical valign={Gtk.Align.START} className="list"> - {bind(News.get_default(), "categories").as(c => - Object.entries(c).map(([k, v]) => <Category title={k} articles={v} />) - )} - </box> -); - -const NoNews = ({ disabled }: { disabled?: boolean }) => ( - <box homogeneous name="empty"> - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label="full_coverage" /> - <label label={disabled ? "Headlines disabled" : "No news headlines!"} /> - </box> - </box> -); - -const HeadlinesDisabled = () => ( - <> - <box vertical className="headlines"> - <box className="header-bar"> - <label label="Top news headlines" /> - <box hexpand /> - <button - cursor="pointer" - onClicked={() => setConfig("sidebar.modules.headlines.enabled", true)} - label=" Enable" - /> - </box> - <NoNews disabled /> - </box> - </> -); - -const Headlines = ({ monitor }: { monitor: Monitor }) => ( - <> - <box className="header-bar"> - <label label="Top news headlines" /> - <box hexpand /> - <button - className={bind(News.get_default(), "loading").as(l => (l ? "enabled" : ""))} - sensitive={bind(News.get_default(), "loading").as(l => !l)} - cursor="pointer" - onClicked={() => News.get_default().getNews()} - label={bind(News.get_default(), "loading").as(l => (l ? " Loading" : " Reload"))} - /> - </box> - <stack - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={200} - shown={bind(News.get_default(), "articles").as(a => (a.length > 0 ? "list" : "empty"))} - > - <NoNews /> - <scrollable - css={bind(News.get_default(), "articles").as(a => - a.length > 0 ? `min-height: ${Math.round(monitor.height * 0.4)}px;` : "" - )} - hscroll={Gtk.PolicyType.NEVER} - name="list" - > - <List /> - </scrollable> - </stack> - </> -); - -export default ({ monitor }: { monitor: Monitor }) => ( - <box vertical className="headlines"> - {bind(sidebar.modules.headlines.enabled).as(e => (e ? <Headlines monitor={monitor} /> : <HeadlinesDisabled />))} - </box> -); diff --git a/src/modules/sidebar/modules/hwresources.tsx b/src/modules/sidebar/modules/hwresources.tsx deleted file mode 100644 index 768d8bd..0000000 --- a/src/modules/sidebar/modules/hwresources.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import Cpu from "@/services/cpu"; -import Gpu from "@/services/gpu"; -import Memory from "@/services/memory"; -import Storage from "@/services/storage"; -import Slider from "@/widgets/slider"; -import { bind, type Binding } from "astal"; -import { Gtk, type Widget } from "astal/gtk3"; - -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 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> -); - -export default () => ( - <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> -); diff --git a/src/modules/sidebar/modules/media.tsx b/src/modules/sidebar/modules/media.tsx deleted file mode 100644 index 169a98d..0000000 --- a/src/modules/sidebar/modules/media.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import Players from "@/services/players"; -import { lengthStr } from "@/utils/strings"; -import Slider from "@/widgets/slider"; -import { bind, timeout, Variable } from "astal"; -import { Gtk } from "astal/gtk3"; -import AstalMpris from "gi://AstalMpris"; - -const noNull = (s: string | null) => s ?? "-"; - -const NoMedia = () => ( - <box vertical className="player" name="none"> - <box homogeneous halign={Gtk.Align.CENTER} className="cover-art"> - <label xalign={0.4} label="" /> - </box> - <box vertical className="progress"> - <Slider value={bind(Variable(0))} /> - <box className="time"> - <label label="-1:-1" /> - <box hexpand /> - <label label="-1:-1" /> - </box> - </box> - <box vertical className="details"> - <label truncate className="title" label="No media" /> - <label truncate className="artist" label="Try play some music!" /> - <label truncate className="album" label="" /> - </box> - <box vertical className="controls"> - <box halign={Gtk.Align.CENTER} className="playback"> - <button sensitive={false} cursor="pointer" label="" /> - <button sensitive={false} cursor="pointer" label="" /> - <button sensitive={false} cursor="pointer" label="" /> - </box> - <box className="options"> - <button sensitive={false} cursor="pointer" label="" /> - <button sensitive={false} cursor="pointer" label="" /> - <box hexpand /> - <button className="needs-adjustment" sensitive={false} cursor="pointer" label="" /> - <button className="needs-adjustment" sensitive={false} cursor="pointer" label="" /> - </box> - </box> - </box> -); - -const Player = ({ player }: { player: AstalMpris.Player }) => { - const position = Variable.derive([bind(player, "position"), bind(player, "length")], (p, l) => p / l); - - return ( - <box vertical className="player" name={player.busName} onDestroy={() => position.drop()}> - <box - homogeneous - halign={Gtk.Align.CENTER} - className="cover-art" - css={bind(player, "coverArt").as(a => `background-image: url("${a}");`)} - > - {bind(player, "coverArt").as(a => (a ? <box visible={false} /> : <label xalign={0.4} label="" />))} - </box> - <box vertical className="progress"> - <Slider value={bind(position)} onChange={(_, v) => player.set_position(v * player.length)} /> - <box className="time"> - <label label={bind(player, "position").as(lengthStr)} /> - <box hexpand /> - <label label={bind(player, "length").as(lengthStr)} /> - </box> - </box> - <box vertical className="details"> - <label truncate className="title" label={bind(player, "title").as(noNull)} /> - <label truncate className="artist" label={bind(player, "artist").as(noNull)} /> - <label truncate className="album" label={bind(player, "album").as(noNull)} /> - </box> - <box vertical className="controls"> - <box halign={Gtk.Align.CENTER} className="playback"> - <button - sensitive={bind(player, "canGoPrevious")} - cursor="pointer" - onClicked={() => player.next()} - label="" - /> - <button - sensitive={bind(player, "canControl")} - cursor="pointer" - onClicked={() => player.play_pause()} - label={bind(player, "playbackStatus").as(s => - s === AstalMpris.PlaybackStatus.PLAYING ? "" : "" - )} - /> - <button - sensitive={bind(player, "canGoNext")} - cursor="pointer" - onClicked={() => player.next()} - label="" - /> - </box> - <box className="options"> - <button - sensitive={bind(player, "canSetFullscreen")} - cursor="pointer" - onClicked={() => player.toggle_fullscreen()} - label={bind(player, "fullscreen").as(f => (f ? "" : ""))} - /> - <button - sensitive={bind(player, "canControl")} - cursor="pointer" - onClicked={() => player.shuffle()} - label={bind(player, "shuffleStatus").as(s => (s === AstalMpris.Shuffle.ON ? "" : ""))} - /> - <box hexpand /> - <button - className="needs-adjustment" - sensitive={bind(player, "canControl")} - cursor="pointer" - onClicked={() => player.loop()} - label={bind(player, "loopStatus").as(l => - l === AstalMpris.Loop.TRACK ? "" : l === AstalMpris.Loop.PLAYLIST ? "" : "" - )} - /> - <button - className="needs-adjustment" - sensitive={bind(player, "canRaise")} - cursor="pointer" - onClicked={() => player.raise()} - label="" - /> - </box> - </box> - </box> - ); -}; - -const Indicator = ({ active, player }: { active: Variable<string>; player: AstalMpris.Player }) => ( - <button - className={bind(active).as(a => (a === player.busName ? "active" : ""))} - cursor="pointer" - onClicked={() => active.set(player.busName)} - /> -); - -export default () => { - const players = Players.get_default(); - const active = Variable(players.lastPlayer?.busName ?? "none"); - - active.observe(players, "notify::list", () => { - timeout(10, () => active.set(players.lastPlayer?.busName ?? "none")); - return "none"; - }); - - return ( - <box vertical className="players" onDestroy={() => active.drop()}> - <stack - transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT} - transitionDuration={150} - shown={bind(active)} - > - <NoMedia /> - {bind(players, "list").as(ps => ps.map(p => <Player player={p} />))} - </stack> - <revealer - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={120} - revealChild={bind(players, "list").as(l => l.length > 1)} - > - <box halign={Gtk.Align.CENTER} className="indicators"> - {bind(players, "list").as(ps => ps.map(p => <Indicator active={active} player={p} />))} - </box> - </revealer> - </box> - ); -}; diff --git a/src/modules/sidebar/modules/networks.tsx b/src/modules/sidebar/modules/networks.tsx deleted file mode 100644 index f98a62c..0000000 --- a/src/modules/sidebar/modules/networks.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { bind, execAsync, Variable, type Binding } from "astal"; -import { Gtk } from "astal/gtk3"; -import AstalNetwork from "gi://AstalNetwork"; - -const sortAPs = (saved: string[], a: AstalNetwork.AccessPoint, b: AstalNetwork.AccessPoint) => { - const { wifi } = AstalNetwork.get_default(); - if (a === wifi.activeAccessPoint || b === wifi.activeAccessPoint) return a === wifi.activeAccessPoint ? -1 : 1; - if (saved.includes(a.ssid) || saved.includes(b.ssid)) return saved.includes(a.ssid) ? -1 : 1; - return b.strength - a.strength; -}; - -const Network = (accessPoint: AstalNetwork.AccessPoint) => ( - <box - className={bind(AstalNetwork.get_default().wifi, "activeAccessPoint").as( - a => `network ${a === accessPoint ? "connected" : ""}` - )} - > - <icon className="icon" icon={bind(accessPoint, "iconName")} /> - <box vertical hexpand> - <label truncate xalign={0} label={bind(accessPoint, "ssid").as(s => s ?? "Unknown")} /> - <label - truncate - xalign={0} - className="sublabel" - label={bind(accessPoint, "strength").as(s => `${accessPoint.frequency > 5000 ? 5 : 2.4}GHz • ${s}/100`)} - /> - </box> - <box hexpand /> - <button - valign={Gtk.Align.CENTER} - visible={false} - cursor="pointer" - onClicked={() => execAsync(`nmcli c delete id '${accessPoint.ssid}'`).catch(console.error)} - label="delete_forever" - setup={self => { - let destroyed = false; - execAsync(`fish -c "nmcli -t -f name,type c show | sed -nE 's/(.*)\\:.*wireless/\\1/p'"`) - .then(out => !destroyed && (self.visible = out.split("\n").includes(accessPoint.ssid))) - .catch(console.error); - self.connect("destroy", () => (destroyed = true)); - }} - /> - <button - valign={Gtk.Align.CENTER} - cursor="pointer" - onClicked={self => { - let destroyed = false; - const id = self.connect("destroy", () => (destroyed = true)); - const cmd = - AstalNetwork.get_default().wifi.activeAccessPoint === accessPoint ? "c down id" : "d wifi connect"; - execAsync(`nmcli ${cmd} '${accessPoint.ssid}'`) - .then(() => { - if (!destroyed) { - self.sensitive = true; - self.disconnect(id); - } - }) - .catch(console.error); - self.sensitive = false; - }} - label={bind(AstalNetwork.get_default().wifi, "activeAccessPoint").as(a => - a === accessPoint ? "wifi_off" : "wifi" - )} - /> - </box> -); - -const List = () => { - const { wifi } = AstalNetwork.get_default(); - const children = Variable<JSX.Element[]>([]); - - const update = async () => { - const out = await execAsync(`fish -c "nmcli -t -f name,type c show | sed -nE 's/(.*)\\:.*wireless/\\1/p'"`); - const saved = out.split("\n"); - const aps = wifi.accessPoints - .filter(a => a.ssid) - .sort((a, b) => sortAPs(saved, a, b)) - .map(Network); - children.set(aps); - }; - - wifi.connect("notify::active-access-point", () => update().catch(console.error)); - wifi.connect("notify::access-points", () => update().catch(console.error)); - update().catch(console.error); - - return ( - <box vertical valign={Gtk.Align.START} className="list" onDestroy={() => children.drop()}> - {bind(children)} - </box> - ); -}; - -const NoNetworks = ({ label }: { label: Binding<string> | string }) => ( - <box homogeneous name="empty"> - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label="wifi_off" /> - <label label={label} /> - </box> - </box> -); - -export default () => { - const network = AstalNetwork.get_default(); - const label = Variable(""); - - const update = () => { - if (network.primary === AstalNetwork.Primary.WIFI) label.set(network.wifi.ssid ?? "Disconnected"); - else if (network.primary === AstalNetwork.Primary.WIRED) label.set(`Ethernet (${network.wired.speed})`); - else label.set("No Wifi"); - }; - network.connect("notify::primary", update); - network.get_wifi()?.connect("notify::ssid", update); - network.get_wired()?.connect("notify::speed", update); - update(); - - return ( - <box vertical className="networks"> - <box className="header-bar"> - <label label={bind(label)} /> - <box hexpand /> - <button - sensitive={network.get_wifi() ? bind(network.wifi, "scanning").as(e => !e) : false} - className={network.get_wifi() ? bind(network.wifi, "scanning").as(s => (s ? "enabled" : "")) : ""} - cursor="pointer" - onClicked={() => network.get_wifi()?.scan()} - label={ - network.get_wifi() - ? bind(network.wifi, "scanning").as(s => (s ? " Scanning" : " Scan")) - : " Scan" - } - /> - </box> - {network.get_wifi() ? ( - <stack - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={200} - shown={bind(network.wifi, "accessPoints").as(a => (a.length > 0 ? "list" : "empty"))} - > - <NoNetworks - label={bind(network.wifi, "enabled").as(p => (p ? "No available networks" : "Wifi is off"))} - /> - <scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list"> - <List /> - </scrollable> - </stack> - ) : ( - <NoNetworks label="Wifi not available" /> - )} - </box> - ); -}; diff --git a/src/modules/sidebar/modules/news.tsx b/src/modules/sidebar/modules/news.tsx deleted file mode 100644 index c799757..0000000 --- a/src/modules/sidebar/modules/news.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import Palette from "@/services/palette"; -import Updates from "@/services/updates"; -import { setupCustomTooltip } from "@/utils/widgets"; -import { bind, Variable } from "astal"; -import { Gtk } from "astal/gtk3"; - -const countNews = (news: string) => news.match(/^([0-9]{4}-[0-9]{2}-[0-9]{2} .+)$/gm)?.length ?? 0; - -const News = ({ header, body }: { header: string; body: string }) => { - const expanded = Variable(false); - - body = body - .slice(0, -5) // Remove last unopened \x1b[0m - .replaceAll("\x1b[0m", "</span>"); // Replace reset code with end span - - return ( - <box vertical className="article"> - <button - className="wrapper" - cursor="pointer" - onClicked={() => expanded.set(!expanded.get())} - setup={self => setupCustomTooltip(self, header)} - > - <box hexpand className="header"> - <label className="icon" label="newspaper" /> - <box vertical> - <label xalign={0} label={header.split(" ")[0]} /> - <label - truncate - xalign={0} - className="sublabel" - label={header.replace(/[0-9]{4}-[0-9]{2}-[0-9]{2} /, "")} - /> - </box> - <label className="icon" label={bind(expanded).as(e => (e ? "expand_less" : "expand_more"))} /> - </box> - </button> - <revealer - revealChild={bind(expanded)} - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={200} - > - <label - wrap - useMarkup - xalign={0} - className="body" - label={bind(Palette.get_default(), "teal").as( - c => body.replaceAll("\x1b[36m", `<span foreground="${c}">`) // Replace colour codes with html spans - )} - /> - </revealer> - </box> - ); -}; - -const List = () => ( - <box vertical valign={Gtk.Align.START} className="list"> - {bind(Updates.get_default(), "news").as(n => { - const children = []; - const news = n.split(/^([0-9]{4}-[0-9]{2}-[0-9]{2} .+)$/gm); - for (let i = 1; i < news.length - 1; i += 2) - children.push(<News header={news[i].trim()} body={news[i + 1].trim()} />); - return children; - })} - </box> -); - -const NoNews = () => ( - <box homogeneous name="empty"> - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label="breaking_news" /> - <label label="No Arch news!" /> - </box> - </box> -); - -export default ({ monitor }: { monitor: Monitor }) => ( - <box vertical className="news"> - <box className="header-bar"> - <label - label={bind(Updates.get_default(), "news") - .as(countNews) - .as(n => `${n} news article${n === 1 ? "" : "s"}`)} - /> - <box hexpand /> - <button - className={bind(Updates.get_default(), "loading").as(l => (l ? "enabled" : ""))} - sensitive={bind(Updates.get_default(), "loading").as(l => !l)} - cursor="pointer" - onClicked={() => Updates.get_default().getUpdates()} - label={bind(Updates.get_default(), "loading").as(l => (l ? " Loading" : " Reload"))} - /> - </box> - <stack - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={200} - shown={bind(Updates.get_default(), "news").as(n => (n ? "list" : "empty"))} - > - <NoNews /> - <scrollable - css={bind(Updates.get_default(), "news").as(n => - n ? `min-height: ${Math.round(monitor.height * 0.4)}px;` : "" - )} - hscroll={Gtk.PolicyType.NEVER} - name="list" - > - <List /> - </scrollable> - </stack> - </box> -); diff --git a/src/modules/sidebar/modules/notifications.tsx b/src/modules/sidebar/modules/notifications.tsx deleted file mode 100644 index e9347ec..0000000 --- a/src/modules/sidebar/modules/notifications.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import Notification from "@/widgets/notification"; -import { bind } from "astal"; -import { Astal, Gtk } from "astal/gtk3"; -import AstalNotifd from "gi://AstalNotifd"; - -const List = ({ compact }: { compact?: boolean }) => ( - <box - vertical - valign={Gtk.Align.START} - className="list" - setup={self => { - const notifd = AstalNotifd.get_default(); - const map = new Map<number, Notification>(); - - const addNotification = (notification: AstalNotifd.Notification) => { - const notif = (<Notification notification={notification} compact={compact} />) as Notification; - notif.connect("destroy", () => map.get(notification.id) === notif && map.delete(notification.id)); - map.get(notification.id)?.destroyWithAnims(); - map.set(notification.id, notif); - - const widget = ( - <eventbox - // Dismiss on middle click - onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()} - setup={self => self.hook(notif, "destroy", () => self.destroy())} - > - {notif} - </eventbox> - ); - - self.pack_end(widget, false, false, 0); - }; - - notifd - .get_notifications() - .sort((a, b) => a.time - b.time) - .forEach(addNotification); - - self.hook(notifd, "notified", (_, id) => addNotification(notifd.get_notification(id))); - self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims()); - }} - /> -); - -const NoNotifs = () => ( - <box homogeneous name="empty"> - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label="mark_email_unread" /> - <label label="All caught up!" /> - </box> - </box> -); - -export default ({ compact }: { compact?: boolean }) => ( - <box vertical className="notifications"> - <box className="header-bar"> - <label - label={bind(AstalNotifd.get_default(), "notifications").as( - n => `${n.length} notification${n.length === 1 ? "" : "s"}` - )} - /> - <box hexpand /> - <button - className={bind(AstalNotifd.get_default(), "dontDisturb").as(d => (d ? "enabled" : ""))} - cursor="pointer" - onClicked={() => (AstalNotifd.get_default().dontDisturb = !AstalNotifd.get_default().dontDisturb)} - label=" Silence" - /> - <button - cursor="pointer" - onClicked={() => - AstalNotifd.get_default() - .get_notifications() - .forEach(n => n.dismiss()) - } - label=" Clear" - /> - </box> - <stack - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={200} - shown={bind(AstalNotifd.get_default(), "notifications").as(n => (n.length > 0 ? "list" : "empty"))} - > - <NoNotifs /> - <scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list"> - <List compact={compact} /> - </scrollable> - </stack> - </box> -); diff --git a/src/modules/sidebar/modules/streams.tsx b/src/modules/sidebar/modules/streams.tsx deleted file mode 100644 index 18a9a58..0000000 --- a/src/modules/sidebar/modules/streams.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { bind, execAsync, Variable } from "astal"; -import { Gtk } from "astal/gtk3"; -import AstalWp from "gi://AstalWp"; - -interface IStream { - stream: AstalWp.Endpoint; - playing: boolean; -} - -const header = (audio: AstalWp.Audio, key: "streams" | "speakers" | "recorders") => - `${audio[key].length} ${audio[key].length === 1 ? key.slice(0, -1) : key}`; - -const sortStreams = (a: IStream, b: IStream) => { - if (a.playing || b.playing) return a.playing ? -1 : 1; - return 0; -}; - -const Stream = ({ stream, playing }: IStream) => ( - <box className={`stream ${playing ? "playing" : ""}`}> - <icon className="icon" icon={bind(stream, "icon")} /> - <box vertical hexpand> - <label truncate xalign={0} label={bind(stream, "name")} /> - <label truncate xalign={0} className="sublabel" label={bind(stream, "description")} /> - </box> - <button valign={Gtk.Align.CENTER} cursor="pointer" onClicked={() => (stream.volume -= 0.05)} label="-" /> - <slider - showFillLevel - restrictToFillLevel={false} - fillLevel={2 / 3} - value={bind(stream, "volume").as(v => v * (2 / 3))} - setup={self => self.connect("value-changed", () => stream.set_volume(self.value * 1.5))} - /> - <button valign={Gtk.Align.CENTER} cursor="pointer" onClicked={() => (stream.volume += 0.05)} label="+" /> - </box> -); - -const List = ({ audio }: { audio: AstalWp.Audio }) => { - const streams = Variable<IStream[]>([]); - - const update = async () => { - const paStreams = JSON.parse(await execAsync("pactl -f json list sink-inputs")); - streams.set( - audio.streams.map(s => ({ - stream: s, - playing: paStreams.find((p: any) => p.properties["object.serial"] == s.serial)?.corked === false, - })) - ); - }; - - streams.watch("pactl -f json subscribe", out => { - if (JSON.parse(out).on === "sink-input") update().catch(console.error); - return streams.get(); - }); - audio.connect("notify::streams", () => update().catch(console.error)); - - return ( - <box vertical valign={Gtk.Align.START} className="list" onDestroy={() => streams.drop()}> - {bind(streams).as(ps => ps.sort(sortStreams).map(s => <Stream stream={s.stream} playing={s.playing} />))} - </box> - ); -}; - -const NoSources = ({ icon, label }: { icon: string; label: string }) => ( - <box homogeneous name="empty"> - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label={icon} /> - <label label={label} /> - </box> - </box> -); - -const NoWp = () => ( - <box vexpand homogeneous> - <box vertical valign={Gtk.Align.CENTER}> - <NoSources icon="no_sound" label="Streams module unavailable" /> - <label className="no-wp-prompt" label="WirePlumber is required for this module" /> - </box> - </box> -); - -export default () => { - const audio = AstalWp.get_default()?.get_audio(); - - if (!audio) return <NoWp />; - - const label = Variable(`${header(audio, "streams")} • ${header(audio, "recorders")}`); - - label.observe( - ["streams", "recorders"].map(k => [audio, `notify::${k}`]), - () => `${header(audio, "streams")} • ${header(audio, "recorders")}` - ); - - return ( - <box vertical className="streams" onDestroy={() => label.drop()}> - <box halign={Gtk.Align.CENTER} className="header-bar"> - <label label={bind(label)} /> - </box> - <stack - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={200} - shown={bind(audio, "streams").as(s => (s.length > 0 ? "list" : "empty"))} - > - <NoSources icon="stream" label="No audio sources" /> - <scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list"> - <List audio={audio} /> - </scrollable> - </stack> - </box> - ); -}; diff --git a/src/modules/sidebar/modules/upcoming.tsx b/src/modules/sidebar/modules/upcoming.tsx deleted file mode 100644 index a64e051..0000000 --- a/src/modules/sidebar/modules/upcoming.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import Calendar, { type IEvent } from "@/services/calendar"; -import { setupCustomTooltip } from "@/utils/widgets"; -import { bind, GLib } from "astal"; -import { Gtk } from "astal/gtk3"; - -const getDateHeader = (events: IEvent[]) => { - const date = events[0].startDate; - const isToday = date.toJSDate().toDateString() === new Date().toDateString(); - return ( - (isToday ? "Today • " : "") + - GLib.DateTime.new_from_unix_local(date.toUnixTime()).format("%B %-d • %A") + - ` • ${events.length} event${events.length === 1 ? "" : "s"}` - ); -}; - -const getEventHeader = (e: IEvent) => { - const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime()); - const time = `${start.format("%-I")}${start.get_minute() > 0 ? `:${start.get_minute()}` : ""}${start.format("%P")}`; - return `${time} <b>${e.event.summary.replaceAll("&", "&")}</b>`; -}; - -const getEventTooltip = (e: IEvent) => { - const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime()); - const end = GLib.DateTime.new_from_unix_local(e.event.endDate.toUnixTime()); - const sameAmPm = start.format("%P") === end.format("%P"); - const time = `${start.format(`%A, %-d %B • %-I:%M${sameAmPm ? "" : "%P"}`)} — ${end.format("%-I:%M%P")}`; - const locIfExists = e.event.location ? ` ${e.event.location}\n` : ""; - const descIfExists = e.event.description ? ` ${e.event.description}\n` : ""; - return `<b>${e.event.summary}</b>\n${time}\n${locIfExists}${descIfExists} ${e.calendar}`.replaceAll("&", "&"); -}; - -const Event = (event: IEvent) => ( - <box className="event" setup={self => setupCustomTooltip(self, getEventTooltip(event), { useMarkup: true })}> - <box className={`calendar-indicator calendar-${Calendar.get_default().getCalendarIndex(event.calendar)}`} /> - <box vertical> - <label truncate useMarkup xalign={0} label={getEventHeader(event)} /> - {event.event.location && <label truncate xalign={0} label={event.event.location} className="sublabel" />} - {event.event.description && ( - <label truncate useMarkup xalign={0} label={event.event.description} className="sublabel" /> - )} - </box> - </box> -); - -const Day = ({ events }: { events: IEvent[] }) => ( - <box vertical className="day"> - <label className="date" xalign={0} label={getDateHeader(events)} /> - <box vertical className="events"> - {events.map(Event)} - </box> - </box> -); - -const List = () => ( - <box vertical valign={Gtk.Align.START}> - {bind(Calendar.get_default(), "upcoming").as(u => - Object.values(u) - .sort((a, b) => a[0].startDate.compare(b[0].startDate)) - .map(e => <Day events={e} />) - )} - </box> -); - -const NoEvents = () => ( - <box homogeneous name="empty"> - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label="calendar_month" /> - <label label="No upcoming events" /> - </box> - </box> -); - -export default () => ( - <box vertical className="upcoming"> - <box className="header-bar"> - <label - label={bind(Calendar.get_default(), "numUpcoming").as(n => `${n} upcoming event${n === 1 ? "" : "s"}`)} - /> - <box hexpand /> - <button - className={bind(Calendar.get_default(), "loading").as(l => (l ? "enabled" : ""))} - sensitive={bind(Calendar.get_default(), "loading").as(l => !l)} - cursor="pointer" - onClicked={() => Calendar.get_default().updateCalendars().catch(console.error)} - label={bind(Calendar.get_default(), "loading").as(l => (l ? " Loading" : " Reload"))} - /> - </box> - <stack - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={200} - shown={bind(Calendar.get_default(), "numUpcoming").as(n => (n > 0 ? "list" : "empty"))} - > - <NoEvents /> - <scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list"> - <List /> - </scrollable> - </stack> - </box> -); diff --git a/src/modules/sidebar/modules/updates.tsx b/src/modules/sidebar/modules/updates.tsx deleted file mode 100644 index e58d848..0000000 --- a/src/modules/sidebar/modules/updates.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import Palette from "@/services/palette"; -import Updates, { Repo as IRepo, Update as IUpdate } from "@/services/updates"; -import { MenuItem, setupCustomTooltip } from "@/utils/widgets"; -import { bind, execAsync, GLib, Variable } from "astal"; -import { Astal, Gtk } from "astal/gtk3"; - -const constructItem = (label: string, exec: string, quiet = true) => - new MenuItem({ label, onActivate: () => execAsync(exec).catch(e => !quiet && console.error(e)) }); - -const Update = (update: IUpdate) => { - const menu = new Gtk.Menu(); - menu.append(constructItem("Open info in browser", `app2unit -O '${update.url}'`, false)); - menu.append(constructItem("Open info in terminal", `app2unit -- foot -H -- pacman -Qi ${update.name}`)); - menu.append(new Gtk.SeparatorMenuItem({ visible: true })); - menu.append(constructItem("Reinstall", `app2unit -- foot -H -- yay -S ${update.name}`)); - menu.append(constructItem("Remove with dependencies", `app2unit -- foot -H -- yay -Rns ${update.name}`)); - - return ( - <button - onClick={(_, event) => event.button === Astal.MouseButton.SECONDARY && menu.popup_at_pointer(null)} - onDestroy={() => menu.destroy()} - > - <label - truncate - useMarkup - xalign={0} - label={bind(Palette.get_default(), "colours").as( - c => - `${update.name} <span foreground="${c.teal}">(${update.version.old} -> ${ - update.version.new - })</span>\n <span foreground="${c.subtext0}">${GLib.markup_escape_text( - update.description, - update.description.length - )}</span>` - )} - setup={self => setupCustomTooltip(self, `${update.name} • ${update.description}`)} - /> - </button> - ); -}; - -const Repo = ({ repo }: { repo: IRepo }) => { - const expanded = Variable(false); - - return ( - <box vertical className="repo"> - <button className="wrapper" cursor="pointer" onClicked={() => expanded.set(!expanded.get())}> - <box className="header"> - <label className="icon" label={repo.icon} /> - <label label={`${repo.name} (${repo.updates.length})`} /> - <box hexpand /> - <label className="icon" label={bind(expanded).as(e => (e ? "expand_less" : "expand_more"))} /> - </box> - </button> - <revealer - revealChild={bind(expanded)} - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={200} - > - <box vertical className="body"> - {repo.updates.map(Update)} - </box> - </revealer> - </box> - ); -}; - -const List = () => ( - <box vertical valign={Gtk.Align.START} className="list"> - {bind(Updates.get_default(), "updateData").as(d => d.repos.map(r => <Repo repo={r} />))} - </box> -); - -const NoUpdates = () => ( - <box homogeneous name="empty"> - <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty"> - <label className="icon" label="deployed_code_history" /> - <label label="All packages up to date!" /> - </box> - </box> -); - -export default () => ( - <box vertical className="updates"> - <box className="header-bar"> - <label - label={bind(Updates.get_default(), "numUpdates").as(n => `${n} update${n === 1 ? "" : "s"} available`)} - /> - <box hexpand /> - <button - className={bind(Updates.get_default(), "loading").as(l => (l ? "enabled" : ""))} - sensitive={bind(Updates.get_default(), "loading").as(l => !l)} - cursor="pointer" - onClicked={() => Updates.get_default().getUpdates()} - label={bind(Updates.get_default(), "loading").as(l => (l ? " Loading" : " Reload"))} - /> - </box> - <stack - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={200} - shown={bind(Updates.get_default(), "numUpdates").as(n => (n > 0 ? "list" : "empty"))} - > - <NoUpdates /> - <scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list"> - <List /> - </scrollable> - </stack> - </box> -); diff --git a/src/modules/sidebar/packages.tsx b/src/modules/sidebar/packages.tsx deleted file mode 100644 index 02b0702..0000000 --- a/src/modules/sidebar/packages.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { Monitor } from "@/services/monitors"; -import News from "./modules/news"; -import Updates from "./modules/updates"; - -export default ({ monitor }: { monitor: Monitor }) => ( - <box vertical className="pane packages" name="packages"> - <Updates /> - <box className="separator" /> - <News monitor={monitor} /> - </box> -); diff --git a/src/modules/sidebar/time.tsx b/src/modules/sidebar/time.tsx deleted file mode 100644 index 1f5ef99..0000000 --- a/src/modules/sidebar/time.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { bindCurrentTime } from "@/utils/system"; -import { Gtk } from "astal/gtk3"; -import Calendar from "./modules/calendar"; -import Upcoming from "./modules/upcoming"; - -const TimeDate = () => ( - <box vertical className="time-date"> - <box halign={Gtk.Align.CENTER}> - <label label={bindCurrentTime("%I:%M:%S")} /> - <label className="ampm" label={bindCurrentTime("%p", c => (c.get_hour() < 12 ? "AM" : "PM"))} /> - </box> - <label className="date" label={bindCurrentTime("%A, %d %B")} /> - </box> -); - -export default () => ( - <box vertical className="pane time" name="time"> - <TimeDate /> - <box className="separator" /> - <Upcoming /> - <box className="separator" /> - <Calendar /> - </box> -); diff --git a/src/services/apps.ts b/src/services/apps.ts deleted file mode 100644 index 5396ac7..0000000 --- a/src/services/apps.ts +++ /dev/null @@ -1,3 +0,0 @@ -import AstalApps from "gi://AstalApps"; - -export const Apps = new AstalApps.Apps(); diff --git a/src/services/calendar.ts b/src/services/calendar.ts deleted file mode 100644 index d5e0329..0000000 --- a/src/services/calendar.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { pathToFileName } from "@/utils/strings"; -import { notify } from "@/utils/system"; -import { - execAsync, - GLib, - GObject, - property, - readFileAsync, - register, - timeout, - writeFileAsync, - type AstalIO, -} from "astal"; -import { calendar as config } from "config"; -import ical from "ical.js"; - -export interface IEvent { - calendar: string; - event: ical.Event; - startDate: ical.Time; - endDate: ical.Time; -} - -@register({ GTypeName: "Calendar" }) -export default class Calendar extends GObject.Object { - static instance: Calendar; - static get_default() { - if (!this.instance) this.instance = new Calendar(); - - return this.instance; - } - - readonly #cacheDir = `${CACHE}/calendars`; - - #calCount: number = 1; - #reminders: AstalIO.Time[] = []; - #loading: boolean = false; - #calendars: { [name: string]: ical.Component } = {}; - #upcoming: { [date: string]: IEvent[] } = {}; - #cachedEvents: { [date: string]: IEvent[] } = {}; - #cachedMonths: Set<string> = new Set(); - - @property(Boolean) - get loading() { - return this.#loading; - } - - @property(Object) - get calendars() { - return this.#calendars; - } - - @property(Object) - get upcoming() { - return this.#upcoming; - } - - @property(Number) - get numUpcoming() { - return Object.values(this.#upcoming).reduce((acc, e) => acc + e.length, 0); - } - - getCalendarIndex(name: string) { - return Object.keys(this.#calendars).indexOf(name) + 1; - } - - getEventsForMonth(date: ical.Time) { - const start = date.startOfMonth(); - - if (this.#cachedMonths.has(start.toJSDate().toDateString())) return this.#cachedEvents; - - this.#cachedMonths.add(start.toJSDate().toDateString()); - const end = date.endOfMonth(); - - const modDates = new Set<string>(); - - for (const [name, cal] of Object.entries(this.#calendars)) { - for (const e of cal.getAllSubcomponents()) { - const event = new ical.Event(e); - - // Skip invalid events - if (!event.startDate) continue; - - if (event.isRecurring()) { - // Recurring events - const iter = event.iterator(); - for (let next = iter.next(); next && next.compare(end) <= 0; next = iter.next()) - if (next.compare(start) >= 0) { - const date = next.toJSDate().toDateString(); - if (!this.#cachedEvents.hasOwnProperty(date)) this.#cachedEvents[date] = []; - - const end = next.clone(); - end.addDuration(event.duration); - this.#cachedEvents[date].push({ calendar: name, event, startDate: next, endDate: end }); - modDates.add(date); - } - } else if (event.startDate.compare(start) >= 0 && event.startDate.compare(end) <= 0) { - const date = event.startDate.toJSDate().toDateString(); - if (!this.#cachedEvents.hasOwnProperty(date)) this.#cachedEvents[date] = []; - this.#cachedEvents[date].push({ - calendar: name, - event, - startDate: event.startDate, - endDate: event.endDate, - }); - modDates.add(date); - } - } - } - - for (const date of modDates) this.#cachedEvents[date].sort((a, b) => a.startDate.compare(b.startDate)); - - return this.#cachedEvents; - } - - getEventsForDay(date: ical.Time) { - return this.getEventsForMonth(date)[date.toJSDate().toDateString()] ?? []; - } - - async updateCalendars() { - this.#loading = true; - this.notify("loading"); - - this.#calendars = {}; - this.#calCount = 1; - - const cals = await Promise.allSettled(config.webcals.get().map(c => execAsync(["curl", c]))); - for (let i = 0; i < cals.length; i++) { - const cal = cals[i]; - const webcal = pathToFileName(config.webcals.get()[i]); - - let icalStr; - if (cal.status === "fulfilled") { - icalStr = cal.value; - } else { - console.error(`Failed to get calendar from ${config.webcals.get()[i]}:\n${cal.reason}`); - if (GLib.file_test(`${this.#cacheDir}/${webcal}`, GLib.FileTest.EXISTS)) - icalStr = await readFileAsync(`${this.#cacheDir}/${webcal}`); - } - - if (icalStr) { - const comp = new ical.Component(ical.parse(icalStr)); - const name = (comp.getFirstPropertyValue("x-wr-calname") ?? `Calendar ${this.#calCount++}`) as string; - this.#calendars[name] = comp; - writeFileAsync(`${this.#cacheDir}/${webcal}`, icalStr).catch(console.error); - } - } - this.#cachedEvents = {}; - this.#cachedMonths.clear(); - - this.notify("calendars"); - - this.updateUpcoming(); - - this.#loading = false; - this.notify("loading"); - } - - updateUpcoming() { - this.#upcoming = {}; - - for (let i = 0; i < config.upcomingDays.get(); i++) { - const date = ical.Time.now().adjust(i, 0, 0, 0); - const events = this.getEventsForDay(date); - if (events.length > 0) this.#upcoming[date.toJSDate().toDateString()] = events; - } - - this.notify("upcoming"); - this.notify("num-upcoming"); - - this.setReminders(); - } - - #notifyEvent(event: IEvent) { - const start = GLib.DateTime.new_from_unix_local(event.startDate.toUnixTime()); - const end = GLib.DateTime.new_from_unix_local(event.endDate.toUnixTime()); - const time = `${start.format(`%A, %-d %B`)} • Now — ${end.format("%-I:%M%P")}`; - const locIfExists = event.event.location ? ` ${event.event.location}\n` : ""; - const descIfExists = event.event.description ? ` ${event.event.description}\n` : ""; - - notify({ - summary: ` ${event.event.summary} `, - body: `${time}\n${locIfExists}${descIfExists} ${event.calendar}`, - }).catch(console.error); - } - - #createReminder(event: IEvent) { - const diff = event.startDate.toJSDate().getTime() - ical.Time.now().toJSDate().getTime(); - if (diff > 0) this.#reminders.push(timeout(diff, () => this.#notifyEvent(event))); - } - - setReminders() { - this.#reminders.forEach(r => r.cancel()); - this.#reminders = []; - - if (!config.notify.get()) return; - - for (const events of Object.values(this.#upcoming)) for (const event of events) this.#createReminder(event); - } - - constructor() { - super(); - - GLib.mkdir_with_parents(this.#cacheDir, 0o755); - - const cals = config.webcals.get().map(async c => { - const webcal = pathToFileName(c); - - if (GLib.file_test(`${this.#cacheDir}/${webcal}`, GLib.FileTest.EXISTS)) { - const data = await readFileAsync(`${this.#cacheDir}/${webcal}`); - const comp = new ical.Component(ical.parse(data)); - const name = (comp.getFirstPropertyValue("x-wr-calname") ?? `Calendar ${this.#calCount++}`) as string; - this.#calendars[name] = comp; - } - }); - Promise.allSettled(cals).then(() => { - this.#cachedEvents = {}; - this.#cachedMonths.clear(); - this.notify("calendars"); - this.updateUpcoming(); - }); - - this.updateCalendars().catch(console.error); - config.webcals.subscribe(() => this.updateCalendars().catch(console.error)); - config.upcomingDays.subscribe(() => this.updateUpcoming()); - config.notify.subscribe(() => this.setReminders()); - } -} diff --git a/src/services/cpu.ts b/src/services/cpu.ts deleted file mode 100644 index 5f80d11..0000000 --- a/src/services/cpu.ts +++ /dev/null @@ -1,49 +0,0 @@ -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(); - - let source = interval(config.interval.get(), () => this.update()); - config.interval.subscribe(i => { - source.cancel(); - source = interval(i, () => this.update()); - }); - } -} diff --git a/src/services/gpu.ts b/src/services/gpu.ts deleted file mode 100644 index 5ac2d8d..0000000 --- a/src/services/gpu.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { execAsync, Gio, GLib, 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"); - }) - .catch(console.error); - } - - 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 (GLib.file_test(`/sys/class/drm/${info.get_name()}/device/gpu_busy_percent`, GLib.FileTest.EXISTS)) { - this.available = true; - break; - } - } - - if (this.available) { - let source = interval(config.interval.get(), () => this.update()); - config.interval.subscribe(i => { - source.cancel(); - source = interval(i, () => this.update()); - }); - } - } -} diff --git a/src/services/math.ts b/src/services/math.ts deleted file mode 100644 index 0cddf1b..0000000 --- a/src/services/math.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal"; -import { math as config } from "config"; -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 #path = `${STATE}/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); - while (this.#history.length > config.maxHistory.get()) 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, this.#variables).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); - } - } - - config.maxHistory.subscribe(n => { - while (this.#history.length > n) this.#history.pop(); - }); - } -} diff --git a/src/services/memory.ts b/src/services/memory.ts deleted file mode 100644 index b1231b9..0000000 --- a/src/services/memory.ts +++ /dev/null @@ -1,64 +0,0 @@ -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(); - - let source = interval(config.interval.get(), () => this.update().catch(console.error)); - config.interval.subscribe(i => { - source.cancel(); - source = interval(i, () => this.update().catch(console.error)); - }); - } -} diff --git a/src/services/monitors.ts b/src/services/monitors.ts deleted file mode 100644 index 6ae7ecb..0000000 --- a/src/services/monitors.ts +++ /dev/null @@ -1,127 +0,0 @@ -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").map(l => l.trimStart()); - if (lines.find(l => l.startsWith("Monitor:"))?.split(":")[3] !== monitor.serial) return false; - this.busNum = lines.find(l => l.startsWith("I2C bus:"))?.split("/dev/i2c-")[1]; - return this.busNum !== undefined; - }); - }) - .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/news.ts b/src/services/news.ts deleted file mode 100644 index 14c980c..0000000 --- a/src/services/news.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { notify } from "@/utils/system"; -import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal"; -import { news as config } from "config"; -import { setConfig } from "config/funcs"; - -export interface IArticle { - title: string; - link: string; - keywords: string[] | null; - creator: string[] | null; - description: string | null; - pubDate: string; - source_name: string; - source_priority: number; - category: string[]; -} - -@register({ GTypeName: "News" }) -export default class News extends GObject.Object { - static instance: News; - static get_default() { - if (!this.instance) this.instance = new News(); - - return this.instance; - } - - readonly #cachePath = `${CACHE}/news.json`; - #notified = false; - - #loading: boolean = false; - #articles: IArticle[] = []; - #categories: { [category: string]: IArticle[] } = {}; - - @property(Boolean) - get loading() { - return this.#loading; - } - - @property(Object) - get articles() { - return this.#articles; - } - - @property(Object) - get categories() { - return this.#categories; - } - - async getNews() { - if (!config.apiKey.get()) { - if (!this.#notified) { - notify({ - summary: "A newsdata.io API key is required", - body: "You can get one by creating an account at https://newsdata.io", - icon: "dialog-warning-symbolic", - urgency: "critical", - actions: { - "Get API key": () => execAsync("app2unit -O -- https://newsdata.io").catch(console.error), - Disable: () => setConfig("sidebar.modules.headlines.enabled", false), - }, - }); - this.#notified = true; - } - return; - } - - this.#loading = true; - this.notify("loading"); - - let countries = config.countries.get().join(","); - const categories = config.categories.get().join(","); - const languages = config.languages.get().join(","); - const domains = config.domains.get().join(","); - const excludeDomains = config.excludeDomains.get().join(","); - const timezone = config.timezone.get(); - - if (countries.includes("current")) { - const out = JSON.parse(await execAsync("curl ipinfo.io")).country.toLowerCase(); - countries = countries.replace("current", out); - } - - let args = "removeduplicate=1&prioritydomain=top"; - if (countries) args += `&country=${countries}`; - if (categories) args += `&category=${categories}`; - if (languages) args += `&language=${languages}`; - if (domains) args += `&domain=${domains}`; - if (excludeDomains) args += `&excludedomain=${excludeDomains}`; - if (timezone) args += `&timezone=${timezone}`; - - const url = `https://newsdata.io/api/1/latest?apikey=${config.apiKey.get()}&${args}`; - try { - const res = JSON.parse(await execAsync(["curl", url])); - if (res.status !== "success") throw new Error(`Failed to get news: ${res.results.message}`); - - this.#articles = [...res.results]; - - let page = res.nextPage; - for (let i = 1; i < config.pages.get(); i++) { - const res = JSON.parse(await execAsync(["curl", `${url}&page=${page}`])); - if (res.status !== "success") throw new Error(`Failed to get news: ${res.results.message}`); - this.#articles.push(...res.results); - page = res.nextPage; - } - - writeFileAsync(this.#cachePath, JSON.stringify(this.#articles)).catch(console.error); - } catch (e) { - console.error(e); - - if (GLib.file_test(this.#cachePath, GLib.FileTest.EXISTS)) - this.#articles = JSON.parse(await readFileAsync(this.#cachePath)); - } - this.notify("articles"); - - this.updateCategories(); - - this.#loading = false; - this.notify("loading"); - } - - updateCategories() { - this.#categories = {}; - for (const article of this.#articles) { - for (const category of article.category) { - if (!this.#categories.hasOwnProperty(category)) this.#categories[category] = []; - this.#categories[category].push(article); - } - } - this.notify("categories"); - } - - constructor() { - super(); - - if (GLib.file_test(this.#cachePath, GLib.FileTest.EXISTS)) - readFileAsync(this.#cachePath) - .then(data => { - this.#articles = JSON.parse(data); - this.notify("articles"); - this.updateCategories(); - }) - .catch(console.error); - - this.getNews().catch(console.error); - config.apiKey.subscribe(() => this.getNews().catch(console.error)); - config.countries.subscribe(() => this.getNews().catch(console.error)); - config.categories.subscribe(() => this.getNews().catch(console.error)); - config.languages.subscribe(() => this.getNews().catch(console.error)); - config.domains.subscribe(() => this.getNews().catch(console.error)); - config.excludeDomains.subscribe(() => this.getNews().catch(console.error)); - config.timezone.subscribe(() => this.getNews().catch(console.error)); - config.pages.subscribe(() => this.getNews().catch(console.error)); - } -} diff --git a/src/services/palette.ts b/src/services/palette.ts deleted file mode 100644 index 952543f..0000000 --- a/src/services/palette.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { execAsync, GLib, GObject, monitorFile, property, readFile, readFileAsync, register } from "astal"; -import Schemes from "./schemes"; - -export type ColourMode = "light" | "dark"; - -export type Hex = `#${string}`; - -export interface IPalette { - rosewater: Hex; - flamingo: Hex; - pink: Hex; - mauve: Hex; - red: Hex; - maroon: Hex; - peach: Hex; - yellow: Hex; - green: Hex; - teal: Hex; - sky: Hex; - sapphire: Hex; - blue: Hex; - lavender: Hex; - text: Hex; - subtext1: Hex; - subtext0: Hex; - overlay2: Hex; - overlay1: Hex; - overlay0: Hex; - surface2: Hex; - surface1: Hex; - surface0: Hex; - base: Hex; - mantle: Hex; - crust: Hex; - primary: Hex; - secondary: Hex; - tertiary: Hex; -} - -@register({ GTypeName: "Palette" }) -export default class Palette extends GObject.Object { - static instance: Palette; - static get_default() { - if (!this.instance) this.instance = new Palette(); - - return this.instance; - } - - #mode: ColourMode; - #scheme: string; - #flavour?: string; - #colours!: IPalette; - - @property(Boolean) - get mode() { - return this.#mode; - } - - @property(String) - get scheme() { - return this.#scheme; - } - - @property(String) - get flavour() { - return this.#flavour; - } - - @property(Object) - get colours() { - return this.#colours; - } - - @property(String) - get rosewater() { - return this.#colours.rosewater; - } - - @property(String) - get flamingo() { - return this.#colours.flamingo; - } - - @property(String) - get pink() { - return this.#colours.pink; - } - - @property(String) - get mauve() { - return this.#colours.mauve; - } - - @property(String) - get red() { - return this.#colours.red; - } - - @property(String) - get maroon() { - return this.#colours.maroon; - } - - @property(String) - get peach() { - return this.#colours.peach; - } - - @property(String) - get yellow() { - return this.#colours.yellow; - } - - @property(String) - get green() { - return this.#colours.green; - } - - @property(String) - get teal() { - return this.#colours.teal; - } - - @property(String) - get sky() { - return this.#colours.sky; - } - - @property(String) - get sapphire() { - return this.#colours.sapphire; - } - - @property(String) - get blue() { - return this.#colours.blue; - } - - @property(String) - get lavender() { - return this.#colours.lavender; - } - - @property(String) - get text() { - return this.#colours.text; - } - - @property(String) - get subtext1() { - return this.#colours.subtext1; - } - - @property(String) - get subtext0() { - return this.#colours.subtext0; - } - - @property(String) - get overlay2() { - return this.#colours.overlay2; - } - - @property(String) - get overlay1() { - return this.#colours.overlay1; - } - - @property(String) - get overlay0() { - return this.#colours.overlay0; - } - - @property(String) - get surface2() { - return this.#colours.surface2; - } - - @property(String) - get surface1() { - return this.#colours.surface1; - } - - @property(String) - get surface0() { - return this.#colours.surface0; - } - - @property(String) - get base() { - return this.#colours.base; - } - - @property(String) - get mantle() { - return this.#colours.mantle; - } - - @property(String) - get crust() { - return this.#colours.crust; - } - - @property(String) - get primary() { - return this.#colours.primary; - } - - @property(String) - get secondary() { - return this.#colours.secondary; - } - - @property(String) - get tertiary() { - return this.#colours.tertiary; - } - - #notify() { - this.notify("colours"); - this.notify("rosewater"); - this.notify("flamingo"); - this.notify("pink"); - this.notify("mauve"); - this.notify("red"); - this.notify("maroon"); - this.notify("peach"); - this.notify("yellow"); - this.notify("green"); - this.notify("teal"); - this.notify("sky"); - this.notify("sapphire"); - this.notify("blue"); - this.notify("lavender"); - this.notify("text"); - this.notify("subtext1"); - this.notify("subtext0"); - this.notify("overlay2"); - this.notify("overlay1"); - this.notify("overlay0"); - this.notify("surface2"); - this.notify("surface1"); - this.notify("surface0"); - this.notify("base"); - this.notify("mantle"); - this.notify("crust"); - this.notify("primary"); - this.notify("secondary"); - this.notify("tertiary"); - } - - update() { - let schemeColours; - if (GLib.file_test(`${STATE}/scheme/current.txt`, GLib.FileTest.EXISTS)) { - const currentScheme = readFile(`${STATE}/scheme/current.txt`); - schemeColours = currentScheme.split("\n").map(l => l.split(" ")); - } else - schemeColours = readFile(`${SRC}/scss/scheme/_default.scss`) - .split("\n") - .map(l => { - const [name, hex] = l.split(":"); - return [name.slice(1), hex.trim().slice(1, -1)]; - }); - - this.#colours = schemeColours.reduce((acc, [name, hex]) => ({ ...acc, [name]: `#${hex}` }), {} as IPalette); - this.#notify(); - } - - switchMode(mode: ColourMode) { - execAsync(`caelestia scheme ${this.scheme} ${this.flavour ?? ""} ${mode}`).catch(console.error); - } - - hasMode(mode: ColourMode) { - const scheme = Schemes.get_default().map[this.scheme]; - if (scheme?.colours?.[mode]) return true; - return scheme?.flavours?.[this.flavour ?? ""]?.colours?.[mode] !== undefined; - } - - constructor() { - super(); - - this.#mode = readFile(`${STATE}/scheme/current-mode.txt`) === "light" ? "light" : "dark"; - monitorFile(`${STATE}/scheme/current-mode.txt`, async file => { - this.#mode = (await readFileAsync(file)) === "light" ? "light" : "dark"; - this.notify("mode"); - }); - - [this.#scheme, this.#flavour] = readFile(`${STATE}/scheme/current-name.txt`).split("-"); - monitorFile(`${STATE}/scheme/current-name.txt`, async file => { - [this.#scheme, this.#flavour] = (await readFileAsync(file)).split("-"); - this.notify("scheme"); - this.notify("flavour"); - }); - - this.update(); - monitorFile(`${STATE}/scheme/current.txt`, () => this.update()); - } -} diff --git a/src/services/players.ts b/src/services/players.ts deleted file mode 100644 index aca7344..0000000 --- a/src/services/players.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { isRealPlayer } from "@/utils/mpris"; -import { GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal"; -import AstalMpris from "gi://AstalMpris"; - -@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 = `${STATE}/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.findIndex(p => p.busName === player.busName); - // Ignore if already current - if (index === 0 || !isRealPlayer(player)) 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(); - this.notify("list"); - - // 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)) { - readFileAsync(this.#path).then(out => { - for (const busName of out.split("\n").reverse()) { - const player = mpris.get_players().find(p => p.busName === busName); - if (player) this.makeCurrent(player); - } - // Add new players from in between sessions - for (const player of mpris.get_players()) this.makeCurrent(player); - }); - } else { - const sortOrder = [ - AstalMpris.PlaybackStatus.PLAYING, - AstalMpris.PlaybackStatus.PAUSED, - AstalMpris.PlaybackStatus.STOPPED, - ]; - const players = mpris - .get_players() - .sort((a, b) => sortOrder.indexOf(b.playbackStatus) - sortOrder.indexOf(a.playbackStatus)); - for (const player of players) this.makeCurrent(player); - } - - // Add and connect signals when added - mpris.connect("player-added", (_, player) => this.makeCurrent(player)); - - // 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/schemes.ts b/src/services/schemes.ts deleted file mode 100644 index c85fa72..0000000 --- a/src/services/schemes.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { basename } from "@/utils/strings"; -import { monitorDirectory } from "@/utils/system"; -import { execAsync, Gio, GLib, GObject, property, readFileAsync, register } from "astal"; -import type { IPalette } from "./palette"; - -export interface Colours { - light?: IPalette; - dark?: IPalette; -} - -export interface Flavour { - name: string; - scheme: string; - colours: Colours; -} - -export interface Scheme { - name: string; - flavours?: { [k: string]: Flavour }; - colours?: Colours; -} - -const DATA = `${GLib.get_user_data_dir()}/caelestia`; - -@register({ GTypeName: "Schemes" }) -export default class Schemes extends GObject.Object { - static instance: Schemes; - static get_default() { - if (!this.instance) this.instance = new Schemes(); - - return this.instance; - } - - readonly #schemeDir: string = `${DATA}/scripts/data/schemes`; - - #map: { [k: string]: Scheme } = {}; - - @property(Object) - get map() { - return this.#map; - } - - async parseMode(path: string): Promise<IPalette | undefined> { - const schemeColours = (await readFileAsync(path).catch(() => undefined))?.split("\n").map(l => l.split(" ")); - return schemeColours?.reduce((acc, [name, hex]) => ({ ...acc, [name]: `#${hex}` }), {} as IPalette); - } - - async parseColours(path: string): Promise<Colours> { - const light = await this.parseMode(`${path}/light.txt`); - const dark = await this.parseMode(`${path}/dark.txt`); - return { light, dark }; - } - - async parseFlavour(scheme: string, name: string): Promise<Flavour> { - const path = `${this.#schemeDir}/${scheme}/${name}`; - return { name, scheme, colours: await this.parseColours(path) }; - } - - async parseScheme(name: string): Promise<Scheme> { - const path = `${this.#schemeDir}/${name}`; - - const flavours = await execAsync(`find ${path}/ -mindepth 1 -maxdepth 1 -type d`); - if (flavours.trim()) - return { - name, - flavours: ( - await Promise.all(flavours.split("\n").map(f => this.parseFlavour(name, basename(f)))) - ).reduce((acc, f) => ({ ...acc, [f.name]: f }), {} as { [k: string]: Flavour }), - }; - - return { name, colours: await this.parseColours(path) }; - } - - async update() { - const schemes = await execAsync(`find ${this.#schemeDir}/ -mindepth 1 -maxdepth 1 -type d`); - (await Promise.all(schemes.split("\n").map(s => this.parseScheme(basename(s))))).forEach( - s => (this.#map[s.name] = s) - ); - this.notify("map"); - } - - async updateFile(file: Gio.File) { - if (file.get_basename() !== "light.txt" && file.get_basename() !== "dark.txt") { - await this.update(); - return; - } - - const mode = file.get_basename()!.slice(0, -4) as "light" | "dark"; - const parent = file.get_parent()!; - const parentParent = parent.get_parent()!; - - if (parentParent.get_basename() === "schemes") - this.#map[parent.get_basename()!].colours![mode] = await this.parseMode(file.get_path()!); - else - this.#map[parentParent.get_basename()!].flavours![parent.get_basename()!].colours![mode] = - await this.parseMode(file.get_path()!); - - this.notify("map"); - } - - constructor() { - super(); - - this.update().catch(console.error); - monitorDirectory(this.#schemeDir, (_m, file, _f, type) => { - if (type !== Gio.FileMonitorEvent.DELETED) this.updateFile(file).catch(console.error); - }); - } -} diff --git a/src/services/storage.ts b/src/services/storage.ts deleted file mode 100644 index 3f8992d..0000000 --- a/src/services/storage.ts +++ /dev/null @@ -1,65 +0,0 @@ -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(); - - let source = interval(config.interval.get(), () => this.update()); - config.interval.subscribe(i => { - source.cancel(); - source = interval(i, () => this.update()); - }); - } -} diff --git a/src/services/updates.ts b/src/services/updates.ts deleted file mode 100644 index 62a8f65..0000000 --- a/src/services/updates.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { capitalize } from "@/utils/strings"; -import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal"; -import { updates as config } from "config"; - -export interface Update { - full: string; - name: string; - description: string; - url: string; - version: { - old: string; - new: string; - }; -} - -export interface Repo { - repo?: string[]; - updates: Update[]; - icon: string; - name: string; -} - -export interface Data { - cached?: boolean; - repos: Repo[]; - errors: string[]; - news: 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.json`; - - #timeout?: GLib.Source; - #loading = false; - #data: Data = { cached: true, repos: [], errors: [], news: "" }; - - @property(Boolean) - get loading() { - return this.#loading; - } - - @property(Object) - get updateData() { - 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); - } - - @property(String) - get news() { - return this.#data.news; - } - - async #updateFromCache() { - this.#data = JSON.parse(await readFileAsync(this.#cachePath)); - this.notify("update-data"); - this.notify("list"); - this.notify("num-updates"); - this.notify("news"); - } - - async getRepo(repo: string) { - return (await execAsync(`bash -c "comm -12 <(pacman -Qq | sort) <(pacman -Slq '${repo}' | sort)"`)).split("\n"); - } - - async constructUpdate(update: string) { - const info = await execAsync(`pacman -Qi ${update.split(" ")[0]}`); - return info.split("\n").reduce( - (acc, line) => { - let [key, value] = line.split(" : "); - key = key.trim().toLowerCase(); - if (key === "name" || key === "description" || key === "url") acc[key] = value.trim(); - else if (key === "version") acc.version.old = value.trim(); - return acc; - }, - { version: { new: update.split("->")[1].trim() } } as Update - ); - } - - getRepoIcon(repo: string) { - switch (repo) { - case "core": - return "hub"; - case "extra": - return "add_circle"; - case "multilib": - return "account_tree"; - default: - return "deployed_code_update"; - } - } - - 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"), execAsync("yay -Pw")]) - .then(async ([pacman, yay, news]) => { - const data: Data = { repos: [], errors: [], news: news.status === "fulfilled" ? news.value : "" }; - - // Pacman updates (checkupdates) - if (pacman.status === "fulfilled") { - const repos: Repo[] = await Promise.all( - (await execAsync("pacman-conf -l")).split("\n").map(async r => ({ - repo: await this.getRepo(r), - updates: [], - icon: this.getRepoIcon(r), - name: capitalize(r) + " 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(await this.constructUpdate(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(await this.constructUpdate(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 { - // Sort updates by name - for (const repo of data.repos) repo.updates.sort((a, b) => a.name.localeCompare(b.name)); - - // Cache and set - writeFileAsync(this.#cachePath, JSON.stringify({ cached: true, ...data })).catch(console.error); - this.#data = data; - this.notify("update-data"); - this.notify("list"); - this.notify("num-updates"); - this.notify("news"); - } - - this.#loading = false; - this.notify("loading"); - - this.#timeout?.destroy(); - this.#timeout = setTimeout(() => this.getUpdates(), config.interval.get()); - }) - .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(); - - config.interval.subscribe(i => { - this.#timeout?.destroy(); - this.#timeout = setTimeout(() => this.getUpdates(), i); - }); - } -} diff --git a/src/services/wallpapers.ts b/src/services/wallpapers.ts deleted file mode 100644 index b5447c2..0000000 --- a/src/services/wallpapers.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { monitorDirectory } from "@/utils/system"; -import Thumbnailer from "@/utils/thumbnailer"; -import { execAsync, GObject, property, register } from "astal"; -import { wallpapers as config } from "config"; -import Monitors from "./monitors"; - -export interface IWallpaper { - path: string; - thumbnails: { - compact: string; - medium: string; - large: string; - }; -} - -export interface ICategory { - path: string; - wallpapers: IWallpaper[]; -} - -@register({ GTypeName: "Wallpapers" }) -export default class Wallpapers extends GObject.Object { - static instance: Wallpapers; - static get_default() { - if (!this.instance) this.instance = new Wallpapers(); - - return this.instance; - } - - #list: IWallpaper[] = []; - #categories: ICategory[] = []; - - @property(Object) - get list() { - return this.#list; - } - - @property(Object) - get categories() { - return this.#categories; - } - - async #listDir(path: { path: string; recursive: boolean; threshold: number }, type: "f" | "d") { - const absPath = path.path.replace("~", HOME); - const maxDepth = path.recursive ? "" : "-maxdepth 1"; - const files = await execAsync(`find ${absPath} ${maxDepth} -path '*/.*' -prune -o -type ${type} -print`); - - if (type === "f" && path.threshold > 0) { - const data = ( - await execAsync([ - "fish", - "-c", - `identify -ping -format '%i %w %h\n' ${files.replaceAll("\n", " ")} ; true`, - ]) - ).split("\n"); - - return data - .filter(l => l && this.#filterSize(l, path.threshold)) - .map(l => l.split(" ").slice(0, -2).join(" ")) - .join("\n"); - } - - return files; - } - - #filterSize(line: string, threshold: number) { - const [width, height] = line.split(" ").slice(-2).map(Number); - const mWidth = Math.max(...Monitors.get_default().list.map(m => m.width)); - const mHeight = Math.max(...Monitors.get_default().list.map(m => m.height)); - - return width >= mWidth * threshold && height >= mHeight * threshold; - } - - async update() { - const results = await Promise.allSettled( - config.paths.get().map(async p => ({ path: p, files: await this.#listDir(p, "f") })) - ); - const successes = results.filter(r => r.status === "fulfilled").map(r => r.value); - - if (!successes.length) { - this.#list = []; - this.notify("list"); - this.#categories = []; - this.notify("categories"); - return; - } - - const files = successes.map(r => r.files.replaceAll("\n", " ")).join(" "); - const list = (await execAsync(["fish", "-c", `identify -ping -format '%i\n' ${files} ; true`])).split("\n"); - - this.#list = await Promise.all( - list.map(async p => ({ - path: p, - thumbnails: { - compact: await Thumbnailer.thumbnail(p, { width: 60, height: 60, exact: true }), - medium: await Thumbnailer.thumbnail(p, { width: 400, height: 150, exact: true }), - large: await Thumbnailer.thumbnail(p, { width: 400, height: 200, exact: true }), - }, - })) - ); - this.#list.sort((a, b) => a.path.localeCompare(b.path)); - this.notify("list"); - - const categories = await Promise.all(successes.map(r => this.#listDir(r.path, "d"))); - this.#categories = categories - .flatMap(c => c.split("\n")) - .map(c => ({ path: c, wallpapers: this.#list.filter(w => w.path.startsWith(c)) })) - .filter(c => c.wallpapers.length > 0) - .sort((a, b) => a.path.localeCompare(b.path)); - this.notify("categories"); - } - - constructor() { - super(); - - this.update().catch(console.error); - - let monitors = config.paths - .get() - .map(p => monitorDirectory(p.path, () => this.update().catch(console.error), p.recursive)); - config.paths.subscribe(v => { - this.update().catch(console.error); - for (const m of monitors) m.cancel(); - monitors = v.map(p => monitorDirectory(p.path, () => this.update().catch(console.error), p.recursive)); - }); - } -} diff --git a/src/services/weather.ts b/src/services/weather.ts deleted file mode 100644 index d51e7fc..0000000 --- a/src/services/weather.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { weatherIcons } from "@/utils/icons"; -import { notify } from "@/utils/system"; -import { - execAsync, - GLib, - GObject, - interval, - property, - readFileAsync, - register, - writeFileAsync, - type Time, -} from "astal"; -import { weather as config } from "config"; - -export interface WeatherCondition { - text: string; - icon: string; - code: number; -} - -interface _WeatherState { - temp_c: number; - temp_f: number; - is_day: number; - condition: WeatherCondition; - wind_mph: number; - wind_kph: number; - wind_degree: number; - wind_dir: "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW"; - pressure_mb: number; - pressure_in: number; - precip_mm: number; - precip_in: number; - humidity: number; - cloud: number; - feelslike_c: number; - feelslike_f: number; - windchill_c: number; - windchill_f: number; - heatindex_c: number; - heatindex_f: number; - dewpoint_c: number; - dewpoint_f: number; - vis_km: number; - vis_miles: number; - uv: number; - gust_mph: number; - gust_kph: number; -} - -export interface WeatherCurrent extends _WeatherState { - last_updated_epoch: number; - last_updated: string; -} - -export interface WeatherHour extends _WeatherState { - time_epoch: number; - time: string; -} - -export interface WeatherDay { - date: string; - date_epoch: number; - day: { - maxtemp_c: number; - maxtemp_f: number; - mintemp_c: number; - mintemp_f: number; - avgtemp_c: number; - avgtemp_f: number; - maxwind_mph: number; - maxwind_kph: number; - totalprecip_mm: number; - totalprecip_in: number; - totalsnow_cm: number; - avgvis_km: number; - avgvis_miles: number; - avghumidity: number; - daily_will_it_rain: number; - daily_chance_of_rain: number; - daily_will_it_snow: number; - daily_chance_of_snow: number; - condition: WeatherCondition; - uv: number; - }; - astro: { - sunrise: string; - sunset: string; - moonrise: string; - moonset: string; - moon_phase: string; - moon_illumination: string; - is_moon_up: number; - is_sun_up: number; - }; - hour: WeatherHour[]; -} - -export interface WeatherLocation { - name: string; - region: string; - country: string; - lat: number; - lon: number; - tz_id: string; - localtime_epoch: number; - localtime: string; -} - -export interface WeatherData { - current: WeatherCurrent; - forecast: { forecastday: WeatherDay[] }; - location: WeatherLocation; -} - -const DEFAULT_STATE: _WeatherState = { - temp_c: 0, - temp_f: 0, - is_day: 0, - condition: { text: "", icon: "", code: 0 }, - wind_mph: 0, - wind_kph: 0, - wind_degree: 0, - wind_dir: "N", - pressure_mb: 0, - pressure_in: 0, - precip_mm: 0, - precip_in: 0, - humidity: 0, - cloud: 0, - feelslike_c: 0, - feelslike_f: 0, - windchill_c: 0, - windchill_f: 0, - heatindex_c: 0, - heatindex_f: 0, - dewpoint_c: 0, - dewpoint_f: 0, - vis_km: 0, - vis_miles: 0, - uv: 0, - gust_mph: 0, - gust_kph: 0, -}; - -const DEFAULT: WeatherData = { - current: { - last_updated_epoch: 0, - last_updated: "", - ...DEFAULT_STATE, - }, - forecast: { - forecastday: [ - { - date: "", - date_epoch: 0, - day: { - maxtemp_c: 0, - maxtemp_f: 0, - mintemp_c: 0, - mintemp_f: 0, - avgtemp_c: 0, - avgtemp_f: 0, - maxwind_mph: 0, - maxwind_kph: 0, - totalprecip_mm: 0, - totalprecip_in: 0, - totalsnow_cm: 0, - avgvis_km: 0, - avgvis_miles: 0, - avghumidity: 0, - daily_will_it_rain: 0, - daily_chance_of_rain: 0, - daily_will_it_snow: 0, - daily_chance_of_snow: 0, - condition: { text: "", icon: "", code: 0 }, - uv: 0, - }, - astro: { - sunrise: "", - sunset: "", - moonrise: "", - moonset: "", - moon_phase: "", - moon_illumination: "", - is_moon_up: 0, - is_sun_up: 0, - }, - hour: Array.from({ length: 24 }, () => ({ - time_epoch: 0, - time: "", - ...DEFAULT_STATE, - })), - }, - ], - }, - location: { - name: "", - region: "", - country: "", - lat: 0, - lon: 0, - tz_id: "", - localtime_epoch: 0, - localtime: "", - }, -}; - -@register({ GTypeName: "Weather" }) -export default class Weather extends GObject.Object { - static instance: Weather; - static get_default() { - if (!this.instance) this.instance = new Weather(); - - return this.instance; - } - - readonly #cache: string = `${CACHE}/weather.json`; - #notified = false; - - #data: WeatherData = DEFAULT; - - #interval: Time | null = null; - - @property(Object) - get raw() { - return this.#data; - } - - @property(Object) - get current() { - return this.#data.current; - } - - @property(Object) - get forecast() { - return this.#data.forecast.forecastday[0].hour; - } - - @property(Object) - get location() { - return this.#data.location; - } - - @property(String) - get condition() { - return this.#data.current.condition.text; - } - - @property(String) - get temperature() { - return this.getTemp(this.#data.current); - } - - @property(String) - get wind() { - return `${Math.round(this.#data.current[`wind_${config.imperial.get() ? "m" : "k"}ph`])} ${ - config.imperial.get() ? "m" : "k" - }ph`; - } - - @property(String) - get rainChance() { - return this.#data.forecast.forecastday[0].day.daily_chance_of_rain + "%"; - } - - @property(String) - get icon() { - return this.getIcon(this.#data.current.condition.text); - } - - @property(String) - get tempIcon() { - return this.getTempIcon(this.#data.current.temp_c); - } - - @property(String) - get tempColour() { - return this.getTempDesc(this.#data.current.temp_c); - } - - getIcon(status: string) { - let query = status.trim().toLowerCase().replaceAll(" ", "_"); - if (!this.#data.current.is_day && query + "_night" in weatherIcons) query += "_night"; - return weatherIcons[query] ?? weatherIcons.warning; - } - - getTemp(data: _WeatherState) { - return `${Math.round(data[`temp_${config.imperial.get() ? "f" : "c"}`])}°${config.imperial.get() ? "F" : "C"}`; - } - - getTempIcon(temp: number) { - if (temp >= 40) return ""; - if (temp >= 30) return ""; - if (temp >= 20) return ""; - if (temp >= 10) return ""; - return ""; - } - - getTempDesc(temp: number) { - if (temp >= 40) return "burning"; - if (temp >= 30) return "hot"; - if (temp >= 20) return "normal"; - if (temp >= 10) return "cold"; - return "freezing"; - } - - #notify() { - this.notify("raw"); - this.notify("current"); - this.notify("forecast"); - this.notify("location"); - this.notify("condition"); - this.notify("temperature"); - this.notify("wind"); - this.notify("rain-chance"); - this.notify("icon"); - this.notify("temp-icon"); - this.notify("temp-colour"); - } - - async getWeather() { - const location = config.location || JSON.parse(await execAsync("curl ipinfo.io")).city; - const opts = `key=${config.apiKey.get()}&q=${location}&days=1&aqi=no&alerts=no`; - const url = `https://api.weatherapi.com/v1/forecast.json?${opts}`; - return JSON.parse(await execAsync(["curl", url])); - } - - async updateWeather() { - if (!config.apiKey.get()) { - if (!this.#notified) { - notify({ - summary: "Weather API key required", - body: `A weather API key is required to get weather data. Get one from https://www.weatherapi.com.`, - icon: "dialog-warning-symbolic", - urgency: "critical", - actions: { - "Get API key": () => execAsync(`app2unit -O 'https://www.weatherapi.com'`).catch(print), - }, - }); - this.#notified = true; - } - return; - } - - if (GLib.file_test(this.#cache, GLib.FileTest.EXISTS)) { - const cache = await readFileAsync(this.#cache); - const cache_data: WeatherData = JSON.parse(cache); - if (cache_data.location.localtime_epoch * 1000 + config.interval.get() > Date.now()) { - if (JSON.stringify(this.#data) !== cache) { - this.#data = cache_data; - this.#notify(); - } - return; - } - } - - try { - const data = await this.getWeather(); - this.#data = data; - writeFileAsync(this.#cache, JSON.stringify(data)).catch(console.error); // Catch here so it doesn't propagate - } catch (e) { - console.error("Error getting weather:", e); - this.#data = DEFAULT; - } - this.#notify(); - } - - constructor() { - super(); - - this.updateWeather().catch(console.error); - this.#interval = interval(config.interval.get(), () => this.updateWeather().catch(console.error)); - - config.apiKey.subscribe(() => this.updateWeather()); - - config.interval.subscribe(i => { - this.#interval?.cancel(); - this.#interval = interval(i, () => this.updateWeather().catch(console.error)); - }); - - config.imperial.subscribe(() => { - this.notify("temperature"); - this.notify("wind"); - }); - } -} diff --git a/src/utils/icons.ts b/src/utils/icons.ts deleted file mode 100644 index f164692..0000000 --- a/src/utils/icons.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Apps } from "@/services/apps"; -import { Gio } from "astal"; -import type AstalApps from "gi://AstalApps"; - -export const osIcons: Record<string, string> = { - almalinux: "", - alpine: "", - arch: "", - archcraft: "", - arcolinux: "", - artix: "", - centos: "", - debian: "", - devuan: "", - elementary: "", - endeavouros: "", - fedora: "", - freebsd: "", - garuda: "", - gentoo: "", - hyperbola: "", - kali: "", - linuxmint: "", - mageia: "", - openmandriva: "", - manjaro: "", - neon: "", - nixos: "", - opensuse: "", - suse: "", - sles: "", - sles_sap: "", - "opensuse-tumbleweed": "", - parrot: "", - pop: "", - raspbian: "", - rhel: "", - rocky: "", - slackware: "", - solus: "", - steamos: "", - tails: "", - trisquel: "", - ubuntu: "", - vanilla: "", - void: "", - zorin: "", -}; - -export const weatherIcons: Record<string, string> = { - warning: "", - sunny: "", - clear: "", - partly_cloudy: "", - partly_cloudy_night: "", - cloudy: "", - overcast: "", - mist: "", - patchy_rain_nearby: "", - patchy_rain_possible: "", - patchy_snow_possible: "", - patchy_sleet_possible: "", - patchy_freezing_drizzle_possible: "", - thundery_outbreaks_possible: "", - blowing_snow: "", - blizzard: "", - fog: "", - freezing_fog: "", - patchy_light_drizzle: "", - light_drizzle: "", - freezing_drizzle: "", - heavy_freezing_drizzle: "", - patchy_light_rain: "", - light_rain: "", - moderate_rain_at_times: "", - moderate_rain: "", - heavy_rain_at_times: "", - heavy_rain: "", - light_freezing_rain: "", - moderate_or_heavy_freezing_rain: "", - light_sleet: "", - moderate_or_heavy_sleet: "", - patchy_light_snow: "", - light_snow: "", - patchy_moderate_snow: "", - moderate_snow: "", - patchy_heavy_snow: "", - heavy_snow: "", - ice_pellets: "", - light_rain_shower: "", - moderate_or_heavy_rain_shower: "", - torrential_rain_shower: "", - light_sleet_showers: "", - moderate_or_heavy_sleet_showers: "", - light_snow_showers: "", - moderate_or_heavy_snow_showers: "", - light_showers_of_ice_pellets: "", - moderate_or_heavy_showers_of_ice_pellets: "", - patchy_light_rain_with_thunder: "", - moderate_or_heavy_rain_with_thunder: "", - moderate_or_heavy_rain_in_area_with_thunder: "", - patchy_light_snow_with_thunder: "", - moderate_or_heavy_snow_with_thunder: "", -}; - -export const desktopEntrySubs: Record<string, string> = { - Firefox: "firefox", -}; - -const categoryIcons: Record<string, string> = { - WebBrowser: "web", - Printing: "print", - Security: "security", - Network: "chat", - Archiving: "archive", - Compression: "archive", - Development: "code", - IDE: "code", - TextEditor: "edit_note", - Audio: "music_note", - Music: "music_note", - Player: "music_note", - Recorder: "mic", - Game: "sports_esports", - FileTools: "files", - FileManager: "files", - Filesystem: "files", - FileTransfer: "files", - Settings: "settings", - DesktopSettings: "settings", - HardwareSettings: "settings", - TerminalEmulator: "terminal", - ConsoleOnly: "terminal", - Utility: "build", - Monitor: "monitor_heart", - Midi: "graphic_eq", - Mixer: "graphic_eq", - AudioVideoEditing: "video_settings", - AudioVideo: "music_video", - Video: "videocam", - Building: "construction", - Graphics: "photo_library", - "2DGraphics": "photo_library", - RasterGraphics: "photo_library", - TV: "tv", - System: "host", -}; - -export const getAppCategoryIcon = (nameOrApp: string | AstalApps.Application) => { - const categories = - typeof nameOrApp === "string" - ? Gio.DesktopAppInfo.new(`${nameOrApp}.desktop`)?.get_categories()?.split(";") ?? - Apps.fuzzy_query(nameOrApp)[0]?.categories - : nameOrApp.categories; - if (categories) - for (const [key, value] of Object.entries(categoryIcons)) if (categories.includes(key)) return value; - return "terminal"; -}; diff --git a/src/utils/mpris.ts b/src/utils/mpris.ts deleted file mode 100644 index e0cc111..0000000 --- a/src/utils/mpris.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { GLib } from "astal"; -import AstalMpris from "gi://AstalMpris"; - -const hasPlasmaIntegration = GLib.find_program_in_path("plasma-browser-integration-host") !== null; - -export const isRealPlayer = (player?: AstalMpris.Player) => - player !== undefined && - // Player closed - player.identity !== null && - // Remove unecessary native buses from browsers if there's plasma integration - !(hasPlasmaIntegration && player.busName.startsWith("org.mpris.MediaPlayer2.firefox")) && - !(hasPlasmaIntegration && player.busName.startsWith("org.mpris.MediaPlayer2.chromium")) && - // playerctld just copies other buses and we don't need duplicates - !player.busName.startsWith("org.mpris.MediaPlayer2.playerctld") && - // Non-instance mpd bus - !(player.busName.endsWith(".mpd") && !player.busName.endsWith("MediaPlayer2.mpd")); diff --git a/src/utils/strings.ts b/src/utils/strings.ts deleted file mode 100644 index 1edad67..0000000 --- a/src/utils/strings.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const basename = (path: string, stripExt = true) => { - const lastSlash = path.lastIndexOf("/"); - const lastDot = path.lastIndexOf("."); - return path.slice(lastSlash + 1, stripExt && lastDot > lastSlash ? lastDot : undefined); -}; - -export const pathToFileName = (path: string, ext?: string) => { - const start = /[a-z]+:\/\//.test(path) ? 0 : path.indexOf("/") + 1; - const dir = path.slice(start, path.lastIndexOf("/")).replaceAll("/", "-"); - return `${dir}-${basename(path, ext !== undefined)}${ext ? `.${ext}` : ""}`; -}; - -export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); - -export const lengthStr = (length: number) => - `${Math.floor(length / 60)}:${Math.floor(length % 60) - .toString() - .padStart(2, "0")}`; diff --git a/src/utils/system.ts b/src/utils/system.ts deleted file mode 100644 index 3a9caa6..0000000 --- a/src/utils/system.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { bind, execAsync, Gio, GLib, Variable, type Binding } from "astal"; -import type AstalApps from "gi://AstalApps"; -import { osIcons } from "./icons"; - -/** - * See https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html - * @param exec The exec field in a desktop file - */ -const execToCmd = (app: AstalApps.Application) => { - let exec = app.executable.replace(/%[fFuUdDnNvm]/g, ""); // Remove useless field codes - exec = exec.replace(/%i/g, app.iconName ? `--icon ${app.iconName}` : ""); // Replace %i app icon - exec = exec.replace(/%c/g, app.name); // Replace %c with app name - exec = exec.replace(/%k/g, (app.app as Gio.DesktopAppInfo).get_filename() ?? ""); // Replace %k with desktop file path - return exec; -}; - -export const launch = (app: AstalApps.Application) => { - let now = Date.now(); - execAsync(["app2unit", "--", app.entry]).catch(() => { - // Try manual exec if launch fails (exits with error within 1 second) - if (Date.now() - now < 1000) { - now = Date.now(); - execAsync(["app2unit", "--", execToCmd(app)]).catch(() => { - // Fallback to regular launch - if (Date.now() - now < 1000) { - app.frequency--; // Decrement frequency cause launch also increments it - app.launch(); - } - }); - } - }); - app.frequency++; -}; - -export const notify = (props: { - summary: string; - body?: string; - icon?: string; - urgency?: "low" | "normal" | "critical"; - transient?: boolean; - actions?: Record<string, () => void>; -}) => - execAsync([ - "notify-send", - "-a", - "caelestia-shell", - ...(props.icon ? ["-i", props.icon] : []), - ...(props.urgency ? ["-u", props.urgency] : []), - ...(props.transient ? ["-e"] : []), - ...Object.keys(props.actions ?? {}).flatMap((k, i) => ["-A", `${i}=${k}`]), - props.summary, - ...(props.body ? [props.body] : []), - ]) - .then(action => props.actions && Object.values(props.actions)[parseInt(action, 10)]?.()) - .catch(console.error); - -export const osId = GLib.get_os_info("ID") ?? "unknown"; -export const osIdLike = GLib.get_os_info("ID_LIKE"); -export const osIcon = (() => { - if (osIcons.hasOwnProperty(osId)) return osIcons[osId]; - if (osIdLike) for (const id of osIdLike.split(" ")) if (osIcons.hasOwnProperty(id)) return osIcons[id]; - return ""; -})(); - -export const currentTime = Variable(GLib.DateTime.new_now_local()).poll(1000, () => GLib.DateTime.new_now_local()); -export const bindCurrentTime = ( - format: Binding<string> | string, - fallback?: (time: GLib.DateTime) => string, - self?: JSX.Element -) => { - const fmt = (c: GLib.DateTime, format: string) => c.format(format) ?? fallback?.(c) ?? new Date().toLocaleString(); - if (typeof format === "string") return bind(currentTime).as(c => fmt(c, format)); - if (!self) throw new Error("bindCurrentTime: self is required when format is a Binding"); - const time = Variable.derive([currentTime, format], (c, f) => fmt(c, f)); - self?.connect("destroy", () => time.drop()); - return bind(time); -}; - -const monitors = new Set(); -export const monitorDirectory = ( - path: string, - callback: ( - source: Gio.FileMonitor, - file: Gio.File, - other_file: Gio.File | null, - type: Gio.FileMonitorEvent - ) => void, - recursive: boolean = true -) => { - const file = Gio.file_new_for_path(path.replace("~", HOME)); - const monitor = file.monitor_directory(null, null); - monitor.connect("changed", (m, f1, f2, t) => { - if (t === Gio.FileMonitorEvent.CHANGES_DONE_HINT || t === Gio.FileMonitorEvent.DELETED) callback(m, f1, f2, t); - }); - - if (recursive) { - const enumerator = file.enumerate_children("standard::*", null, null); - let child; - while ((child = enumerator.next_file(null))) - if (child.get_file_type() === Gio.FileType.DIRECTORY) { - const m = monitorDirectory(`${path}/${child.get_name()}`, callback, recursive); - monitor.connect("notify::cancelled", () => m.cancel()); - } - } - - // Keep ref to monitor so it doesn't get GC'd - monitors.add(monitor); - monitor.connect("notify::cancelled", () => monitor.cancelled && monitors.delete(monitor)); - - return monitor; -}; diff --git a/src/utils/thumbnailer.ts b/src/utils/thumbnailer.ts deleted file mode 100644 index d23dab1..0000000 --- a/src/utils/thumbnailer.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { execAsync, GLib, type Variable } from "astal"; -import { thumbnailer as config } from "config"; - -export interface ThumbOpts { - width?: number; - height?: number; - exact?: boolean; -} - -export default class Thumbnailer { - static readonly thumbnailDir = `${CACHE}/thumbnails`; - - static readonly #running = new Set<string>(); - - static getOpt<T extends keyof ThumbOpts>(opt: T, opts: ThumbOpts) { - return opts[opt] ?? (config.defaults[opt] as Variable<NonNullable<ThumbOpts[T]>>).get(); - } - - static async getThumbPath(path: string, opts: ThumbOpts) { - const hash = (await execAsync(`sha1sum ${path}`)).split(" ")[0]; - const size = `${this.getOpt("width", opts)}x${this.getOpt("height", opts)}`; - const exact = this.getOpt("exact", opts) ? "-exact" : ""; - return `${this.thumbnailDir}/${hash}@${size}${exact}.png`; - } - - static async shouldThumbnail(path: string, opts: ThumbOpts) { - const [w, h] = (await execAsync(`identify -ping -format "%w %h" ${path}`)).split(" ").map(parseInt); - return w > this.getOpt("width", opts) || h > this.getOpt("height", opts); - } - - static async #thumbnail(path: string, opts: ThumbOpts, attempts: number): Promise<string> { - const thumbPath = await this.getThumbPath(path, opts); - - try { - const width = this.getOpt("width", opts); - const height = this.getOpt("height", opts); - const cropCmd = this.getOpt("exact", opts) - ? `-background none -gravity center -extent ${width}x${height}` - : ""; - await execAsync(`magick ${path} -thumbnail ${width}x${height}^ ${cropCmd} -unsharp 0x.5 ${thumbPath}`); - } catch { - if (attempts >= config.maxAttempts.get()) { - console.error(`Failed to generate thumbnail for ${path}`); - return path; - } - - await new Promise(r => setTimeout(r, config.timeBetweenAttempts.get())); - return this.#thumbnail(path, opts, attempts + 1); - } - - return thumbPath; - } - - static async thumbnail(path: string, opts: ThumbOpts = {}): Promise<string> { - if (!(await this.shouldThumbnail(path, opts))) return path; - - let thumbPath = await this.getThumbPath(path, opts); - - // Wait for existing thumbnail for path to finish - while (this.#running.has(path)) await new Promise(r => setTimeout(r, 100)); - - // If no thumbnail, generate - if (!GLib.file_test(thumbPath, GLib.FileTest.EXISTS)) { - this.#running.add(path); - - thumbPath = await this.#thumbnail(path, opts, 0); - - this.#running.delete(path); - } - - return thumbPath; - } - - // Static class - private constructor() {} - - static { - GLib.mkdir_with_parents(this.thumbnailDir, 0o755); - } -} diff --git a/src/utils/types.ts b/src/utils/types.ts deleted file mode 100644 index d2c1943..0000000 --- a/src/utils/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { astalify } from "astal/gtk3"; -import type AstalHyprland from "gi://AstalHyprland"; - -export type AstalWidget = InstanceType<ReturnType<typeof astalify>>; - -export type Address = `0x${string}`; - -export interface Client { - address: Address; - mapped: boolean; - hidden: boolean; - at: [number, number]; - size: [number, number]; - workspace: { - id: number; - name: string; - }; - floating: boolean; - pseudo: boolean; - monitor: number; - class: string; - title: string; - initialClass: string; - initialTitle: string; - pid: number; - xwayland: boolean; - pinned: boolean; - fullscreen: AstalHyprland.Fullscreen; - fullscreenClient: AstalHyprland.Fullscreen; - grouped: Address[]; - tags: string[]; - swallowing: string; - focusHistoryID: number; - inhibitingIdle: boolean; -} diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts deleted file mode 100644 index bef79f2..0000000 --- a/src/utils/widgets.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Binding, idle, register } from "astal"; -import { Astal, astalify, Gtk, Widget, type ConstructProps } from "astal/gtk3"; -import AstalHyprland from "gi://AstalHyprland"; -import type { AstalWidget } from "./types"; - -export const setupCustomTooltip = ( - self: AstalWidget, - text: string | Binding<string>, - labelProps: Widget.LabelProps = {} -) => { - if (!text) return null; - - self.set_has_tooltip(true); - - const window = new Widget.Window({ - visible: false, - namespace: "caelestia-tooltip", - layer: Astal.Layer.OVERLAY, - keymode: Astal.Keymode.NONE, - exclusivity: Astal.Exclusivity.IGNORE, - anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT, - child: new Widget.Label({ ...labelProps, className: "tooltip", label: text }), - }); - self.set_tooltip_window(window); - - if (text instanceof Binding) self.hook(text, (_, v) => !v && window.hide()); - - const positionWindow = ({ x, y }: { x: number; y: number }) => { - const { width: mWidth, height: mHeight } = AstalHyprland.get_default().get_focused_monitor(); - const { width: pWidth, height: pHeight } = window.get_preferred_size()[1]!; - const cursorSize = Gtk.Settings.get_default()?.gtkCursorThemeSize ?? 0; - - let marginLeft = x - pWidth / 2; - if (marginLeft < 0) marginLeft = 0; - else if (marginLeft + pWidth > mWidth) marginLeft = mWidth - pWidth; - - let marginTop = y + cursorSize; - if (marginTop < 0) marginTop = 0; - else if (marginTop + pHeight > mHeight) marginTop = y - pHeight; - - window.marginLeft = marginLeft; - window.marginTop = marginTop; - }; - - let lastPos = { x: 0, y: 0 }; - - window.connect("size-allocate", () => positionWindow(lastPos)); - self.connect("query-tooltip", () => { - if (text instanceof Binding && !text.get()) return false; - if (window.visible) return true; - - const cPos = AstalHyprland.get_default().get_cursor_position(); - positionWindow(cPos); - lastPos = cPos; - - return true; - }); - - self.connect("destroy", () => window.destroy()); - - return window; -}; - -export const setupChildClickthrough = (self: AstalWidget) => { - self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes()); - self.set_size_request(1, 1); - idle(() => self.set_size_request(-1, -1)); -}; - -@register() -export class MenuItem extends astalify(Gtk.MenuItem) { - constructor(props: ConstructProps<MenuItem, Gtk.MenuItem.ConstructorProps, { onActivate: [] }>) { - super(props as any); - } -} - -@register() -export class FlowBox extends astalify(Gtk.FlowBox) { - constructor(props: ConstructProps<FlowBox, Gtk.FlowBox.ConstructorProps>) { - super(props as any); - } -} diff --git a/src/widgets/notification.tsx b/src/widgets/notification.tsx deleted file mode 100644 index 0dfd368..0000000 --- a/src/widgets/notification.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { desktopEntrySubs } from "@/utils/icons"; -import Thumbnailer from "@/utils/thumbnailer"; -import { setupCustomTooltip } from "@/utils/widgets"; -import { bind, GLib, register, timeout, Variable } from "astal"; -import { Astal, Gtk, Widget } from "astal/gtk3"; -import { notifpopups as config } from "config"; -import AstalNotifd from "gi://AstalNotifd"; - -const urgencyToString = (urgency: AstalNotifd.Urgency) => { - switch (urgency) { - case AstalNotifd.Urgency.LOW: - return "low"; - case AstalNotifd.Urgency.NORMAL: - return "normal"; - case AstalNotifd.Urgency.CRITICAL: - return "critical"; - } -}; - -const getTime = (time: number) => { - const messageTime = GLib.DateTime.new_from_unix_local(time); - const now = GLib.DateTime.new_now_local(); - const todayDay = now.get_day_of_year(); - - if (config.agoTime.get()) { - const diff = now.difference(messageTime) / 1e6; - if (diff < 60) return "Now"; - if (diff < 3600) { - const d = Math.floor(diff / 60); - return `${d} min${d === 1 ? "" : "s"} ago`; - } - if (diff < 86400) { - const d = Math.floor(diff / 3600); - return `${d} hour${d === 1 ? "" : "s"} ago`; - } - } else if (messageTime.get_day_of_year() === todayDay) { - const aMinuteAgo = GLib.DateTime.new_now_local().add_seconds(-60); - return aMinuteAgo !== null && messageTime.compare(aMinuteAgo) > 0 ? "Now" : messageTime.format("%H:%M")!; - } - - if (messageTime.get_day_of_year() === todayDay - 1) return "Yesterday"; - return messageTime.format("%d/%m")!; -}; - -const AppIcon = ({ appIcon, desktopEntry }: { appIcon: string; desktopEntry: string }) => { - // Try app icon - let icon = Astal.Icon.lookup_icon(appIcon) && appIcon; - // Try desktop entry - if (!icon) { - if (desktopEntrySubs.hasOwnProperty(desktopEntry)) icon = desktopEntrySubs[desktopEntry]; - else if (Astal.Icon.lookup_icon(desktopEntry)) icon = desktopEntry; - } - return icon ? <icon className="app-icon" icon={icon} /> : null; -}; - -const Image = ({ compact, icon }: { compact?: boolean; icon: string }) => { - if (GLib.file_test(icon, GLib.FileTest.EXISTS)) - return ( - <box - valign={Gtk.Align.START} - className={`image ${compact ? "small" : ""}`} - setup={self => - Thumbnailer.thumbnail(icon) - .then(p => (self.css = `background-image: url("${p}");`)) - .catch(console.error) - } - /> - ); - if (Astal.Icon.lookup_icon(icon)) - return <icon valign={Gtk.Align.START} className={`image ${compact ? "small" : ""}`} icon={icon} />; - return null; -}; - -@register() -export default class Notification extends Widget.Box { - readonly #revealer; - #destroyed = false; - - constructor({ - notification, - popup, - compact = popup, - }: { - notification: AstalNotifd.Notification; - popup?: boolean; - compact?: boolean; - }) { - super({ className: "notification" }); - - const time = Variable(getTime(notification.time)).poll(60000, () => getTime(notification.time)); - this.hook(config.agoTime, () => time.set(getTime(notification.time))); - - this.#revealer = ( - <revealer - revealChild={popup} - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={150} - > - <box className="wrapper"> - <box vertical className={`inner ${urgencyToString(notification.urgency)}`}> - <box className="header"> - <AppIcon appIcon={notification.appIcon} desktopEntry={notification.appName} /> - <label className="app-name" label={notification.appName ?? "Unknown"} /> - <box hexpand /> - <label className="time" label={bind(time)} onDestroy={() => time.drop()} /> - </box> - <box hexpand className="separator" /> - <box className="content"> - {notification.image && <Image compact={compact} icon={notification.image} />} - <box vertical> - <label className="summary" xalign={0} label={notification.summary} truncate /> - {notification.body && ( - <label - className="body" - xalign={0} - label={compact ? notification.body.split("\n")[0] : notification.body} - wrap - lines={compact ? 1 : -1} - truncate={compact} - setup={self => compact && !popup && setupCustomTooltip(self, notification.body)} - /> - )} - </box> - </box> - {!popup && ( - <box className="actions"> - <button - hexpand - cursor="pointer" - onClicked={() => notification.dismiss()} - label="Close" - /> - {notification.actions.map(a => ( - <button hexpand cursor="pointer" onClicked={() => notification.invoke(a.id)}> - {notification.actionIcons ? <icon icon={a.label} /> : a.label} - </button> - ))} - </box> - )} - </box> - </box> - </revealer> - ) as Widget.Revealer; - this.add(this.#revealer); - - // Init animation - const width = this.get_preferred_width()[1]; - if (popup) this.css = `margin-left: ${width}px; margin-right: -${width}px;`; - timeout(1, () => { - this.#revealer.revealChild = true; - this.css = `transition: 300ms cubic-bezier(0.05, 0.9, 0.1, 1.1); margin-left: 0; margin-right: 0;`; - }); - - // Close popup after timeout if transient or expire enabled in config - if (popup && (config.expire.get() || notification.transient)) - timeout( - notification.expireTimeout > 0 - ? notification.expireTimeout - : notification.urgency === AstalNotifd.Urgency.CRITICAL - ? 10000 - : 5000, - () => this.destroyWithAnims() - ); - } - - destroyWithAnims() { - if (this.#destroyed) return; - this.#destroyed = true; - - const animTime = 120; - const animMargin = this.get_allocated_width(); - this.css = `transition: ${animTime}ms cubic-bezier(0.85, 0, 0.15, 1); - margin-left: ${animMargin}px; margin-right: -${animMargin}px;`; - timeout(animTime, () => { - this.#revealer.revealChild = false; - timeout(this.#revealer.transitionDuration, () => this.destroy()); - }); - } -} diff --git a/src/widgets/popupwindow.ts b/src/widgets/popupwindow.ts deleted file mode 100644 index 5ffa061..0000000 --- a/src/widgets/popupwindow.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Binding, register } from "astal"; -import { App, Astal, Gdk, Widget } from "astal/gtk3"; -import { bar } from "config"; -import AstalHyprland from "gi://AstalHyprland"; - -const extendProp = <T>( - prop: T | Binding<T | undefined> | undefined, - override: (prop: T | undefined) => T | undefined -) => prop && (prop instanceof Binding ? prop.as(override) : override(prop)); - -@register() -export default class PopupWindow extends Widget.Window { - constructor(props: Widget.WindowProps) { - super({ - keymode: Astal.Keymode.ON_DEMAND, - borderWidth: 20, // To allow shadow, cause if not it gets cut off - ...props, - visible: false, - application: App, - name: props.monitor ? extendProp(props.name, n => (n ? n + props.monitor : undefined)) : props.name, - namespace: extendProp(props.name, n => `caelestia-${n}`), - onKeyPressEvent: (self, event) => { - // Close window on escape - if (event.get_keyval()[1] === Gdk.KEY_Escape) self.hide(); - - return props.onKeyPressEvent?.(self, event); - }, - }); - } - - popup_at_widget(widget: JSX.Element, event: Gdk.Event | Astal.ClickEvent) { - const { width, height } = widget.get_allocation(); - const { width: mWidth, height: mHeight } = AstalHyprland.get_default().get_focused_monitor(); - const pWidth = this.get_preferred_width()[1]; - const pHeight = this.get_preferred_height()[1]; - const [, x, y] = event instanceof Gdk.Event ? event.get_coords() : [null, event.x, event.y]; - const { x: cx, y: cy } = AstalHyprland.get_default().get_cursor_position(); - - let marginLeft = 0; - let marginTop = 0; - if (bar.vertical.get()) { - marginLeft = cx + (width - x); - marginTop = cy + ((height - pHeight) / 2 - y); - if (marginTop < 0) marginTop = 0; - else if (marginTop + pHeight > mHeight) marginTop = mHeight - pHeight; - } else { - marginLeft = cx + ((width - pWidth) / 2 - x); - if (marginLeft < 0) marginLeft = 0; - else if (marginLeft + pWidth > mWidth) marginLeft = mWidth - pWidth; - marginTop = cy + (height - y); - } - - this.anchor = Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT; - this.exclusivity = Astal.Exclusivity.IGNORE; - this.marginLeft = marginLeft; - this.marginTop = marginTop; - - this.show(); - } - - popup_at_corner(corner: `${"top" | "bottom"} ${"left" | "right"}`) { - let anchor = 0; - if (corner.includes("top")) anchor |= Astal.WindowAnchor.TOP; - else anchor |= Astal.WindowAnchor.BOTTOM; - if (corner.includes("left")) anchor |= Astal.WindowAnchor.LEFT; - else anchor |= Astal.WindowAnchor.RIGHT; - - this.anchor = anchor; - this.exclusivity = Astal.Exclusivity.NORMAL; - this.marginLeft = 0; - this.marginTop = 0; - - this.show(); - } -} diff --git a/src/widgets/screencorner.tsx b/src/widgets/screencorner.tsx deleted file mode 100644 index a55d782..0000000 --- a/src/widgets/screencorner.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Binding } from "astal"; -import { Gtk, type Widget } from "astal/gtk3"; -import type cairo from "cairo"; - -type Place = "topleft" | "topright" | "bottomleft" | "bottomright"; - -export default ({ place, ...rest }: Widget.DrawingAreaProps & { place: Place | Binding<Place> }) => ( - <drawingarea - {...rest} - className="screen-corner" - setup={self => { - self.connect("realize", () => self.get_window()?.set_pass_through(true)); - - const r = self.get_style_context().get_property("border-radius", Gtk.StateFlags.NORMAL) as number; - self.set_size_request(r, r); - self.connect("draw", (_, cr: cairo.Context) => { - const c = self.get_style_context().get_background_color(Gtk.StateFlags.NORMAL); - const r = self.get_style_context().get_property("border-radius", Gtk.StateFlags.NORMAL) as number; - self.set_size_request(r, r); - - switch (typeof place === "string" ? place : place.get()) { - case "topleft": - cr.arc(r, r, r, Math.PI, (3 * Math.PI) / 2); - cr.lineTo(0, 0); - break; - - case "topright": - cr.arc(0, r, r, (3 * Math.PI) / 2, 2 * Math.PI); - cr.lineTo(r, 0); - break; - - case "bottomleft": - cr.arc(r, 0, r, Math.PI / 2, Math.PI); - cr.lineTo(0, r); - break; - - case "bottomright": - cr.arc(0, 0, r, 0, Math.PI / 2); - cr.lineTo(r, r); - break; - } - - cr.closePath(); - cr.setSourceRGBA(c.red, c.green, c.blue, c.alpha); - cr.fill(); - }); - }} - /> -); diff --git a/src/widgets/slider.tsx b/src/widgets/slider.tsx deleted file mode 100644 index 0a66609..0000000 --- a/src/widgets/slider.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { bind, type Binding } from "astal"; -import { Gdk, Gtk, type Widget } from "astal/gtk3"; -import type cairo from "cairo"; - -export default ({ - value, - onChange, -}: { - value: Binding<number>; - onChange?: (self: Widget.DrawingArea, value: number) => void; -}) => ( - <drawingarea - hexpand - valign={Gtk.Align.CENTER} - className="slider" - css={bind(value).as(v => `font-size: ${Math.min(1, Math.max(0, 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(); - }); - - self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK); - self.connect("button-press-event", (_, event: Gdk.Event) => - onChange?.(self, event.get_coords()[1] / self.get_allocated_width()) - ); - }} - /> -); |