Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 182 additions & 83 deletions src/behaviors/HidUsagePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,21 @@ import {
Button,
Checkbox,
CheckboxGroup,
Collection,
ComboBox,
Header,
Input,
Key,
Label,
ListBox,
ListBoxItem,
Popover,
Section,
Tab,
TabList,
TabPanel,
Tabs,
} from "react-aria-components";
import {
hid_usage_from_page_and_id,
hid_usage_page_get_ids,
} from "../hid-usages";
import { hid_usage_page_get_ids, hid_usage_get_metadata } from "../hid-usages";
import { useCallback, useMemo } from "react";
import { ChevronDown } from "lucide-react";


export interface HidUsagePage {
id: number;
Expand All @@ -33,41 +31,6 @@ export interface HidUsagePickerProps {
onValueChanged: (value?: number) => void;
}

type UsageSectionProps = HidUsagePage;

const UsageSection = ({ id, min, max }: UsageSectionProps) => {
const info = useMemo(() => hid_usage_page_get_ids(id), [id]);

let usages = useMemo(() => {
let usages = info?.UsageIds || [];
if (max || min) {
usages = usages.filter(
(i) =>
(i.Id <= (max || Number.MAX_SAFE_INTEGER) && i.Id >= (min || 0)) ||
(id === 7 && i.Id >= 0xe0 && i.Id <= 0xe7)
);
}

return usages;
}, [id, min, max, info]);

return (
<Section id={id}>
<Header className="text-base-content/50">{info?.Name}</Header>
<Collection items={usages}>
{(i) => (
<ListBoxItem
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]"
id={hid_usage_from_page_and_id(id, i.Id)}
>
{i.Name}
</ListBoxItem>
)}
</Collection>
</Section>
);
};

enum Mods {
LeftControl = 0x01,
LeftShift = 0x02,
Expand Down Expand Up @@ -109,6 +72,156 @@ function mask_mods(value: number) {
return value & ~(mods_to_flags(all_mods) << 24);
}

const HidUsageGrid = ({
value,
onValueChanged,
usagePages,
}: HidUsagePickerProps) => {
type Usage = {
Name: string;
Id: number;
pageName: string;
pageId: number;
};
const allUsages = useMemo(() => {
return usagePages.flatMap((page) => {
const pageInfo = hid_usage_page_get_ids(page.id);
if (!pageInfo) {
return [];
}

let usages = pageInfo.UsageIds || [];
if (page.max || page.min) {
usages = usages.filter(
(i) =>
(i.Id <= (page.max || Number.MAX_SAFE_INTEGER) &&
i.Id >= (page.min || 0)) ||
(page.id === 7 && i.Id >= 0xe0 && i.Id <= 0xe7)
);
}

return usages.map((usage) => ({
...usage,
pageId: page.id,
pageName: pageInfo.Name,
}));
});
}, [usagePages]);

const selectedKey = value !== undefined ? mask_mods(value) : null;

const getButtonLabel = (usage: Usage) => {
const metadata = hid_usage_get_metadata(usage.pageId, usage.Id);
if (metadata?.med) {
return metadata.med;
}
if (metadata?.short) {
return metadata.short;
}

if (usage.pageName === "Keyboard/Keypad") {
const match = usage.Name.match(/^(Keyboard|Keypad) (\S+)/);
if (match && match[2]) {
return match[2];
}
}
return usage.Name;
};

const categorizedUsages = useMemo(() => {
const categories: Record<string, Usage[]> = {};

for (const usage of allUsages) {
const metadata = hid_usage_get_metadata(usage.pageId, usage.Id);
const category = metadata?.category || "Other";

if (!categories[category]) {
categories[category] = [];
}
categories[category].push(usage);
}

return categories;
}, [allUsages]);

const categoryOrder = ["Basic", "Numpad", "Apps/Media/Special", "International", "Other"];
const sortedCategories = Object.keys(categorizedUsages).sort((a, b) => {
const indexA = categoryOrder.indexOf(a);
const indexB = categoryOrder.indexOf(b);
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
return a.localeCompare(b);
});

return (
<Tabs className="flex flex-col">
<TabList className="flex border-b">
{sortedCategories.map((category) => (
<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">
{category}
</Tab>
))}
</TabList>
{sortedCategories.map((category) => (
<TabPanel
key={category}
id={category}
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"
>
{category === "Other" ? (
<ComboBox
className="w-full p-2"
defaultItems={categorizedUsages[category]}
selectedKey={selectedKey}
onSelectionChange={(key) =>
key !== null && onValueChanged(key as number)
}
>
<Label className="text-sm">Search for another key</Label>
<div className="relative flex items-center">
<Input className="p-1 rounded-l" />
<Button className="rounded-r bg-primary text-primary-content w-8 h-8 flex justify-center items-center">
<ChevronDown className="size-4" />
</Button>
</div>
<Popover className="w-[var(--trigger-width)] max-h-4 shadow-md text-base-content rounded border-base-content bg-base-100">
<ListBox className="block max-h-[30vh] min-h-[unset] overflow-auto p-2">
{(item: Usage) => {
const usageValue = (item.pageId << 16) | item.Id;
return (
<ListBoxItem
id={usageValue}
textValue={item.Name}
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]"
>
{item.Name}
</ListBoxItem>
);
}}
</ListBox>
</Popover>
</ComboBox>
) : (
categorizedUsages[category].map((usage) => {
const usageValue = (usage.pageId << 16) | usage.Id;
return (
<Button
key={usageValue}
onPress={() => onValueChanged(usageValue)}
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"}`}
>
{getButtonLabel(usage)}
</Button>
);
})
)}
</TabPanel>
))}
</Tabs>
);
};

