app launcher
This commit is contained in:
parent
6a66214b8f
commit
e923d39cde
7 changed files with 412 additions and 8 deletions
16
app.tsx
16
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);
|
||||
|
||||
<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");
|
||||
},
|
||||
});
|
||||
|
|
|
|||
190
modules/launcher.tsx
Normal file
190
modules/launcher.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
80
scss/launcher.scss
Normal file
80
scss/launcher.scss
Normal file
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue