Skip to content
Merged
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
95 changes: 95 additions & 0 deletions src/components/table-search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { hexAddr } from "@/lib/format";
import { matchRows, type SearchEntry } from "@/lib/table-model";

const PLACEHOLDER = "Search the table";

export function TableSearch({
index,
onPick,
}: {
index: readonly SearchEntry[];
onPick: (entry: SearchEntry) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const trigger = useRef<HTMLButtonElement>(null);
const hits = useMemo(() => matchRows(index, query), [index, query]);

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "k" || !(e.metaKey || e.ctrlKey)) return;
e.preventDefault();
// The pop-under hangs off the trigger, which a scrolled table has left behind.
if (!open) trigger.current?.scrollIntoView({ block: "nearest" });
openChange(!open);
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
};
}, [open]);

function openChange(next: boolean) {
setOpen(next);
if (!next) setQuery("");
}

function pick(entry: SearchEntry) {
openChange(false);
onPick(entry);
}

return (
<Popover open={open} onOpenChange={openChange}>
<PopoverTrigger asChild>
<Button
ref={trigger}
variant="outline"
size="sm"
className="w-56 justify-start font-normal text-text-3"
>
<Search />
{PLACEHOLDER}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[32rem] gap-0 p-0">
{/* matchRows owns the matching, so cmdk must not score the items again. */}
<Command shouldFilter={false}>
<CommandInput placeholder={PLACEHOLDER} value={query} onValueChange={setQuery} />
<CommandList>
{query.trim() !== "" && <CommandEmpty>Nothing matches.</CommandEmpty>}
{hits.map((entry) => (
<CommandItem
key={entry.name}
value={entry.name}
onSelect={() => {
pick(entry);
}}
>
<span className="shrink-0 font-medium">{entry.label}</span>
<span className="truncate font-mono text-xs text-text-3">
{entry.name} {hexAddr(entry.addr)}
</span>
<CommandShortcut className="shrink-0 tracking-normal">
{entry.tab} / {entry.group}
</CommandShortcut>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
11 changes: 11 additions & 0 deletions src/lib/edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
parseInput,
rangeHint,
toRaw,
toValue,
type NumberKind,
} from "./edit";

Expand Down Expand Up @@ -213,3 +214,13 @@ describe("round trips", () => {
});
});
});

test("toValue tags a parsed edit by the field's kind and rejects a mismatch", () => {
expect(toValue(id, 7)).toEqual({ kind: "uint", value: 7 });
expect(toValue(goal, -3)).toEqual({ kind: "int", value: -3 });
expect(toValue(mode, 2)).toEqual({ kind: "enum", value: 2 });
expect(toValue(torque, true)).toEqual({ kind: "bool", value: true });
const bytes = new Uint8Array([1, 2, 3, 4]);
expect(toValue(words, bytes)).toEqual({ kind: "bytes", value: bytes });
expect(() => toValue(torque, 1)).toThrow("torque_enable is bool, not number");
});
20 changes: 19 additions & 1 deletion src/lib/edit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Field } from "@openservocore/client";
import type { Field, Value } from "@openservocore/client";

