1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
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>
);
};
|