Skip to content

Commit 31c2063

Browse files
author
Your Name
committed
feat(menu): add app launcher
1 parent 210d5e2 commit 31c2063

File tree

2 files changed

+172
-1
lines changed

2 files changed

+172
-1
lines changed

src/components/menus/apps/index.tsx

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import DropdownMenu from '../shared/dropdown/index.js';
2+
import options from 'src/options.js';
3+
import Variable from 'astal/variable';
4+
import { bind } from 'astal/binding';
5+
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
6+
import { App, Gtk } from 'astal/gtk3';
7+
import Separator from 'src/components/shared/Separator.js';
8+
import AstalApps from 'gi://AstalApps?version=0.1'
9+
import { icon, launchApp } from 'src/lib/utils.js';
10+
import { Entry, EntryProps, Scrollable } from 'astal/gtk3/widget';
11+
12+
import PopupWindow from '../shared/popup/index.js';
13+
14+
const apps = new AstalApps.Apps({
15+
nameMultiplier: 2,
16+
keywordsMultiplier: 2,
17+
executableMultiplier: 1,
18+
entryMultiplier: 0.5,
19+
categoriesMultiplier: 0.5,
20+
});
21+
22+
interface ApplicationItemProps {
23+
app: AstalApps.Application;
24+
onLaunched?: () => void;
25+
}
26+
27+
const ApplicationItem = ({ app, onLaunched }: ApplicationItemProps): JSX.Element => {
28+
return (
29+
<button className="notification-card" halign={Gtk.Align.FILL} valign={Gtk.Align.START} onClick={() => { launchApp(app); onLaunched?.() }}>
30+
<box spacing={5}>
31+
<icon className="notification-card-image icon" margin={5} halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} vexpand={false} icon={icon(app.iconName)} />
32+
<label halign={Gtk.Align.START} valign={Gtk.Align.CENTER} label={app.name} hexpand vexpand truncate wrap />
33+
</box>
34+
</button>
35+
);
36+
}
37+
38+
39+
function useRef<T>() {
40+
let ref: T | null = null;
41+
42+
return {
43+
set: (r: T) => { ref = r },
44+
get: () => ref
45+
}
46+
}
47+
48+
function useApplicationsFilter() {
49+
const filter = Variable('')
50+
51+
const list = bind(filter).as((f) => {
52+
// show all apps by default
53+
if (!f) return apps.get_list()
54+
// if the filter is a single character, show all apps that start with that character
55+
if (f.length === 1) return apps.get_list().filter((app) => app.name.toLowerCase().startsWith(f.toLowerCase()))
56+
// otherwise, do a fuzzy search (this method wont filter with a single character)
57+
return apps.fuzzy_query(f)
58+
})
59+
60+
return { filter, list }
61+
}
62+
63+
interface ApplicationLauncherProps {
64+
visible: Variable<boolean>;
65+
onLaunched?: () => void;
66+
}
67+
68+
const SearchBar = ({ value, setup, onActivate }: { value?: Variable<string>; setup?: (self: Entry) => void; onActivate?: EntryProps['onActivate'] }) => {
69+
return (
70+
<box className="notification-menu-controls" expand={false} vertical={false}>
71+
<box className="menu-label-container notifications" halign={Gtk.Align.START} valign={Gtk.Align.CENTER} expand>
72+
<entry onActivate={onActivate} setup={setup} className="menu-label notifications" placeholderText="Filter" text={value && bind(value)} onChanged={value && ((entry) => value.set(entry.text))} />
73+
</box>
74+
<box halign={Gtk.Align.END} valign={Gtk.Align.CENTER} expand={false}>
75+
<Separator
76+
halign={Gtk.Align.CENTER}
77+
vexpand={true}
78+
className="menu-separator notification-controls"
79+
/>
80+
<label className="clear-notifications-label txt-icon" label="" />
81+
</box>
82+
</box>
83+
)
84+
}
85+
86+
87+
const ApplicationLauncher = ({ visible, onLaunched }: ApplicationLauncherProps): JSX.Element => {
88+
const entry = useRef<Entry>()
89+
const scrollable = useRef<Scrollable>()
90+
91+
const { filter, list } = useApplicationsFilter()
92+
93+
const onFilterReturn = () => {
94+
const first = list.get()[0]
95+
if (!first) return;
96+
launchApp(first)
97+
onLaunched?.()
98+
}
99+
100+
// focus the entry when the menu is shown
101+
const onShow = () => {
102+
entry.get()?.grab_focus()
103+
}
104+
visible.subscribe(v => v && onShow());
105+
106+
const onHide = () => {
107+
// clear the filter when the menu is hidden
108+
filter.set('')
109+
// TODO: reset scroll position
110+
}
111+
visible.subscribe(v => !v && onHide);
112+
113+
return (
114+
<box className="notification-menu-content" css="padding: 1px; margin: -1px;" hexpand vexpand>
115+
<box className="notification-card-container menu" hexpand vexpand vertical>
116+
<SearchBar value={filter} setup={entry.set} onActivate={onFilterReturn} />
117+
<scrollable vscroll={Gtk.PolicyType.AUTOMATIC} setup={scrollable.set}>
118+
<box className="menu-content-container notifications" halign={Gtk.Align.FILL} valign={Gtk.Align.START} spacing={0} vexpand vertical>
119+
{list.as(apps => apps.map((app) => <ApplicationItem app={app} onLaunched={onLaunched} />))}
120+
</box>
121+
</scrollable>
122+
</box>
123+
</box>
124+
)
125+
}
126+
127+
/**
128+
* track the visibility of a window
129+
* this is necessary because menu are realized at startup and never destroyed
130+
* making onRealize and onDestroy unreliable for lifecycle management
131+
*/
132+
function useWindowVisibility(windowName: string) {
133+
const visible = Variable(!!App.get_window(windowName)?.visible);
134+
135+
App.connect('window-toggled', (_, window) => {
136+
if (window.name !== windowName) return;
137+
visible.set(window.visible);
138+
})
139+
140+
return visible;
141+
}
142+
143+
export const ApplicationsDropdownMenu = (): JSX.Element => {
144+
const visible = useWindowVisibility('applicationsdropdownmenu');
145+
146+
const close = () => App.get_window('applicationsdropdownmenu')?.set_visible(false);
147+
148+
return (
149+
<DropdownMenu
150+
name={'applicationsdropdownmenu'}
151+
transition={bind(options.menus.transition).as((transition) => RevealerTransitionMap[transition])}
152+
>
153+
<ApplicationLauncher visible={visible} onLaunched={close} />
154+
</DropdownMenu>
155+
);
156+
};
157+
158+
159+
export const ApplicationsMenu = (): JSX.Element => {
160+
const visible = useWindowVisibility('applicationsmenu');
161+
162+
const close = () => App.get_window('applicationsmenu')?.set_visible(false);
163+
164+
return (
165+
<PopupWindow name={'applicationsmenu'} transition={bind(options.menus.transition).as((transition) => RevealerTransitionMap[transition])}>
166+
<ApplicationLauncher visible={visible} onLaunched={close} />
167+
</PopupWindow>
168+
)
169+
}

src/components/menus/exports.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import CalendarMenu from './calendar/index.js';
99
import EnergyMenu from './energy/index.js';
1010
import DashboardMenu from './dashboard/index.js';
1111
import PowerDropdown from './powerDropdown/index.js';
12+
import {ApplicationsDropdownMenu, ApplicationsMenu} from './apps/index';
1213

1314
export const DropdownMenus = [
1415
AudioMenu,
@@ -20,6 +21,7 @@ export const DropdownMenus = [
2021
EnergyMenu,
2122
DashboardMenu,
2223
PowerDropdown,
24+
ApplicationsDropdownMenu
2325
];
2426

25-
export const StandardWindows = [PowerMenu, Verification];
27+
export const StandardWindows = [PowerMenu, Verification, ApplicationsMenu];

0 commit comments

Comments
 (0)