/** `min`/`max` are the descriptor's bounds in counts; `scale` is display units per count. */
export interface NumberKind {
Expand Down Expand Up @@ -40,6 +40,24 @@ export type ValueOf<K extends FieldKind> = K extends BoolKind

export type ParseResult<T> = { ok: true; value: T } | { ok: false; reason: string };

/** The codec's tagged value for a parsed edit; the tag follows the field, so `raw` must match it. */
export function toValue(field: Pick<Field, "name" | "kind">, raw: EditValue): Value {
switch (field.kind) {
case "uint":
case "int":
case "enum":
if (typeof raw === "number") return { kind: field.kind, value: raw };
break;
case "bool":
if (typeof raw === "boolean") return { kind: "bool", value: raw };
break;
case "bytes":
if (raw instanceof Uint8Array) return { kind: "bytes", value: raw };
break;
}
throw new Error(`${field.name} is ${field.kind}, not ${typeof raw}`);
}

export function fieldKind(field: Field): FieldKind {
switch (field.kind) {
case "uint":
Expand Down
113 changes: 113 additions & 0 deletions src/lib/table-live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { spanHolding, startPoll } from "./table-live";

const spans = [
{ addr: 0, count: 9 },
{ addr: 16, count: 4 },
];

test("spanHolding picks the span the field lies inside", () => {
expect(spanHolding(spans, { addr: 2, width: 2 })).toBe(spans[0]);
expect(spanHolding(spans, { addr: 16, width: 4 })).toBe(spans[1]);
});

test("spanHolding falls back to the field's own extent when no span holds all of it", () => {
expect(spanHolding(spans, { addr: 8, width: 2 })).toEqual({ addr: 8, count: 2 });
expect(spanHolding([], { addr: 30, width: 1 })).toEqual({ addr: 30, count: 1 });
});

/** A tick whose completion the test controls. */
function gate(): { tick: () => Promise<void>; release: () => void; calls: number } {
const resolvers: (() => void)[] = [];
const g = {
calls: 0,
tick: () => {
g.calls++;
return new Promise<void>((resolve) => resolvers.push(resolve));
},
release: () => {
for (const r of resolvers.splice(0)) r();
},
};
return g;
}

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

test("ticks at once, then once per period", async () => {
const g = gate();
const stop = startPoll({ periodMs: 100, tick: g.tick, onError: () => undefined });
expect(g.calls).toBe(1);
g.release();
await vi.advanceTimersByTimeAsync(100);
expect(g.calls).toBe(2);
g.release();
await vi.advanceTimersByTimeAsync(100);
expect(g.calls).toBe(3);
stop();
});

test("a tick still pending is not overlapped; the next period after it resolves ticks", async () => {
const g = gate();
const stop = startPoll({ periodMs: 100, tick: g.tick, onError: () => undefined });
await vi.advanceTimersByTimeAsync(350);
expect(g.calls).toBe(1);
g.release();
await vi.advanceTimersByTimeAsync(100);
expect(g.calls).toBe(2);
stop();
});

test("a hold skips ticks until it lifts", async () => {
const g = gate();
let held = true;
const stop = startPoll({
periodMs: 100,
tick: g.tick,
hold: () => held,
onError: () => undefined,
});
await vi.advanceTimersByTimeAsync(300);
expect(g.calls).toBe(0);
held = false;
await vi.advanceTimersByTimeAsync(100);
expect(g.calls).toBe(1);
stop();
});

test("stop ends the ticks and silences a late error", async () => {
const errors: unknown[] = [];
let reject: (e: Error) => void = () => undefined;
const tick = vi.fn(
() =>
new Promise<void>((_, rej) => {
reject = rej;
}),
);
const stop = startPoll({ periodMs: 100, tick, onError: (e) => errors.push(e) });
stop();
reject(new Error("late"));
await vi.advanceTimersByTimeAsync(500);
expect(tick).toHaveBeenCalledTimes(1);
expect(errors).toEqual([]);
});

test("an error is reported and polling goes on", async () => {
const errors: unknown[] = [];
let fail = true;
const tick = vi.fn(() => (fail ? Promise.reject(new Error("bus")) : Promise.resolve()));
const stop = startPoll({ periodMs: 100, tick, onError: (e) => errors.push(e) });
await vi.advanceTimersByTimeAsync(0);
expect(errors).toHaveLength(1);
fail = false;
await vi.advanceTimersByTimeAsync(200);
expect(tick).toHaveBeenCalledTimes(3);
expect(errors).toHaveLength(1);
stop();
});
52 changes: 52 additions & 0 deletions src/lib/table-live.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Field } from "@openservocore/client";
import type { Span } from "./table-read";

export const LIVE_POLL_MS = 1000;
export const FLASH_MS = 800;

/** The span a re-read after a write covers: the one holding `field`, else the field alone. */
export function spanHolding(spans: readonly Span[], field: Pick<Field, "addr" | "width">): Span {
const end = field.addr + field.width;
return (
spans.find((s) => s.addr <= field.addr && end <= s.addr + s.count) ?? {
addr: field.addr,
count: field.width,
}
);
}

export interface PollOptions {
periodMs: number;
/** One read pass. */
tick: () => Promise<void>;
/** While true a tick is skipped: an edit is in flight and the next tick picks up its result. */
hold?: () => boolean;
onError: (error: unknown) => void;
}

/**
* Ticks at once and then every `periodMs`, skipping a tick while the previous
* one is still pending or `hold` is set. An error is reported and polling goes
* on; only the returned stop ends it.
*/
export function startPoll(o: PollOptions): () => void {
let pending = false;
let stopped = false;
const tick = (): void => {
if (stopped || pending || o.hold?.() === true) return;
pending = true;
o.tick()
.catch((e: unknown) => {
if (!stopped) o.onError(e);
})
.finally(() => {
pending = false;
});
};
const timer = setInterval(tick, o.periodMs);
tick();
return () => {
stopped = true;
clearInterval(timer);
};
}
Loading
Loading