diff options
| author | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-01-16 16:35:37 +1100 |
|---|---|---|
| committer | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-01-16 16:35:37 +1100 |
| commit | 02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38 (patch) | |
| tree | 5e2a56becf6ba6961995e541ce9688224f704773 /modules | |
| parent | popupwindow: switch to class (diff) | |
| download | caelestia-shell-02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38.tar.gz caelestia-shell-02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38.tar.bz2 caelestia-shell-02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38.zip | |
refactor: move ts to src
Also move popupwindow to own file
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/bar.tsx | 500 | ||||
| -rw-r--r-- | modules/launcher.tsx | 390 | ||||
| -rw-r--r-- | modules/notifpopups.tsx | 177 | ||||
| -rw-r--r-- | modules/osds.tsx | 326 |
4 files changed, 0 insertions, 1393 deletions
diff --git a/modules/bar.tsx b/modules/bar.tsx deleted file mode 100644 index 5ed309e..0000000 --- a/modules/bar.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import { execAsync, GLib, register, Variable } from "astal"; -import { bind, kebabify } from "astal/binding"; -import { App, Astal, astalify, Gdk, Gtk, type ConstructProps } from "astal/gtk3"; -import AstalBluetooth from "gi://AstalBluetooth"; -import AstalHyprland from "gi://AstalHyprland"; -import AstalNetwork from "gi://AstalNetwork"; -import AstalNotifd from "gi://AstalNotifd"; -import AstalTray from "gi://AstalTray"; -import AstalWp01 from "gi://AstalWp"; -import { bar as config } from "../config"; -import type { Monitor } from "../services/monitors"; -import Players from "../services/players"; -import Updates from "../services/updates"; -import { getAppCategoryIcon } from "../utils/icons"; -import { ellipsize } from "../utils/strings"; -import { osIcon } from "../utils/system"; -import { setupCustomTooltip } from "../utils/widgets"; - -const hyprland = AstalHyprland.get_default(); - -const hookFocusedClientProp = ( - self: any, // Ugh why is there no base Widget type - 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 OSIcon = () => <label className="module os-icon" label={osIcon} />; - -const ActiveWindow = () => ( - <box - hasTooltip - className="module active-window" - 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 - setup={self => - hookFocusedClientProp(self, "title", c => (self.label = c?.title ? ellipsize(c.title) : "Desktop")) - } - /> - </box> -); - -const MediaPlaying = () => { - 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) { - // TODO: media panel - } 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 className="module media-playing"> - <icon - setup={self => - players.hookLastPlayer(self, "notify::identity", () => { - const icon = `caelestia-${players.lastPlayer?.identity.toLowerCase()}-symbolic`; - self.icon = players.lastPlayer - ? Astal.Icon.lookup_icon(icon) - ? icon - : "caelestia-media-generic-symbolic" - : "caelestia-media-none-symbolic"; - }) - } - /> - <label - setup={self => - players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => { - self.label = ellipsize(getLabel("No media")); // TODO: scroll text - }) - } - /> - </box> - </button> - ); -}; - -const Workspace = ({ idx }: { idx: number }) => { - let wsId = Math.floor((hyprland.focusedWorkspace.id - 1) / config.wsPerGroup) * config.wsPerGroup + idx; - return ( - <button - halign={Gtk.Align.CENTER} - valign={Gtk.Align.CENTER} - onClicked={() => hyprland.dispatch("workspace", String(wsId))} - setup={self => { - const update = () => - self.toggleClassName( - "occupied", - hyprland.clients.some(c => c.workspace.id === wsId) - ); - - self.hook(hyprland, "notify::focused-workspace", () => { - wsId = Math.floor((hyprland.focusedWorkspace.id - 1) / config.wsPerGroup) * config.wsPerGroup + idx; - self.toggleClassName("focused", hyprland.focusedWorkspace.id === wsId); - update(); - }); - self.hook(hyprland, "client-added", update); - self.hook(hyprland, "client-moved", update); - self.hook(hyprland, "client-removed", update); - - self.toggleClassName("focused", hyprland.focusedWorkspace.id === wsId); - update(); - }} - /> - ); -}; - -const Workspaces = () => ( - <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 className="module workspaces"> - {Array.from({ length: config.wsPerGroup }).map((_, idx) => ( - <Workspace idx={idx + 1} /> // Start from 1 - ))} - </box> - </eventbox> -); - -@register() -class TrayItemMenu extends astalify(Gtk.Menu) { - readonly item: AstalTray.TrayItem; - - constructor(props: ConstructProps<TrayItemMenu, Gtk.Menu.ConstructorProps> & { item: AstalTray.TrayItem }) { - const { item, ...sProps } = props; - super(sProps as any); - - this.item = item; - - this.hook(item, "notify::menu-model", () => this.bind_model(item.menuModel, null, true)); - this.hook(item, "notify::action-group", () => this.insert_action_group("dbusmenu", item.actionGroup)); - this.bind_model(item.menuModel, null, true); - this.insert_action_group("dbusmenu", item.actionGroup); - } - - popup_at_widget_bottom(widget: Gtk.Widget) { - this.item.about_to_show(); - this.popup_at_widget(widget, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null); - } -} - -const TrayItem = (item: AstalTray.TrayItem) => { - const menu = (<TrayItemMenu item={item} />) as TrayItemMenu; - return ( - <button - onClick={(self, event) => { - if (event.button === Astal.MouseButton.PRIMARY) { - if (item.isMenu) menu.popup_at_widget_bottom(self); - else item.activate(0, 0); - } else if (event.button === Astal.MouseButton.SECONDARY) menu.popup_at_widget_bottom(self); - }} - onScroll={(_, event) => { - if (event.delta_x !== 0) item.scroll(event.delta_x, "horizontal"); - if (event.delta_y !== 0) item.scroll(event.delta_y, "vertical"); - }} - onDestroy={() => menu.destroy()} - setup={self => setupCustomTooltip(self, bind(item, "tooltipMarkup"))} - > - <icon halign={Gtk.Align.CENTER} gicon={bind(item, "gicon")} /> - </button> - ); -}; - -const Tray = () => <box className="module tray">{bind(AstalTray.get_default(), "items").as(i => i.map(TrayItem))}</box>; - -const Network = () => ( - <button - onClick={(_, event) => { - const network = AstalNetwork.get_default(); - if (event.button === Astal.MouseButton.PRIMARY) { - // TODO: networks panel - } else if (event.button === Astal.MouseButton.SECONDARY) network.wifi.enabled = !network.wifi.enabled; - else if (event.button === Astal.MouseButton.MIDDLE) - execAsync("uwsm app -- gnome-control-center wifi").catch(() => { - network.wifi.scan(); - execAsync( - "uwsm app -- foot -T nmtui fish -c 'sleep .1; set -e COLORTERM; TERM=xterm-old nmtui connect'" - ).catch(err => { - // Idk why but foot always throws this error when it opens - if ( - err.message !== - "warn: wayland.c:1619: compositor does not implement the XDG toplevel icon protocol\nwarn: terminal.c:1973: slave exited with signal 1 (Hangup)" - ) - console.error(err); - }); - }); - }} - 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 Bluetooth = () => ( - <button - onClick={(_, event) => { - if (event.button === Astal.MouseButton.PRIMARY) { - // TODO: bluetooth panel - } else if (event.button === Astal.MouseButton.SECONDARY) AstalBluetooth.get_default().toggle(); - else if (event.button === Astal.MouseButton.MIDDLE) - execAsync("uwsm app -- 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> -); - -const StatusIcons = () => ( - <box className="module status-icons"> - <Network /> - <Bluetooth /> - </box> -); - -const PkgUpdates = () => ( - <box - className="module updates" - setup={self => - setupCustomTooltip( - self, - bind(Updates.get_default(), "numUpdates").as(n => `${n} update${n === 1 ? "" : "s"} available`) - ) - } - > - <label className="icon" label="download" /> - <label label={bind(Updates.get_default(), "numUpdates").as(String)} /> - </box> -); - -const Notifications = () => { - const unreadCount = Variable(0); - return ( - <box - className="module notifications" - setup={self => - setupCustomTooltip( - self, - bind(unreadCount).as(n => `${n} unread notification${n === 1 ? "" : "s"}`) - ) - } - > - <label className="icon" label="info" /> - <label - label="0" - setup={self => { - const notifd = AstalNotifd.get_default(); - let notifsOpen = false; - let unread = new Set<number>(); - - self.hook(notifd, "notified", (self, id, replaced) => { - if (!notifsOpen && !replaced) { - unread.add(id); - unreadCount.set(unread.size); - self.label = String(unread.size); - } - }); - self.hook(notifd, "resolved", (self, id) => { - if (unread.delete(id)) { - unreadCount.set(unread.size); - self.label = String(unread.size); - } - }); - self.hook(App, "window-toggled", (_, window) => { - if (window.name === "notifications") { - notifsOpen = window.visible; - if (notifsOpen) { - unread.clear(); - unreadCount.set(0); - } - } - }); - }} - /> - </box> - ); -}; - -const DateTime = () => ( - <box className="module date-time"> - <label className="icon" label="calendar_month" /> - <label - setup={self => { - const pollVar = Variable(null).poll(5000, () => { - self.label = - GLib.DateTime.new_now_local().format(config.dateTimeFormat) ?? new Date().toLocaleString(); - return null; - }); - self.connect("destroy", () => pollVar.drop()); - }} - /> - </box> -); - -const Power = () => ( - <button - className="module power" - label="power_settings_new" - onClicked={() => execAsync("fish -c 'pkill wlogout || wlogout -p layer-shell'").catch(console.error)} - /> -); - -export default ({ monitor }: { monitor: Monitor }) => ( - <window - namespace="caelestia-bar" - monitor={monitor.id} - anchor={Astal.WindowAnchor.TOP} - exclusivity={Astal.Exclusivity.EXCLUSIVE} - > - <centerbox className="bar" css={"min-width: " + monitor.width * 0.8 + "px;"}> - <box> - <OSIcon /> - <ActiveWindow /> - <MediaPlaying /> - <button - hexpand - onScroll={(_, event) => - event.delta_y > 0 ? (monitor.brightness -= 0.1) : (monitor.brightness += 0.1) - } - /> - </box> - <Workspaces /> - <box> - <button - hexpand - onScroll={(_, event) => { - const speaker = AstalWp01.get_default()?.audio.defaultSpeaker; - if (!speaker) return; - speaker.mute = false; - if (event.delta_y > 0) speaker.volume -= 0.1; - else speaker.volume += 0.1; - }} - /> - <Tray /> - <StatusIcons /> - <PkgUpdates /> - <Notifications /> - <DateTime /> - <Power /> - </box> - </centerbox> - </window> -); diff --git a/modules/launcher.tsx b/modules/launcher.tsx deleted file mode 100644 index 966cdfd..0000000 --- a/modules/launcher.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import { bind, execAsync, Gio, GLib, register, timeout, Variable } from "astal"; -import { Astal, Gtk, Widget } from "astal/gtk3"; -import fuzzysort from "fuzzysort"; -import type AstalApps from "gi://AstalApps"; -import AstalHyprland from "gi://AstalHyprland"; -import { launcher as config } from "../config"; -import { Apps } from "../services/apps"; -import Math, { type HistoryItem } from "../services/math"; -import { getAppCategoryIcon } from "../utils/icons"; -import { launch } from "../utils/system"; -import { PopupWindow, setupCustomTooltip } from "../utils/widgets"; - -type Mode = "apps" | "files" | "math"; - -interface Subcommand { - icon: string; - name: string; - description: string; - command: (...args: string[]) => void; -} - -const getIconFromMode = (mode: Mode) => { - switch (mode) { - case "apps": - return "apps"; - case "files": - return "folder"; - case "math": - return "calculate"; - } -}; - -const getEmptyTextFromMode = (mode: Mode) => { - switch (mode) { - case "apps": - return "No apps found"; - case "files": - return GLib.find_program_in_path("fd") === null ? "File search requires `fd`" : "No files found"; - case "math": - return "Type an expression"; - } -}; - -const close = (self: JSX.Element) => { - const toplevel = self.get_toplevel(); - if (toplevel instanceof Widget.Window) toplevel.hide(); -}; - -const launchAndClose = (self: JSX.Element, astalApp: AstalApps.Application) => { - close(self); - launch(astalApp); -}; - -const openFileAndClose = (self: JSX.Element, path: string) => { - close(self); - 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:"" || xdg-open "${path}"`, - ]).catch(console.error); -}; - -const PinnedApp = (names: string[]) => { - let app: Gio.DesktopAppInfo | null = null; - let astalApp: AstalApps.Application | undefined; - for (const name of names) { - app = Gio.DesktopAppInfo.new(`${name}.desktop`); - if (app) { - astalApp = Apps.get_list().find(a => a.entry === `${name}.desktop`); - if (app.get_icon() && astalApp) break; - else app = null; // Set app to null if no icon or matching AstalApps#Application - } - } - - if (!app) console.error(`Launcher - Unable to find app for "${names.join(", ")}"`); - - return app ? ( - <button - className="pinned-app result" - cursor="pointer" - onClicked={self => launchAndClose(self, astalApp!)} - setup={self => setupCustomTooltip(self, app.get_display_name())} - > - <icon gicon={app.get_icon()!} /> - </button> - ) : null; -}; - -const PinnedApps = () => <box homogeneous>{config.pins.map(PinnedApp)}</box>; - -const SearchEntry = ({ entry }: { entry: Widget.Entry }) => ( - <stack - hexpand - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={150} - setup={self => - self.hook(entry, "notify::text-length", () => - // Timeout to avoid flickering when replacing entire text (cause it'll set len to 0 then back to > 0) - timeout(1, () => (self.shown = entry.textLength > 0 ? "entry" : "placeholder")) - ) - } - > - <label name="placeholder" className="placeholder" xalign={0} label='Type ">" for subcommands' /> - {entry} - </stack> -); - -const Result = ({ - icon, - materialIcon, - label, - sublabel, - onClicked, -}: { - icon?: string; - materialIcon?: string; - label: string; - sublabel?: string; - onClicked: (self: Widget.Button) => void; -}) => ( - <button className="result" cursor="pointer" onClicked={onClicked}> - <box> - {icon && Astal.Icon.lookup_icon(icon) ? ( - <icon valign={Gtk.Align.START} className="icon" icon={icon} /> - ) : ( - <label valign={Gtk.Align.START} className="icon" label={materialIcon} /> - )} - {sublabel ? ( - <box vertical valign={Gtk.Align.CENTER} className="has-sublabel"> - <label hexpand truncate maxWidthChars={1} xalign={0} label={label} /> - <label hexpand truncate maxWidthChars={1} className="sublabel" xalign={0} label={sublabel} /> - </box> - ) : ( - <label xalign={0} label={label} /> - )} - </box> - </button> -); - -const SubcommandResult = ({ - entry, - subcommand, - args, -}: { - entry: Widget.Entry; - subcommand: Subcommand; - args: string[]; -}) => ( - <Result - materialIcon={subcommand.icon} - label={subcommand.name} - sublabel={subcommand.description} - onClicked={() => { - subcommand.command(...args); - entry.set_text(""); - }} - /> -); - -const AppResult = ({ app }: { app: AstalApps.Application }) => ( - <Result - icon={app.iconName} - materialIcon={getAppCategoryIcon(app)} - label={app.name} - sublabel={app.description} - onClicked={self => launchAndClose(self, app)} - /> -); - -const MathResult = ({ math, isHistory, entry }: { math: HistoryItem; isHistory?: boolean; entry: Widget.Entry }) => ( - <Result - materialIcon={math.icon} - label={math.equation} - sublabel={math.result} - onClicked={() => { - if (isHistory) { - Math.get_default().select(math); - entry.set_text(math.equation); - entry.grab_focus(); - entry.set_position(-1); - } else { - execAsync(`wl-copy -- ${math.result}`).catch(console.error); - entry.set_text(""); - } - }} - /> -); - -const FileResult = ({ path }: { path: string }) => ( - <Result - label={path.split("/").pop()!} - sublabel={path.startsWith(HOME) ? "~" + path.slice(HOME.length) : path} - onClicked={self => openFileAndClose(self, path)} - /> -); - -const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> }) => { - const empty = Variable(true); - - return ( - <stack - className="results" - transitionType={Gtk.StackTransitionType.CROSSFADE} - transitionDuration={150} - shown={bind(empty).as(t => (t ? "empty" : "list"))} - > - <box name="empty" className="empty" halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER}> - <label className="icon" label="bug_report" /> - <label - label={bind(entry, "text").as(t => - t.startsWith(">") ? "No matching subcommands" : getEmptyTextFromMode(mode.get()) - )} - /> - </box> - <box - vertical - name="list" - setup={self => { - const subcommands: Record<string, Subcommand> = { - apps: { - icon: "apps", - name: "Apps", - description: "Search for apps", - command: () => mode.set("apps"), - }, - files: { - icon: "folder", - name: "Files", - description: "Search for files", - command: () => mode.set("files"), - }, - math: { - icon: "calculate", - name: "Math", - description: "Do math calculations", - command: () => mode.set("math"), - }, - todo: { - icon: "checklist", - name: "Todo", - description: "Create a todo in <INSERT_TODO_APP>", - command: (...args) => { - // TODO: todo service or maybe use external app - }, - }, - }; - const subcommandList = Object.keys(subcommands); - - const updateEmpty = () => empty.set(self.get_children().length === 0); - - const appSearch = () => { - const apps = Apps.fuzzy_query(entry.text); - if (apps.length > config.maxResults) apps.length = config.maxResults; - for (const app of apps) self.add(<AppResult app={app} />); - }; - - const calculate = () => { - if (entry.text) { - self.add(<MathResult math={Math.get_default().evaluate(entry.text)} entry={entry} />); - self.add(<box className="separator" />); - } - for (const item of Math.get_default().history) - self.add(<MathResult isHistory math={item} entry={entry} />); - }; - - const fileSearch = () => - execAsync(["fd", ...config.fdOpts, entry.text, HOME]) - .then(out => { - const paths = out.split("\n").filter(path => path); - if (paths.length > config.maxResults) paths.length = config.maxResults; - self.foreach(ch => ch.destroy()); - for (const path of paths) self.add(<FileResult path={path} />); - }) - .catch(e => { - // Ignore execAsync error - if (!(e instanceof Gio.IOErrorEnum || e instanceof GLib.SpawnError)) console.error(e); - }) - .finally(updateEmpty); - - self.hook(entry, "activate", () => { - if (mode.get() === "math") { - if (entry.text.startsWith("clear")) Math.get_default().clear(); - else Math.get_default().commit(); - } - self.get_children()[0]?.activate(); - }); - self.hook(entry, "changed", () => { - if (!entry.text && mode.get() === "apps") return; - - // Files has delay cause async so it does some stuff by itself - const ignoreFileAsync = entry.text.startsWith(">") || mode.get() !== "files"; - if (ignoreFileAsync) self.foreach(ch => ch.destroy()); - - if (entry.text.startsWith(">")) { - const args = entry.text.split(" "); - for (const { target } of fuzzysort.go(args[0].slice(1), subcommandList, { all: true })) - self.add( - <SubcommandResult - entry={entry} - subcommand={subcommands[target]} - args={args.slice(1)} - /> - ); - } else if (mode.get() === "apps") appSearch(); - else if (mode.get() === "math") calculate(); - else if (mode.get() === "files") fileSearch(); - - if (ignoreFileAsync) updateEmpty(); - }); - }} - /> - </stack> - ); -}; - -const LauncherContent = ({ - mode, - showResults, - entry, -}: { - mode: Variable<Mode>; - showResults: Variable<boolean>; - entry: Widget.Entry; -}) => ( - <box vertical className={bind(mode).as(m => `launcher ${m}`)}> - <box className="search-bar"> - <label className="icon" label="search" /> - <SearchEntry entry={entry} /> - <label className="icon" label={bind(mode).as(getIconFromMode)} /> - </box> - <revealer - revealChild={bind(showResults).as(s => !s)} - transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} - transitionDuration={150} - > - <PinnedApps /> - </revealer> - <revealer - revealChild={bind(showResults)} - transitionType={Gtk.RevealerTransitionType.SLIDE_UP} - transitionDuration={150} - > - <Results entry={entry} mode={mode} /> - </revealer> - </box> -); - -@register() -export default class Launcher extends PopupWindow { - readonly mode: Variable<Mode>; - - constructor() { - const entry = (<entry name="entry" />) as Widget.Entry; - const mode = Variable<Mode>("apps"); - const showResults = Variable.derive([bind(entry, "textLength"), mode], (t, m) => t > 0 || m !== "apps"); - - super({ - name: "launcher", - anchor: Astal.WindowAnchor.TOP, - keymode: Astal.Keymode.EXCLUSIVE, - 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: <LauncherContent mode={mode} showResults={showResults} entry={entry} />, - }); - - this.mode = mode; - - this.connect("show", () => (this.marginTop = AstalHyprland.get_default().focusedMonitor.height / 4)); - - // Clear search on hide if not in math mode - this.connect("hide", () => mode.get() !== "math" && entry.set_text("")); - - this.connect("destroy", () => showResults.drop()); - } - - open(mode: Mode) { - this.mode.set(mode); - this.show(); - } -} diff --git a/modules/notifpopups.tsx b/modules/notifpopups.tsx deleted file mode 100644 index c3441a9..0000000 --- a/modules/notifpopups.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { GLib, register, timeout } from "astal"; -import { Astal, Gtk, Widget } from "astal/gtk3"; -import AstalNotifd from "gi://AstalNotifd"; -import { notifpopups as config } from "../config"; -import { desktopEntrySubs } from "../utils/icons"; -import { setupChildClickthrough } from "../utils/widgets"; - -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 todayDay = GLib.DateTime.new_now_local().get_day_of_year(); - 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"); - } else 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 = ({ icon }: { icon: string }) => { - if (GLib.file_test(icon, GLib.FileTest.EXISTS)) - return ( - <box - valign={Gtk.Align.START} - className="image" - css={` - background-image: url("${icon}"); - `} - /> - ); - if (Astal.Icon.lookup_icon(icon)) return <icon valign={Gtk.Align.START} className="image" icon={icon} />; - return null; -}; - -@register() -class NotifPopup extends Widget.Box { - readonly #revealer; - #destroyed = false; - - constructor({ notification }: { notification: AstalNotifd.Notification }) { - super(); - - this.#revealer = ( - <revealer revealChild transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} transitionDuration={150}> - <box className="wrapper"> - <box vertical className={`popup ${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={getTime(notification.time)!} - setup={self => - timeout(60000, () => !this.#destroyed && (self.label = getTime(notification.time)!)) - } - /> - </box> - <box hexpand className="separator" /> - <box className="content"> - {notification.image && <Image icon={notification.image} />} - <box vertical> - <label className="summary" xalign={0} label={notification.summary} truncate /> - <label className="body" xalign={0} label={notification.body} wrap useMarkup /> - </box> - </box> - <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]; - this.css = `margin-left: ${width}px; margin-right: -${width}px;`; - timeout(1, () => { - 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 (config.expire || 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()); - }); - } -} - -export default () => ( - <window - 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, NotifPopup>(); - self.hook(notifd, "notified", (self, id) => { - const notification = notifd.get_notification(id); - - const popup = (<NotifPopup notification={notification} />) as NotifPopup; - 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 - // Dismiss on middle click - onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()} - // Close on hover lost - onHoverLost={() => popup.destroyWithAnims()} - > - {popup} - </eventbox> - ); - - // Limit number of popups - if (config.maxPopups > 0 && self.children.length > config.maxPopups) - map.values().next().value?.destroyWithAnims(); - }); - self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims()); - - // Change input region to child region so can click through empty space - setupChildClickthrough(self); - }} - /> - </window> -); diff --git a/modules/osds.tsx b/modules/osds.tsx deleted file mode 100644 index b6e1333..0000000 --- a/modules/osds.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import { execAsync, register, timeout, Variable, type Time } from "astal"; -import { App, Astal, Gtk, Widget } from "astal/gtk3"; -import cairo from "cairo"; -import AstalWp from "gi://AstalWp"; -import Cairo from "gi://cairo"; -import Pango from "gi://Pango"; -import PangoCairo from "gi://PangoCairo"; -import { osds as config } from "../config"; -import Monitors, { type Monitor } from "../services/monitors"; -import { PopupWindow } from "../utils/widgets"; - -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={config[type].position} - margin={config[type].margin} - setup={self => { - let time: Time | null = null; - const hideAfterTimeout = () => { - time?.cancel(); - time = timeout(config[type].hideDelay, () => 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 === Astal.WindowAnchor.LEFT || - config[type].position === 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) { - 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={type.slice(0, 1).toUpperCase() + type.slice(1) + "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 - : 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, () => 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; -}; |