export const HidUsagePicker = ({
label,
value,
Expand All @@ -122,7 +235,7 @@ export const HidUsagePicker = ({
}, [value]);

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

return (
<div className="flex gap-2 relative">
{label && <Label id="hid-usage-picker">{label}:</Label>}
<ComboBox
selectedKey={value ? mask_mods(value) : null}
onSelectionChange={selectionChanged}
aria-labelledby="hid-usage-picker"
>
<div className="flex">
<Input className="p-1 rounded-l" />
<Button className="rounded-r bg-primary text-primary-content w-8 h-8 flex justify-center items-center">
<ChevronDown className="size-4" />
</Button>
</div>
<Popover className="w-[var(--trigger-width)] max-h-4 shadow-md text-base-content rounded border-base-content bg-base-100">
<ListBox
items={usagePages}
className="block max-h-[30vh] min-h-[unset] overflow-auto p-2"
selectionMode="single"
>
{({ id, min, max }) => <UsageSection id={id} min={min} max={max} />}
</ListBox>
</Popover>
</ComboBox>
<CheckboxGroup
aria-label="Implicit Modifiers"
className="grid grid-flow-col gap-x-px auto-cols-[minmax(min-content,1fr)] content-stretch divide-x rounded-md"
value={mods}
onChange={modifiersChanged}
>
{all_mods.map((m) => (
<Checkbox
key={m}
value={m.toLocaleString()}
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"
>
{mod_labels[m]}
</Checkbox>
))}
</CheckboxGroup>
<div className="flex flex-col gap-2 relative">
<div className="flex gap-2 items-center">
{label && <Label id="hid-usage-picker">{label}:</Label>}
<CheckboxGroup
aria-label="Implicit Modifiers"
className="grid grid-flow-col gap-x-px auto-cols-[minmax(min-content,1fr)] content-stretch divide-x rounded-md"
value={mods}
onChange={modifiersChanged}
>
{all_mods.map((m) => (
<Checkbox
key={m}
value={m.toLocaleString()}
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"
>
{mod_labels[m]}
</Checkbox>
))}
</CheckboxGroup>
</div>
<HidUsageGrid
value={value}
onValueChanged={selectionChanged}
usagePages={usagePages}
/>
</div>
);
};
Loading