diff options
| -rw-r--r-- | app.tsx | 16 | ||||
| -rw-r--r-- | modules/launcher.tsx | 190 | ||||
| -rw-r--r-- | scss/launcher.scss | 80 | ||||
| -rw-r--r-- | style.scss | 1 | ||||
| -rw-r--r-- | utils/icons.ts | 9 | ||||
| -rw-r--r-- | utils/system.ts | 11 | ||||
| -rw-r--r-- | utils/widgets.tsx | 113 |
7 files changed, 412 insertions, 8 deletions
@@ -2,7 +2,9 @@ import { execAsync, GLib, writeFileAsync } from "astal"; import { App } from "astal/gtk3"; import AstalHyprland from "gi://AstalHyprland"; import Bar from "./modules/bar"; +import Launcher from "./modules/launcher"; import NotifPopups from "./modules/notifpopups"; +import { PopupWindow } from "./utils/widgets"; const loadStyleAsync = async () => { if (!GLib.file_test(`${SRC}/scss/scheme/_index.scss`, GLib.FileTest.EXISTS)) @@ -17,15 +19,25 @@ App.start({ main() { loadStyleAsync().catch(console.error); + <Launcher />; <NotifPopups />; AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />); console.log("Caelestia started"); }, requestHandler(request, res) { + let log = true; + if (request === "reload css") loadStyleAsync().catch(console.error); - else return res("Unknown command: " + request); - console.log(`Request handled: ${request}`); + else if (request.startsWith("toggle")) { + const window = App.get_window(request.slice(7)); + if (window instanceof PopupWindow) window.toggle(); + else App.toggle_window(request.slice(7)); + + log = false; + } else return res("Unknown command: " + request); + + if (log) console.log(`Request handled: ${request}`); res("OK"); }, }); diff --git a/modules/launcher.tsx b/modules/launcher.tsx new file mode 100644 index 0000000..4cde7c4 --- /dev/null +++ b/modules/launcher.tsx @@ -0,0 +1,190 @@ +import { bind, Gio, timeout, Variable } from "astal"; +import { Astal, Gtk, Widget } from "astal/gtk3"; +import type AstalApps from "gi://AstalApps"; +import AstalHyprland from "gi://AstalHyprland"; +import { Apps } from "../services/apps"; +import { getAppCategoryIcon } from "../utils/icons"; +import { launch } from "../utils/system"; +import { PopupWindow, setupCustomTooltip, TransitionType } from "../utils/widgets"; + +const maxSearchResults = 15; + +const browser = [ + "firefox", + "waterfox", + "google-chrome", + "chromium", + "brave-browser", + "vivaldi-stable", + "vivaldi-snapshot", +]; +const terminal = ["foot", "alacritty", "kitty", "wezterm"]; +const files = ["thunar", "nemo", "nautilus"]; +const ide = ["codium", "code", "clion", "intellij-idea-ultimate-edition"]; +const music = ["spotify-adblock", "spotify", "audacious", "elisa"]; + +const launchAndClose = (self: JSX.Element, astalApp: AstalApps.Application) => { + const toplevel = self.get_toplevel(); + if (toplevel instanceof Widget.Window) toplevel.hide(); + launch(astalApp); +}; + +const PinnedApp = ({ names }: { 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="app" + 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 className="pinned"> + <PinnedApp names={browser} /> + <PinnedApp names={terminal} /> + <PinnedApp names={files} /> + <PinnedApp names={ide} /> + <PinnedApp names={music} /> + </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 = ({ app }: { app: AstalApps.Application }) => ( + <button className="app" cursor="pointer" onClicked={self => launchAndClose(self, app)}> + <box> + {Astal.Icon.lookup_icon(app.iconName) ? ( + <icon className="icon" icon={app.iconName} /> + ) : ( + <label className="icon" label={getAppCategoryIcon(app)} /> + )} + <label xalign={0} label={app.name} /> + </box> + </button> +); + +const Results = ({ entry }: { entry: Widget.Entry }) => { + 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="apps_outage" /> + <label label="No results" /> + </box> + <box + vertical + name="list" + setup={self => { + let apps: AstalApps.Application[] = []; + self.hook(entry, "activate", () => { + if (entry.text && apps[0]) launchAndClose(self, apps[0]); + }); + self.hook(entry, "changed", () => { + if (!entry.text) return; + self.foreach(ch => ch.destroy()); + apps = Apps.fuzzy_query(entry.text); + empty.set(apps.length === 0); + if (apps.length > maxSearchResults) apps.length = maxSearchResults; + for (const app of apps) self.add(<Result app={app} />); + }); + }} + /> + </stack> + ); +}; + +const Launcher = ({ entry }: { entry: Widget.Entry }) => ( + <box + vertical + className="launcher" + css={bind(AstalHyprland.get_default(), "focusedMonitor").as(m => `margin-top: ${m.height / 4}px;`)} + > + <box className="search-bar"> + <label className="icon" label="search" /> + <SearchEntry entry={entry} /> + <label className="icon" label="apps" /> + </box> + <revealer + revealChild={bind(entry, "textLength").as(t => t === 0)} + transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} + transitionDuration={150} + > + <PinnedApps /> + </revealer> + <revealer + revealChild={bind(entry, "textLength").as(t => t > 0)} + transitionType={Gtk.RevealerTransitionType.SLIDE_UP} + transitionDuration={150} + > + <Results entry={entry} /> + </revealer> + </box> +); + +export default () => { + const entry = (<entry name="entry" />) as Widget.Entry; + + return ( + <PopupWindow + name="launcher" + keymode={Astal.Keymode.EXCLUSIVE} + exclusivity={Astal.Exclusivity.IGNORE} + 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; + } + }} + // Clear entry text on hide + setup={self => self.connect("hide", () => entry.set_text(""))} + transitionType={TransitionType.SLIDE_DOWN} + halign={Gtk.Align.CENTER} + valign={Gtk.Align.START} + > + <Launcher entry={entry} /> + </PopupWindow> + ); +}; diff --git a/scss/launcher.scss b/scss/launcher.scss new file mode 100644 index 0000000..eddbc88 --- /dev/null +++ b/scss/launcher.scss @@ -0,0 +1,80 @@ +@use "scheme"; +@use "lib"; +@use "font"; + +.launcher { + @include lib.rounded(10); + @include lib.border(scheme.$overlay0, 0.2); + @include lib.shadow; + @include lib.element-decel; + @include font.main; + + background-color: scheme.$mantle; + color: scheme.$text; + padding: lib.s(10) lib.s(14); + + .search-bar { + @include lib.spacing; + + margin-bottom: lib.s(5); + font-size: lib.s(16); + + .icon { + font-size: lib.s(18); + color: scheme.$sapphire; + } + + .placeholder { + color: scheme.$subtext0; + } + } + + .app { + @include lib.element-decel; + + padding: lib.s(5) lib.s(10); + + &:hover, + &:focus { + background-color: scheme.$surface0; + } + + &:active { + background-color: scheme.$surface1; + } + } + + .pinned { + .app { + @include lib.rounded(5); + + font-size: lib.s(64); + } + } + + .results { + .empty { + @include lib.spacing; + + color: scheme.$subtext0; + + .icon { + font-size: lib.s(32); + } + } + + .app { + @include lib.rounded(10); + + font-size: lib.s(18); + + & > * { + @include lib.spacing; + } + + .icon { + font-size: lib.s(32); + } + } + } +} @@ -4,6 +4,7 @@ // Modules @use "scss/bar"; @use "scss/notifpopups"; +@use "scss/launcher"; * { all: unset; // Remove GTK theme styles diff --git a/utils/icons.ts b/utils/icons.ts index dff47f3..f12aee0 100644 --- a/utils/icons.ts +++ b/utils/icons.ts @@ -1,4 +1,5 @@ import { Gio } from "astal"; +import type AstalApps from "gi://AstalApps"; import { Apps } from "../services/apps"; // Code points from https://www.github.com/lukas-w/font-logos @@ -73,10 +74,12 @@ const categoryIcons: Record<string, string> = { System: "host", }; -export const getAppCategoryIcon = (name: string) => { +export const getAppCategoryIcon = (nameOrApp: string | AstalApps.Application) => { const categories = - Gio.DesktopAppInfo.new(`${name}.desktop`)?.get_categories()?.split(";") ?? - Apps.fuzzy_query(name)[0]?.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/utils/system.ts b/utils/system.ts index 9a328d5..99e9d7c 100644 --- a/utils/system.ts +++ b/utils/system.ts @@ -1,4 +1,5 @@ -import { exec, GLib } from "astal"; +import { exec, execAsync, GLib } from "astal"; +import type AstalApps from "gi://AstalApps"; import { osIcons } from "./icons"; export const inPath = (bin: string) => { @@ -10,6 +11,14 @@ export const inPath = (bin: string) => { return true; }; +export const launch = (app: AstalApps.Application) => { + execAsync(["uwsm", "app", "--", app.entry]).catch(() => { + app.frequency--; // Decrement frequency cause launch also increments it + app.launch(); + }); + app.frequency++; +}; + export const osId = GLib.get_os_info("ID") ?? "unknown"; export const osIdLike = GLib.get_os_info("ID_LIKE"); export const osIcon = String.fromCodePoint( diff --git a/utils/widgets.tsx b/utils/widgets.tsx index 7c40184..a0a96cb 100644 --- a/utils/widgets.tsx +++ b/utils/widgets.tsx @@ -1,5 +1,5 @@ -import { Binding } from "astal"; -import { Astal, type Widget } from "astal/gtk3"; +import { Binding, property, register, timeout } from "astal"; +import { App, Astal, Gdk, Gtk, Widget } from "astal/gtk3"; import AstalHyprland from "gi://AstalHyprland"; export const setupCustomTooltip = (self: any, text: string | Binding<string>) => { @@ -46,3 +46,112 @@ export const setupCustomTooltip = (self: any, text: string | Binding<string>) => export const setupChildClickthrough = (self: any) => self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes()); + +export enum TransitionType { + FADE = "", + SLIDE_DOWN = "margin-top: -${height}px; margin-bottom: ${height}px;", + SLIDE_UP = "margin-top: ${height}px; margin-bottom: -${height}px;", + SLIDE_RIGHT = "margin-left: -${width}px; margin-right: ${width}px;", + SLIDE_LEFT = "margin-left: ${width}px; margin-right: -${width}px;", +} + +@register() +export class PopupWindow extends Widget.Window { + readonly transitionType: TransitionType; + readonly transitionDuration: number; + readonly transitionAmount: number; + + readonly #content: Widget.Box; + #visible: boolean = false; + + @property(Boolean) + get realVisible() { + return this.#visible; + } + + set realVisible(v: boolean) { + if (v) this.show(); + else this.hide(); + } + + constructor( + props: Widget.WindowProps & { + transitionType?: TransitionType; + transitionDuration?: number; + transitionAmount?: number; + } + ) { + const { + onKeyPressEvent, + clickThrough, + child, + halign = Gtk.Align.START, + valign = Gtk.Align.START, + transitionType = TransitionType.FADE, + transitionDuration = 200, + transitionAmount = 0.2, + ...sProps + } = props; + + sProps.visible = false; + sProps.application = App; + sProps.namespace = `caelestia-${props.name}`; + sProps.anchor = + Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT; + sProps.onKeyPressEvent = (self, event) => { + // Close window on escape + if (event.get_keyval()[1] === Gdk.KEY_Escape) self.hide(); + + return onKeyPressEvent?.(self, event); + }; + super(sProps); + + this.transitionType = transitionType; + this.transitionDuration = transitionDuration; + this.transitionAmount = transitionAmount; + + // Wrapper box for animations + this.#content = ( + <box halign={halign} valign={valign} className={`${props.name}-wrapper`}> + {clickThrough ? <eventbox>{child}</eventbox> : child} + </box> + ) as Widget.Box; + this.#content.css = this.#getTransitionCss(false); + this.add(this.#content); + + if (clickThrough) setupChildClickthrough(this); + } + + #getTransitionCss(visible: boolean) { + return ( + `transition-duration: ${this.transitionDuration}ms;` + + (visible + ? "opacity: 1;" + this.transitionType.replaceAll("${width}", "0").replaceAll("${height}", "0") + : "opacity: 0;" + + this.transitionType + .replaceAll("${width}", String(this.#content.get_preferred_width()[1] * this.transitionAmount)) + .replaceAll("${height}", String(this.#content.get_preferred_height()[1] * this.transitionAmount))) + ); + } + + show() { + this.#visible = true; + this.notify("real-visible"); + + super.show(); + this.#content.css = this.#getTransitionCss(true); + } + + hide() { + this.#visible = false; + this.notify("real-visible"); + + this.#content.css = this.#getTransitionCss(false); + timeout(this.transitionDuration, () => !this.#visible && super.hide()); + } + + toggle() { + if (this.#visible) this.hide(); + else this.show(); + } +} |