From e923d39cdeed4b42e747afc01b3420d6d89af6e6 Mon Sep 17 00:00:00 2001
From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
Date: Tue, 14 Jan 2025 00:22:59 +1100
Subject: app launcher
---
app.tsx | 16 ++++-
modules/launcher.tsx | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++
scss/launcher.scss | 80 ++++++++++++++++++++++
style.scss | 1 +
utils/icons.ts | 9 ++-
utils/system.ts | 11 ++-
utils/widgets.tsx | 113 +++++++++++++++++++++++++++++-
7 files changed, 412 insertions(+), 8 deletions(-)
create mode 100644 modules/launcher.tsx
create mode 100644 scss/launcher.scss
diff --git a/app.tsx b/app.tsx
index 27ca512..db766c8 100644
--- a/app.tsx
+++ b/app.tsx
@@ -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);
+ ;
;
AstalHyprland.get_default().monitors.forEach(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 ? (
+
+ ) : null;
+};
+
+const PinnedApps = () => (
+
+
+
+
+
+
+
+);
+
+const SearchEntry = ({ entry }: { entry: Widget.Entry }) => (
+
+ 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"))
+ )
+ }
+ >
+
+ {entry}
+
+);
+
+const Result = ({ app }: { app: AstalApps.Application }) => (
+
+);
+
+const Results = ({ entry }: { entry: Widget.Entry }) => {
+ const empty = Variable(true);
+ return (
+ (t ? "empty" : "list"))}
+ >
+
+
+
+
+ {
+ 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();
+ });
+ }}
+ />
+
+ );
+};
+
+const Launcher = ({ entry }: { entry: Widget.Entry }) => (
+ `margin-top: ${m.height / 4}px;`)}
+ >
+
+
+
+
+
+ t === 0)}
+ transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
+ transitionDuration={150}
+ >
+
+
+ t > 0)}
+ transitionType={Gtk.RevealerTransitionType.SLIDE_UP}
+ transitionDuration={150}
+ >
+
+
+
+);
+
+export default () => {
+ const entry = () as Widget.Entry;
+
+ return (
+ {
+ 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}
+ >
+
+
+ );
+};
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);
+ }
+ }
+ }
+}
diff --git a/style.scss b/style.scss
index 60c0ccf..3ccf193 100644
--- a/style.scss
+++ b/style.scss
@@ -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 = {
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) => {
@@ -46,3 +46,112 @@ export const setupCustomTooltip = (self: any, text: string | Binding) =>
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 = (
+
+ {clickThrough ? {child} : child}
+
+ ) 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();
+ }
+}
--
cgit v1.2.3-freya