Skip to content

Commit def646b

Browse files
authored
feat(niri): add a command for browsing configured keybinds (#399)
* feat(niri): add a command for browsing configured keybinds niri exposes no IPC request for binds, so the command reads the config file itself: $NIRI_CONFIG, else $XDG_CONFIG_HOME/niri/config.kdl, else /etc/niri/config.kdl, following `include` nodes relative to the file that declares them.
1 parent 5882e1b commit def646b

15 files changed

Lines changed: 851 additions & 74 deletions

‎extensions/niri/package.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@
4949
"description": "Display the list of workspaces",
5050
"mode": "view"
5151
},
52+
{
53+
"name": "keybinds",
54+
"title": "Keybinds",
55+
"subtitle": "Show configured keybinds",
56+
"description": "Browse the keybinds declared in the niri config",
57+
"mode": "view"
58+
},
5259
{
5360
"name": "pick-color",
5461
"title": "Pick Color",
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { runNiriAction, showSuccess } from './utils';
1+
import { showSuccess } from './lib/feedback';
2+
import { runNiriAction } from './lib/niri';
23

34
export default async function ClearDynamicCastTarget() {
45
const success = await runNiriAction('clear-dynamic-cast-target');
56
if (success) {
67
showSuccess('Dynamic cast target cleared');
78
}
8-
}
9+
}

