Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for mouse, keyboard and controller input #75

Merged
merged 35 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
77e7ce2
Add legacy mode switch
fwcd Feb 28, 2025
ee68071
Store input config
fwcd Feb 28, 2025
ab01d27
Add thumb icons to the switches
fwcd Feb 28, 2025
4d39036
Bump nighthouse to 4.0.0
fwcd Feb 28, 2025
fbee0da
Add InputState for tracking the last events
fwcd Feb 28, 2025
f4fc260
Disable mouse switch in legacy mode
fwcd Feb 28, 2025
6fc1b55
Add support for keyboard events
fwcd Feb 28, 2025
021719f
Ignore key events on input fields
fwcd Feb 28, 2025
ea10d7a
Filter text inputs
fwcd Feb 28, 2025
ebd78c0
Store the last event sent
fwcd Feb 28, 2025
e93eba7
Add BooleanChip
fwcd Feb 28, 2025
37e5825
Visualize keyboard input
fwcd Feb 28, 2025
0549c2d
Generalize MonitorInspectorTable to ObjectInspectorTable
fwcd Feb 28, 2025
82e43c0
Add Names type
fwcd Feb 28, 2025
251c1dc
Generalize to ObjectInspectorValue
fwcd Feb 28, 2025
be4391a
Use object inspector in display inspector
fwcd Feb 28, 2025
b30178f
Remove the object inspector wrapper
fwcd Feb 28, 2025
75c2fd4
Display bools as yes/no in the UI
fwcd Feb 28, 2025
6034913
Use a checkmark to visualize booleans in the UI
fwcd Feb 28, 2025
f535d2d
Implement MouseEventView
fwcd Feb 28, 2025
5903474
Emit mouse events
fwcd Feb 28, 2025
2aff50f
Visualize mouse events properly
fwcd Feb 28, 2025
633fc80
Disable mouse events by default
fwcd Feb 28, 2025
302cc44
Add AnimatedPresence wrapper
fwcd Feb 28, 2025
3dae269
Visualize legacy controller events
fwcd Feb 28, 2025
dcdbaff
Try observing gamepad events
fwcd Feb 28, 2025
3cece29
Disable all input events by default to save resources
fwcd Feb 28, 2025
0b22751
Store input config in local storage
fwcd Feb 28, 2025
4dfca9d
Use polling to determine gamepad count
fwcd Feb 28, 2025
08d6bcb
Track gamepad changes in DisplayView
fwcd Feb 28, 2025
ef99868
Abstract out model API for finer-grained reactivity
fwcd Feb 28, 2025
d4f4fcf
Implement basic gamepad handling
fwcd Feb 28, 2025
0762834
Add note regarding API design
fwcd Feb 28, 2025
1e49806
Update legacy events in input state
fwcd Feb 28, 2025
d27b66f
Update note on remapping
fwcd Feb 28, 2025
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"@testing-library/user-event": "^14.5.2",
"framer-motion": "^11",
"immutable": "^4.3.6",
"nighthouse": "3.0.5",
"nighthouse": "4.0.0",
"react": "^18.3.1",
"react-card-flip": "^1.2.3",
"react-dom": "^18.3.1",
Expand Down
27 changes: 16 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,26 @@ import { WindowDimensionsContextProvider } from '@luna/contexts/env/WindowDimens
import { router } from '@luna/routes';
import { HeroUIProvider } from '@heroui/react';
import { RouterProvider } from 'react-router-dom';
import { ClientIdContextProvider } from '@luna/contexts/env/ClientIdContext';

const clientId = crypto.randomUUID();

export function App() {
return (
<HeroUIProvider>
<ColorSchemeContextProvider>
<WindowDimensionsContextProvider>
<AuthContextProvider>
<ModelContextProvider>
<SearchContextProvider>
<RouterProvider router={router} />
</SearchContextProvider>
</ModelContextProvider>
</AuthContextProvider>
</WindowDimensionsContextProvider>
</ColorSchemeContextProvider>
<ClientIdContextProvider clientId={clientId}>
<ColorSchemeContextProvider>
<WindowDimensionsContextProvider>
<AuthContextProvider>
<ModelContextProvider>
<SearchContextProvider>
<RouterProvider router={router} />
</SearchContextProvider>
</ModelContextProvider>
</AuthContextProvider>
</WindowDimensionsContextProvider>
</ColorSchemeContextProvider>
</ClientIdContextProvider>
</HeroUIProvider>
);
}
19 changes: 19 additions & 0 deletions src/components/BooleanCheck.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ColorSchemeContext } from '@luna/contexts/env/ColorSchemeContext';
import { IconCheck, IconX } from '@tabler/icons-react';
import { useContext } from 'react';

export interface BooleanCheckProps {
value: boolean;
}

