Skip to content

Commit 8bc19a5

Browse files
committed
feat: update HID usage input to use grid of buttons
1 parent 0f965ed commit 8bc19a5

5 files changed

Lines changed: 362 additions & 182 deletions

File tree

src/behaviors/HidUsagePicker.tsx

Lines changed: 182 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,21 @@ import {
22
Button,
33
Checkbox,
44
CheckboxGroup,
5-
Collection,
65
ComboBox,
7-
Header,
86
Input,
9-
Key,
107
Label,
118
ListBox,
129
ListBoxItem,
1310
Popover,
14-
Section,
11+
Tab,
12+
TabList,
13+
TabPanel,
14+
Tabs,
1515
} from "react-aria-components";
16-
import {
17-
hid_usage_from_page_and_id,
18-
hid_usage_page_get_ids,
19-
} from "../hid-usages";
16+
import { hid_usage_page_get_ids, hid_usage_get_metadata } from "../hid-usages";
2017
import { useCallback, useMemo } from "react";
2118
import { ChevronDown } from "lucide-react";
19+
2220

2321
export interface HidUsagePage {
2422
id: number;
@@ -33,41 +31,6 @@ export interface HidUsagePickerProps {
3331
onValueChanged: (value?: number) => void;
3432
}
3533

36-
type UsageSectionProps = HidUsagePage;
37-
38-
const UsageSection = ({ id, min, max }: UsageSectionProps) => {
39-
const info = useMemo(() => hid_usage_page_get_ids(id), [id]);
40-
41-
let usages = useMemo(() => {
42-
let usages = info?.UsageIds || [];
43-
if (max || min) {
44-
usages = usages.filter(
45-
(i) =>
46-
(i.Id <= (max || Number.MAX_SAFE_INTEGER) && i.Id >= (min || 0)) ||
47-
(id === 7 && i.Id >= 0xe0 && i.Id <= 0xe7)
48-
);
49-
}
50-
51-
return usages;
52-
}, [id, min, max, info]);
53-
54-
return (
55-
<Section id={id}>
56-
<Header className="text-base-content/50">{info?.Name}</Header>
57-
<Collection items={usages}>
58-
{(i) => (
59-
<ListBoxItem
60-
className="rac-hover:bg-base-300 pl-3 relative rac-focus:bg-base-300 cursor-default select-none rac-selected:before:content-['✔'] before:absolute before:left-[0] before:top-[0]"
61-
id={hid_usage_from_page_and_id(id, i.Id)}
62-
>
63-
{i.Name}
64-
</ListBoxItem>
65-
)}
66-
</Collection>
67-
</Section>
68-
);
69-
};
70-
7134
enum Mods {
7235
LeftControl = 0x01,
7336
LeftShift = 0x02,
@@ -109,6 +72,156 @@ function mask_mods(value: number) {
10972
return value & ~(mods_to_flags(all_mods) << 24);
11073
}
11174

75+
const HidUsageGrid = ({
76+
value,
77+
onValueChanged,
78+
usagePages,
79+
}: HidUsagePickerProps) => {
80+
type Usage = {
81+
Name: string;
82+
Id: number;
83+
pageName: string;
84+
pageId: number;
85+
};
86+
const allUsages = useMemo(() => {
87+
return usagePages.flatMap((page) => {
88+
const pageInfo = hid_usage_page_get_ids(page.id);
89+
if (!pageInfo) {
90+
return [];
91+
}
92+
93+
let usages = pageInfo.UsageIds || [];
94+
if (page.max || page.min) {
95+
usages = usages.filter(
96+
(i) =>
97+
(i.Id <= (page.max || Number.MAX_SAFE_INTEGER) &&
98+
i.Id >= (page.min || 0)) ||
99+
(page.id === 7 && i.Id >= 0xe0 && i.Id <= 0xe7)
100+
);
101+
}
102+
103+
return usages.map((usage) => ({
104+
...usage,
105+
pageId: page.id,
106+
pageName: pageInfo.Name,
107+
}));
108+
});
109+
}, [usagePages]);
110+
111+
const selectedKey = value !== undefined ? mask_mods(value) : null;
112+
113+
const getButtonLabel = (usage: Usage) => {
114+
const metadata = hid_usage_get_metadata(usage.pageId, usage.Id);
115+
if (metadata?.med) {
116+
return metadata.med;
117+
}
118+
if (metadata?.short) {
119+
return metadata.short;
120+
}
121+
122+
if (usage.pageName === "Keyboard/Keypad") {
123+
const match = usage.Name.match(/^(Keyboard|Keypad) (\S+)/);
124+
if (match && match[2]) {
125+
return match[2];
126+
}
127+
}
128+
return usage.Name;
129+
};
130+
131+
const categorizedUsages = useMemo(() => {
132+
const categories: Record<string, Usage[]> = {};
133+
134+
for (const usage of allUsages) {
135+
const metadata = hid_usage_get_metadata(usage.pageId, usage.Id);
136+
const category = metadata?.category || "Other";
137+
138+
if (!categories[category]) {
139+
categories[category] = [];
140+
}
141+
categories[category].push(usage);
142+
}
143+
144+
return categories;
145+
}, [allUsages]);
146+
147+
const categoryOrder = ["Basic", "Numpad", "Apps/Media/Special", "ISO/JIS", "Other"];
148+
const sortedCategories = Object.keys(categorizedUsages).sort((a, b) => {
149+
const indexA = categoryOrder.indexOf(a);
150+
const indexB = categoryOrder.indexOf(b);
151+
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
152+
if (indexA !== -1) return -1;
153+
if (indexB !== -1) return 1;
154+
return a.localeCompare(b);
155+
});
156+
157+
return (
158+
<Tabs className="flex flex-col">
159+
<TabList className="flex border-b">
160+
{sortedCategories.map((category) => (
161+
<Tab key={category} id={category} className="px-4 py-2 cursor-default outline-none rac-selected:border-b-2 rac-selected:border-primary rac-focus-visible:ring-2 rac-focus-visible:ring-primary rounded-t-md">
162+
{category}
163+
</Tab>
164+
))}
165+
</TabList>
166+
{sortedCategories.map((category) => (
167+
<TabPanel
168+
key={category}
169+
id={category}
170+
className="min-h-56 max-h-56 overflow-y-auto flex flex-wrap justify-start content-start gap-1 p-1 border border-t-0 rounded-b rac-focus-visible:ring-2 rac-focus-visible:ring-primary"
171+
>
172+
{category === "Other" ? (
173+
<ComboBox
174+
className="w-full p-2"
175+
defaultItems={categorizedUsages[category]}
176+
selectedKey={selectedKey}
177+
onSelectionChange={(key) =>
178+
key !== null && onValueChanged(key as number)
179+
}
180+
>
181+
<Label className="text-sm">Search for another key</Label>
182+
<div className="relative flex items-center">
183+
<Input className="p-1 rounded-l" />
184+
<Button className="rounded-r bg-primary text-primary-content w-8 h-8 flex justify-center items-center">
185+
<ChevronDown className="size-4" />
186+
</Button>
187+
</div>
188+
<Popover className="w-[var(--trigger-width)] max-h-4 shadow-md text-base-content rounded border-base-content bg-base-100">
189+
<ListBox className="block max-h-[30vh] min-h-[unset] overflow-auto p-2">
190+
{(item: Usage) => {
191+
const usageValue = (item.pageId << 16) | item.Id;
192+
return (
193+
<ListBoxItem
194+
id={usageValue}
195+
textValue={item.Name}
196+
className="rac-hover:bg-base-300 pl-3 relative rac-focus:bg-base-300 cursor-default select-none rac-selected:before:content-['✔'] before:absolute before:left-[0] before:top-[0]"
197+
>
198+
{item.Name}
199+
</ListBoxItem>
200+
);
201+
}}
202+
</ListBox>
203+
</Popover>
204+
</ComboBox>
205+
) : (
206+
categorizedUsages[category].map((usage) => {
207+
const usageValue = (usage.pageId << 16) | usage.Id;
208+
return (
209+
<Button
210+
key={usageValue}
211+
onPress={() => onValueChanged(usageValue)}
212+
className={`w-16 h-16 p-1 rounded border text-center flex items-center justify-center ${selectedKey === usageValue ? "bg-primary text-primary-content" : "bg-base-200 hover:bg-base-300"}`}
213+
>
214+
{getButtonLabel(usage)}
215+
</Button>
216+
);
217+
})
218+
)}
219+
</TabPanel>
220+
))}
221+
</Tabs>
222+
);
223+
};
224+
112225
export const HidUsagePicker = ({
113226
label,
114227
value,
@@ -122,7 +235,7 @@ export const HidUsagePicker = ({
122235
}, [value]);
123236

124237
const selectionChanged = useCallback(
125-
(e: Key | null) => {
238+
(e: number | undefined) => {
126239
let value = typeof e == "number" ? e : undefined;
127240
if (value !== undefined) {
128241
let mod_flags = mods_to_flags(mods.map((m) => parseInt(m)));
@@ -148,45 +261,31 @@ export const HidUsagePicker = ({
148261
);
149262

150263
return (
151-
<div className="flex gap-2 relative">
152-
{label && <Label id="hid-usage-picker">{label}:</Label>}
153-
<ComboBox
154-
selectedKey={value ? mask_mods(value) : null}
155-
onSelectionChange={selectionChanged}
156-
aria-labelledby="hid-usage-picker"
157-
>
158-
<div className="flex">
159-
<Input className="p-1 rounded-l" />
160-
<Button className="rounded-r bg-primary text-primary-content w-8 h-8 flex justify-center items-center">
161-
<ChevronDown className="size-4" />
162-
</Button>
163-
</div>
164-
<Popover className="w-[var(--trigger-width)] max-h-4 shadow-md text-base-content rounded border-base-content bg-base-100">
165-
<ListBox
166-
items={usagePages}
167-
className="block max-h-[30vh] min-h-[unset] overflow-auto p-2"
168-
selectionMode="single"
169-
>
170-
{({ id, min, max }) => <UsageSection id={id} min={min} max={max} />}
171-
</ListBox>
172-
</Popover>
173-
</ComboBox>
174-
<CheckboxGroup
175-
aria-label="Implicit Modifiers"
176-
className="grid grid-flow-col gap-x-px auto-cols-[minmax(min-content,1fr)] content-stretch divide-x rounded-md"
177-
value={mods}
178-
onChange={modifiersChanged}
179-
>
180-
{all_mods.map((m) => (
181-
<Checkbox
182-
key={m}
183-
value={m.toLocaleString()}
184-
className="text-nowrap cursor-pointer grid px-2 content-center justify-center rac-selected:bg-primary border-base-100 bg-base-300 hover:bg-base-100 first:rounded-s-md last:rounded-e-md rac-selected:text-primary-content"
185-
>
186-
{mod_labels[m]}
187-
</Checkbox>
188-
))}
189-
</CheckboxGroup>
264+
<div className="flex flex-col gap-2 relative">
265+
<div className="flex gap-2 items-center">
266+
{label && <Label id="hid-usage-picker">{label}:</Label>}
267+
<CheckboxGroup
268+
aria-label="Implicit Modifiers"
269+
className="grid grid-flow-col gap-x-px auto-cols-[minmax(min-content,1fr)] content-stretch divide-x rounded-md"
270+
value={mods}
271+
onChange={modifiersChanged}
272+
>
273+
{all_mods.map((m) => (
274+
<Checkbox
275+
key={m}
276+
value={m.toLocaleString()}
277+
className="text-nowrap cursor-pointer grid px-2 content-center justify-center rac-selected:bg-primary border-base-100 bg-base-300 hover:bg-base-100 first:rounded-s-md last:rounded-e-md rac-selected:text-primary-content"
278+
>
279+
{mod_labels[m]}
280+
</Checkbox>
281+
))}
282+
</CheckboxGroup>
283+
</div>
284+
<HidUsageGrid
285+
value={value}
286+
onValueChanged={selectionChanged}
287+
usagePages={usagePages}
288+
/>
190289
</div>
191290
);
192291
};

0 commit comments

Comments
 (0)