‎extensions/niri/src/keybinds.tsx‎

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { List, Icon, Color, ActionPanel, Action } from '@vicinae/api';
2+
import { basename } from 'path';
3+
import { useCallback, useEffect, useMemo, useState } from 'react';
4+
import { handleError } from './lib/feedback';
5+
import { collectKeybinds, getNiriConfigPath, loadNiriConfig } from './lib/keybinds/config';
6+
import type { Keybind } from './lib/types';
7+
8+
interface Category {
9+
title: string;
10+
icon: Icon;
11+
matches: (action: string) => boolean;
12+
}
13+
14+
const OTHER_CATEGORY: Category = {
15+
title: 'Compositor',
16+
icon: Icon.Cog,
17+
matches: () => true,
18+
};
19+
20+
const CATEGORIES: Category[] = [
21+
{
22+
title: 'Applications',
23+
icon: Icon.Terminal,
24+
matches: (action) => action === 'spawn' || action === 'spawn-sh',
25+
},
26+
{
27+
title: 'Screenshots',
28+
icon: Icon.Camera,
29+
matches: (action) => action.startsWith('screenshot'),
30+
},
31+
{
32+
title: 'Monitors',
33+
icon: Icon.Monitor,
34+
matches: (action) => action.includes('monitor') || action.includes('output'),
35+
},
36+
{
37+
title: 'Workspaces',
38+
icon: Icon.Layers,
39+
matches: (action) => action.includes('workspace'),
40+
},
41+
{
42+
title: 'Columns',
43+
icon: Icon.AppWindowGrid2x2,
44+
matches: (action) => action.includes('column'),
45+
},
46+
{
47+
title: 'Windows',
48+
icon: Icon.AppWindow,
49+
matches: (action) => action.includes('window'),
50+
},
51+
OTHER_CATEGORY,
52+
];
53+
54+
function categoryOf(action: string): Category {
55+
return CATEGORIES.find((category) => category.matches(action)) ?? OTHER_CATEGORY;
56+
}
57+
58+
function formatCombo(combo: string): string {
59+
return combo
60+
.split('+')
61+
.map((part) => part.replace(/_/g, ' '))
62+
.join(' + ');
63+
}
64+
65+
function formatArgs(args: string[]): string {
66+
return args.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(' ');
67+
}
68+
69+
function formatCommand(keybind: Keybind): string {
70+
return [keybind.action, formatArgs(keybind.args)].filter(Boolean).join(' ');
71+
}
72+
73+
function flagsOf(keybind: Keybind): { text: string; color: Color }[] {
74+
const flags: { text: string; color: Color }[] = [];
75+
76+
if (!keybind.repeat) flags.push({ text: 'No Repeat', color: Color.SecondaryText });
77+
if (keybind.cooldownMs !== null) {
78+
flags.push({ text: `${keybind.cooldownMs}ms Cooldown`, color: Color.Blue });
79+
}
80+
if (keybind.allowWhenLocked) flags.push({ text: 'Works When Locked', color: Color.Orange });
81+
if (!keybind.allowInhibiting) flags.push({ text: 'Never Inhibited', color: Color.Purple });
82+
if (keybind.hiddenFromOverlay) {
83+
flags.push({ text: 'Hidden From Overlay', color: Color.SecondaryText });
84+
}
85+
86+
return flags;
87+
}
88+
89+
export default function Keybinds() {
90+
const [keybinds, setKeybinds] = useState<Keybind[]>([]);
91+
const [loading, setLoading] = useState(true);
92+
const [error, setError] = useState<string | null>(null);
93+
const [showDetail, setShowDetail] = useState(false);
94+
95+
const load = useCallback(async () => {
96+
setLoading(true);
97+
try {
98+
setKeybinds(collectKeybinds(await loadNiriConfig()));
99+
setError(null);
100+
} catch (caught) {
101+
setKeybinds([]);
102+
setError(caught instanceof Error ? caught.message : 'Unknown error');
103+
handleError('Failed to read the niri config', caught);
104+
} finally {
105+
setLoading(false);
106+
}
107+
}, []);
108+
109+
useEffect(() => {
110+
load();
111+
}, [load]);
112+
113+
const sections = useMemo(
114+
() =>
115+
CATEGORIES.map((category) => ({
116+
category,
117+
keybinds: keybinds.filter((keybind) => categoryOf(keybind.action) === category),
118+
})).filter((section) => section.keybinds.length > 0),
119+
[keybinds]
120+
);
121+
122+
const reloadAction = (
123+
<Action
124+
title="Reload Config"
125+
icon={Icon.ArrowClockwise}
126+
onAction={() => load()}
127+
shortcut={{ modifiers: ['cmd'], key: 'r' }}
128+
/>
129+
);
130+
131+
const emptyView = loading ? null : (
132+
<List.EmptyView
133+
icon={error === null ? Icon.Keyboard : Icon.Warning}
134+
title={error === null ? 'No Keybinds Found' : 'Cannot Read the Niri Config'}
135+
description={error ?? `No binds are declared in ${getNiriConfigPath()}.`}
136+
actions={<ActionPanel>{reloadAction}</ActionPanel>}
137+
/>
138+
);
139+
140+
return (
141+
<List
142+
isLoading={loading}
143+
isShowingDetail={showDetail && keybinds.length > 0}
144+
searchBarPlaceholder="Search keys, actions and arguments"
145+
>
146+
{keybinds.length === 0
147+
? emptyView
148+
: sections.map((section) => (
149+
<List.Section
150+
key={section.category.title}
151+
title={section.category.title}
152+
subtitle={`${section.keybinds.length}`}
153+
>
154+
{section.keybinds.map((keybind, index) => {
155+
const keys = formatCombo(keybind.combo);
156+
const command = formatCommand(keybind);
157+
const flags = flagsOf(keybind);
158+
159+
return (
160+
<List.Item
161+
key={`${keybind.source}:${keybind.line}:${index}`}
162+
title={keys}
163+
subtitle={showDetail ? '' : (keybind.overlayTitle ?? command)}
164+
icon={section.category.icon}
165+
keywords={[
166+
keybind.combo,
167+
keybind.action,
168+
keybind.overlayTitle ?? '',
169+
...keybind.args,
170+
].filter(Boolean)}
171+
accessories={
172+
showDetail
173+
? []
174+
: flags.map((flag) => ({
175+
tag: { value: flag.text, color: flag.color },
176+
}))
177+
}
178+
actions={
179+
<ActionPanel>
180+
<Action
181+
title={showDetail ? 'Hide Details' : 'Show Details'}
182+
icon={Icon.Text}
183+
onAction={() => setShowDetail((shown) => !shown)}
184+
shortcut={{ modifiers: ['cmd'], key: 'd' }}
185+
/>
186+
<Action.CopyToClipboard
187+
title="Copy Keys"
188+
icon={Icon.Keyboard}
189+
content={keys}
190+
/>
191+
<Action.CopyToClipboard
192+
title="Copy Command"
193+
icon={Icon.Terminal}
194+
content={command}
195+
/>
196+
<Action.CopyToClipboard
197+
title="Copy Bind"
198+
icon={Icon.CopyClipboard}
199+
content={keybind.text}
200+
/>
201+
<Action.CopyToClipboard
202+
title="Copy Config Path"
203+
icon={Icon.Finder}
204+
content={keybind.source}
205+
/>
206+
{reloadAction}
207+
</ActionPanel>
208+
}
209+
detail={
210+
<List.Item.Detail
211+
metadata={
212+
<List.Item.Detail.Metadata>
213+
<List.Item.Detail.Metadata.Label title="Keys" text={keys} />
214+
<List.Item.Detail.Metadata.Label
215+
title="Action"
216+
text={keybind.action || 'None'}
217+
/>
218+
{keybind.args.length > 0 && (
219+
<List.Item.Detail.Metadata.Label
220+
title="Arguments"
221+
text={formatArgs(keybind.args)}
222+
/>
223+
)}
224+
{keybind.overlayTitle !== null && (
225+
<List.Item.Detail.Metadata.Label
226+
title="Overlay Title"
227+
text={keybind.overlayTitle}
228+
/>
229+
)}
230+
<List.Item.Detail.Metadata.Separator />
231+
{flags.length > 0 && (
232+
<List.Item.Detail.Metadata.TagList title="Flags">
233+
{flags.map((flag) => (
234+
<List.Item.Detail.Metadata.TagList.Item
235+
key={flag.text}
236+
text={flag.text}
237+
color={flag.color}
238+
/>
239+
))}
240+
</List.Item.Detail.Metadata.TagList>
241+
)}
242+
<List.Item.Detail.Metadata.Label
243+
title="Declared In"
244+
text={`${basename(keybind.source)}:${keybind.line}`}
245+
/>
246+
</List.Item.Detail.Metadata>
247+
}
248+
/>
249+
}
250+
/>
251+
);
252+
})}
253+
</List.Section>
254+
))}
255+
</List>
256+
);
257+
}

‎extensions/niri/src/layers.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { List, Icon, ActionPanel, Action } from '@vicinae/api';
2-
import { useNiriArrayData } from './hooks';
3-
import type { Layer } from './types';
2+
import { useNiriArrayData } from './lib/hooks';
3+
import type { Layer } from './lib/types';
44
import { NiriList } from './components/NiriList';
55

66
export default function Layers() {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { showToast, Toast } from '@vicinae/api';
2+
3+
export function handleError(title: string, error: unknown) {
4+
showToast({
5+
style: Toast.Style.Failure,
6+
title,
7+
message: error instanceof Error ? error.message : 'Unknown error',
8+
});
9+
}
10+
11+
export function showSuccess(title: string, message?: string) {
12+
showToast({
13+
style: Toast.Style.Success,
14+
title,
15+
...(message && { message }),
16+
});
17+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useState } from 'react';
2-
import { execAsync, handleError } from './utils';
2+
import { handleError } from './feedback';
3+
import { execAsync } from './niri';
34

45
function useNiriData<T>(command: string, errorMessage: string): [T[] | Record<string, T>, boolean] {
56
const [data, setData] = useState<T[] | Record<string, T>>([]);

0 commit comments

Comments
 (0)