export function BooleanCheck({ value }: BooleanCheckProps) {
const { colorScheme } = useContext(ColorSchemeContext);

return value ? (
<IconCheck
color={colorScheme.isDark ? 'rgb(60, 255, 0)' : 'rgb(0, 180, 0)'}
/>
) : (
<IconX color="red" />
);
}
13 changes: 13 additions & 0 deletions src/components/BooleanChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Chip } from '@heroui/react';

export interface BooleanChipProps {
value: boolean;
}

export function BooleanChip({ value }: BooleanChipProps) {
return (
<Chip color={value ? 'success' : 'danger'} variant="flat">
{value ? 'yes' : 'no'}
</Chip>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,48 @@ import {
TableColumn,
TableRow,
} from '@heroui/react';
import { ObjectInspectorValue } from '@luna/components/ObjectInspectorValue';
import { ReactNode, useCallback, useMemo } from 'react';
import { TableBody, TableHeader } from 'react-stately';

// TODO: Generalize this to a generic component for data tables?
export type Names<T> = { [Property in keyof T]?: string };

export interface MonitorInspectorTableProps<T> {
metrics: T[];
names: { [Property in keyof T]?: string };
export interface ObjectInspectorTableProps<T extends object> {
objects: T[];
names: Names<T>;
selection?: keyof T;
onSelect?: (prop?: keyof T) => void;
render: <K extends keyof T>(value: T[K], prop: K) => ReactNode;
render?: <K extends keyof T>(value: T[K], prop: K) => ReactNode;
}

export function MonitorInspectorTable<T extends object>({
metrics,
export function ObjectInspectorTable<T extends object>({
objects,
names,
selection,
onSelect,
render,
}: MonitorInspectorTableProps<T>) {
render = (value, _prop) => <ObjectInspectorValue value={value} />,
}: ObjectInspectorTableProps<T>) {
const columns = useMemo(
() =>
[...Array(metrics.length + 1).keys()].map(i => ({
[...Array(objects.length + 1).keys()].map(i => ({
key: `${i}`,
})),
[metrics.length]
[objects.length]
);

const rows = useMemo(
() =>
metrics.length > 0
objects.length > 0
? (Object.keys(names) as (keyof T)[]).map(prop => ({
key: prop as string,
prop,
values: [names[prop], ...metrics.map(v => v[prop])] as [
values: [names[prop], ...objects.map(v => v[prop])] as [
string,
...T[keyof T][],
],
}))
: [],
[metrics, names]
[objects, names]
);

const onSelectionChange = useCallback(
Expand All @@ -65,7 +66,7 @@ export function MonitorInspectorTable<T extends object>({
classNames={{
table: 'bg-red',
}}
isStriped
removeWrapper
isCompact
selectedKeys={[selection as string]}
selectionMode={selection || onSelect ? 'single' : undefined}
Expand Down
68 changes: 68 additions & 0 deletions src/components/ObjectInspectorValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { BooleanCheck } from '@luna/components/BooleanCheck';
import { isBounded } from '@luna/utils/bounded';
import * as vec2 from '@luna/utils/vec2';

export interface ObjectInspectorValueProps {
value: any;
unit?: string;
precision?: number;
}

export function ObjectInspectorValue({
value,
unit,
precision,
}: ObjectInspectorValueProps) {
return (
<div className="flex flex-row gap-1">
<ObjectInspectorRawValue value={value} precision={precision} />
<div>{unit}</div>
</div>
);
}

function ObjectInspectorRawValue({
value,
precision = 4,
}: {
value: any;
precision?: number;
}) {
if (value === null) {
return <>null</>;
}
if (value === undefined) {
return <>undefined</>;
}
switch (typeof value) {
case 'string':
return <>{value}</>;
case 'number':
return <>{Number.isInteger(value) ? value : value.toFixed(precision)}</>;
case 'boolean':
return <BooleanCheck value={value} />;
case 'object':
if (vec2.isInstance(value)) {
return (
<div className="flex flex-col">
<span>
x: <ObjectInspectorRawValue value={value.x} precision={2} />
</span>
<span>
y: <ObjectInspectorRawValue value={value.y} precision={2} />
</span>
</div>
);
} else if (isBounded(value)) {
return (
<>
<ObjectInspectorRawValue value={value.value} /> of{' '}
<ObjectInspectorRawValue value={value.total} />
</>
);
}
return <>{JSON.stringify(value)}</>;
default:
return <>?</>;
}
}
9 changes: 3 additions & 6 deletions src/components/RouteLink.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { Button } from '@heroui/react';
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
import { ElementType, ReactNode, useCallback, useState } from 'react';
import { ReactNode, useCallback, useState } from 'react';
import { NavLink } from 'react-router-dom';
import { AnimatePresence as AnimatePresenceFM, motion } from 'framer-motion';

// Workaround for https://github.com/framer/motion/issues/1509
// See https://github.com/withastro/astro/issues/8195#issuecomment-2613930022
const AnimatePresence = AnimatePresenceFM as ElementType;
import { motion } from 'framer-motion';
import { AnimatePresence } from '@luna/utils/motion';

interface RouteLinkParams {
icon: ReactNode;
Expand Down
1 change: 1 addition & 0 deletions src/constants/LocalStorageKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export enum LocalStorageKey {
ColorSchemeFollowsSystem = 'luna.contexts.colorScheme.followsSystem',
AdminResourcesLayout = 'luna.screens.home.admin.resources.layout',
DisplaysZoom = 'luna.screens.home.displays.zoom',
DisplayInputConfig = 'luna.screens.home.display.inputConfig',
}
68 changes: 51 additions & 17 deletions src/contexts/api/model/ModelContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
connect,
ConsoleLogHandler,
DirectoryTree,
InputEvent,
LegacyInputEvent,
LeveledLogHandler,
Lighthouse,
LIGHTHOUSE_FRAME_BYTES,
Expand All @@ -32,10 +34,7 @@ export interface Users {
readonly active: Set<string>;
}

export interface ModelContextValue {
/** The user models, active users etc. */
readonly users: Users;

export interface ModelAPI {
/** Lists an arbitrary path. */
list(path: string[]): Promise<Result<DirectoryTree>>;

Expand All @@ -51,6 +50,15 @@ export interface ModelContextValue {
/** Updates a resource at an arbitrary path. */
put(path: string[], payload: any): Promise<Result<unknown>>;

/** Sends a legacy input event for the given user to the given endpoint. */
putLegacyInput(
user: string,
payload: LegacyInputEvent
): Promise<Result<unknown>>;

/** Sends an input event for the given user to the given endpoint. */
putInput(user: string, payload: InputEvent): Promise<Result<unknown>>;

/** Creates a directory at an arbitrary path. */
mkdir(path: string[]): Promise<Result<unknown>>;

Expand All @@ -64,21 +72,34 @@ export interface ModelContextValue {
getLaserMetrics(): Promise<Result<LaserMetrics>>;
}

export interface ModelContextValue {
/** The user models, active users etc. */
readonly users: Users;

/** A facility interact with the model server API. */
readonly api: ModelAPI;
}

export const ModelContext = createContext<ModelContextValue>({
users: {
models: Map(),
active: Set(),
},
list: async () => errorResult('No model context for listing path'),
get: async () => errorResult('No model context for fetching path'),
delete: async () => errorResult('No model context for deleting path'),
put: async () => errorResult('No model context for updating resource'),
create: async () => errorResult('No model context for creating resource'),
mkdir: async () => errorResult('No model context for creating directory'),
isDirectory: async () => false,
move: async () => errorResult('No model context for moving resource'),
getLaserMetrics: async () =>
errorResult('No model context for fetching laser metrics'),
api: {
list: async () => errorResult('No model context for listing path'),
get: async () => errorResult('No model context for fetching path'),
delete: async () => errorResult('No model context for deleting path'),
put: async () => errorResult('No model context for updating resource'),
putLegacyInput: async () =>
errorResult('No model context for putting input'),
putInput: async () => errorResult('No model context for putting input'),
create: async () => errorResult('No model context for creating resource'),
mkdir: async () => errorResult('No model context for creating directory'),
isDirectory: async () => false,
move: async () => errorResult('No model context for moving resource'),
getLaserMetrics: async () =>
errorResult('No model context for fetching laser metrics'),
},
});

interface ModelContextProviderProps {
Expand Down Expand Up @@ -195,9 +216,8 @@ export function ModelContextProvider({ children }: ModelContextProviderProps) {

useAsyncIterable(getUserStreams, consumeUserStreams);

const value: ModelContextValue = useMemo(
const api: ModelAPI = useMemo(
() => ({
users,
async list(path) {
return messageToResult(await client?.list(path));
},
Expand All @@ -210,6 +230,12 @@ export function ModelContextProvider({ children }: ModelContextProviderProps) {
async put(path, payload) {
return messageToResult(await client?.put(path, payload));
},
async putLegacyInput(user, payload) {
return messageToResult(await client?.putModel(payload, user));
},
async putInput(user, payload) {
return messageToResult(await client?.putInput(payload, user));
},
async create(path) {
return messageToResult(await client?.create(path));
},
Expand Down Expand Up @@ -245,7 +271,15 @@ export function ModelContextProvider({ children }: ModelContextProviderProps) {
return (await this.get(['metrics', 'laser'])) as Result<LaserMetrics>;
},
}),
[client, users]
[client]
);

const value: ModelContextValue = useMemo(
() => ({
users,
api,
}),
[users, api]
);

return (
Expand Down
Loading