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 /src/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 'src/modules')
| -rw-r--r-- | src/modules/bar.tsx | 500 | ||||
| -rw-r--r-- | src/modules/launcher.tsx | 391 | ||||
| -rw-r--r-- | src/modules/notifications.tsx | 57 | ||||
| -rw-r--r-- | src/modules/notifpopups.tsx | 49 | ||||
| -rw-r--r-- | src/modules/osds.tsx | 326 |
5 files changed, 1323 insertions, 0 deletions
diff --git a/src/modules/bar.tsx b/src/modules/bar.tsx new file mode 100644 index 0000000..b56d94a --- /dev/null +++ b/src/modules/bar.tsx @@ -0,0 +1,500 @@ +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/src/modules/launcher.tsx b/src/modules/launcher.tsx new file mode 100644 index 0000000..2fc6eef --- /dev/null +++ b/src/modules/launcher.tsx @@ -0,0 +1,391 @@ +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 { setupCustomTooltip } from "../utils/widgets"; +import PopupWindow from "../widgets/popupwindow"; + +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/src/modules/notifications.tsx b/src/modules/notifications.tsx new file mode 100644 index 0000000..66188a1 --- /dev/null +++ b/src/modules/notifications.tsx @@ -0,0 +1,57 @@ +import { Gtk } from "astal/gtk3"; +import AstalNotifd from "gi://AstalNotifd"; +import { PopupWindow, setupChildClickthrough } from "../utils/widgets"; + +const List = () => ( + <box + vertical + valign={Gtk.Align.START} + className="list" + 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); + }} + /> +); + +export default class Notifications extends PopupWindow { + constructor() { + super({ + name: "notifications", + child: ( + <box> + <List /> + </box> + ), + }); + + setupChildClickthrough(self); + } +} diff --git a/src/modules/notifpopups.tsx b/src/modules/notifpopups.tsx new file mode 100644 index 0000000..5da3092 --- /dev/null +++ b/src/modules/notifpopups.tsx @@ -0,0 +1,49 @@ +import { Astal, Gtk } from "astal/gtk3"; +import AstalNotifd from "gi://AstalNotifd"; +import { notifpopups as config } from "../../config"; +import { setupChildClickthrough } from "../utils/widgets"; +import Notification from "../widgets/notification"; + +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, Notification>(); + self.hook(notifd, "notified", (self, id) => { + 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 + // 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/src/modules/osds.tsx b/src/modules/osds.tsx new file mode 100644 index 0000000..a87fcc3 --- /dev/null +++ b/src/modules/osds.tsx @@ -0,0 +1,326 @@ +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 "../widgets/popupwindow"; + +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; +}; |