UI and a Lisp experiment

This commit is contained in:
Yura Dupyn 2026-04-25 01:10:49 +02:00
parent 38ff06ea45
commit f55b437037
24 changed files with 2746 additions and 89 deletions

127
src/ui/App.tsx Normal file
View file

@ -0,0 +1,127 @@
import { createMemo, createSignal, Show } from 'solid-js';
import { sourceText } from 'source-region';
import type { CodePointSpan, SourceRegion, SourceText } from 'source-region';
import { parseDocument } from '../parser';
import type { ParseError } from '../parser';
import type { ConcreteSyntax } from '../syntax';
import { spanLabel } from './format';
import { PaneHeader, PaneSplitter } from './Pane';
import { SourceGrid } from './SourceGrid';
import type { SourceGridAnnotation } from './SourceGrid';
import { ErrorList, ExpressionList } from './SyntaxPane';
import type { HoverTarget } from './types';
type ParsedDocument = {
source: SourceText;
region: SourceRegion;
values: ConcreteSyntax[];
errors: ParseError[];
};
const SAMPLE_INPUT = `(define square (_ x) (mul x x))
(add 1 2)
(define pyth (_ x y) (+ (square x) (square y)))
foo ) @@@ (bar 1)
(nested (list 123 abc_9 name-with-dash))`;
export function App() {
const [input, setInput] = createSignal(SAMPLE_INPUT);
const [hovered, setHovered] = createSignal<HoverTarget | undefined>();
const [leftWidth, setLeftWidth] = createSignal(420);
const [middleWidth, setMiddleWidth] = createSignal(420);
const parsed = createMemo<ParsedDocument>(() => {
const source = sourceText(input());
const region = source.fullRegion();
const result = parseDocument(region);
return { source, region, values: result.values, errors: result.errors };
});
return (
<main
class="app-shell"
style={{
"--left-width": `${leftWidth()}px`,
"--middle-width": `${middleWidth()}px`,
}}
>
<section class="pane input-pane">
<PaneHeader title="Source" detail={`${input().length} UTF-16 units`} />
<textarea
class="source-input"
spellcheck={false}
value={input()}
onInput={(event) => {
setInput(event.currentTarget.value);
setHovered(undefined);
}}
/>
</section>
<PaneSplitter
label="Resize source and structure panes"
onDrag={(delta) => {
setLeftWidth((width) => clamp(width + delta, 280, 760));
}}
/>
<section class="pane structure-pane">
<PaneHeader
title="Structure"
detail={`${parsed().values.length} expressions, ${parsed().errors.length} errors`}
/>
<Show
when={parsed().errors.length > 0}
fallback={
<ExpressionList
values={parsed().values}
onHover={setHovered}
/>
}
>
<ErrorList
errors={parsed().errors}
values={parsed().values}
onHover={setHovered}
/>
</Show>
</section>
<PaneSplitter
label="Resize structure and source grid panes"
onDrag={(delta) => {
setMiddleWidth((width) => clamp(width + delta, 260, 760));
}}
/>
<section class="pane source-pane">
<PaneHeader
title="Source Grid"
detail={hovered() ? spanLabel(hovered()!.span) : "nothing hovered"}
/>
<SourceGrid
source={parsed().source}
region={parsed().region}
annotations={hovered() ? [hoverAnnotation(hovered()!)] : []}
/>
</section>
</main>
);
}
function hoverAnnotation(target: HoverTarget): SourceGridAnnotation {
return {
id: "hovered",
span: target.span,
label: target.label,
cellClass: "annotation-hovered",
markerClass: "annotation-hovered-marker",
};
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}

50
src/ui/Pane.tsx Normal file
View file

@ -0,0 +1,50 @@
export function PaneHeader(props: { title: string; detail: string }) {
return (
<header class="pane-header">
<h1>{props.title}</h1>
<div class="pane-detail">{props.detail}</div>
</header>
);
}
export function PaneSplitter(props: {
label: string;
onDrag: (deltaX: number) => void;
}) {
let startX = 0;
function onPointerDown(event: PointerEvent) {
startX = event.clientX;
const target = event.currentTarget as HTMLElement;
target.setPointerCapture(event.pointerId);
document.body.classList.add("is-resizing-pane");
}
function onPointerMove(event: PointerEvent) {
if (!(event.currentTarget as HTMLElement).hasPointerCapture(event.pointerId)) return;
const delta = event.clientX - startX;
startX = event.clientX;
props.onDrag(delta);
}
function onPointerUp(event: PointerEvent) {
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId);
}
document.body.classList.remove("is-resizing-pane");
}
return (
<div
class="pane-splitter"
role="separator"
aria-label={props.label}
aria-orientation="vertical"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
);
}

261
src/ui/SourceGrid.tsx Normal file
View file

@ -0,0 +1,261 @@
import { createMemo, createSignal, For, Show } from 'solid-js';
import type { JSX } from 'solid-js';
import {
CARRIAGE_RETURN,
NEW_LINE,
SPACE,
TAB,
} from 'source-region';
import type {
CodePoint,
CodePointIndex,
CodePointSpan,
SourceRegion,
SourceText,
} from 'source-region';
export type SourceGridAnnotation = {
id: string;
span: CodePointSpan;
label?: string;
cellClass?: string;
cellStyle?: JSX.CSSProperties;
markerClass?: string;
markerStyle?: JSX.CSSProperties;
};
type SourceGridModel = {
rows: SourceGridRow[];
maxColumn: number;
};
type SourceGridRow = {
lineNo: number;
cells: SourceGridCell[];
};
type SourceGridCell = {
index: CodePointIndex;
line: number;
column: number;
codePoint: CodePoint;
display: string;
kind: "normal" | "space" | "tab" | "newline" | "carriage-return" | "combining-mark" | "format" | "control";
};
type ZeroWidthMarker = {
line: number;
column: number;
annotation: SourceGridAnnotation;
};
export function SourceGrid(props: {
source: SourceText;
region: SourceRegion;
annotations: SourceGridAnnotation[];
}) {
const [hoveredCell, setHoveredCell] = createSignal<SourceGridCell | undefined>();
const grid = createMemo(() => makeSourceGrid(props.source, props.region));
const zeroMarkers = createMemo(() => zeroWidthMarkers(props.source, props.annotations));
const maxColumn = createMemo(() => {
const markerMax = zeroMarkers().reduce((max, marker) => Math.max(max, marker.column), 0);
return Math.max(1, grid().maxColumn, markerMax);
});
return (
<div class="source-grid-shell">
<div class="grid-status">
<span>{props.annotations[0]?.label ?? "Hover an error or expression."}</span>
<Show when={hoveredCell()}>
{(cell) => (
<span>
index {cell().index}, line {cell().line}, col {cell().column}, U+{cell().codePoint.toString(16).toUpperCase()}
</span>
)}
</Show>
</div>
<div
class="source-grid"
style={{ "--max-column": `${maxColumn()}` }}
>
<For each={grid().rows}>
{(row) => (
<div class="source-grid-row">
<div class="row-header">{row.lineNo}</div>
<For each={zeroMarkers().filter((marker) => marker.line === row.lineNo)}>
{(marker) => (
<div
class={markerClass(marker.annotation)}
style={{
"grid-column": `${marker.column + 1}`,
...marker.annotation.markerStyle,
}}
title={marker.annotation.label}
/>
)}
</For>
<For each={row.cells}>
{(cell) => (
<div
class={cellClass(cell, hoveredCell(), props.annotations)}
style={cellStyle(cell, props.annotations)}
title={cellTitle(cell, props.annotations)}
onMouseEnter={() => setHoveredCell(cell)}
onMouseLeave={() => setHoveredCell(undefined)}
>
{cell.display}
</div>
)}
</For>
</div>
)}
</For>
</div>
</div>
);
}
function makeSourceGrid(source: SourceText, region: SourceRegion): SourceGridModel {
const rows: SourceGridRow[] = [];
let maxColumn = 0;
for (let lineNo = region.span.start.line; lineNo <= region.span.end.line; lineNo++) {
const range = source.getLineRange(lineNo);
const start = Math.max(range.start, region.span.start.index);
const end = Math.min(range.end, region.span.end.index);
const cells: SourceGridCell[] = [];
for (let index = start; index < end; index++) {
const codePoint = source.codePointAt(index);
const location = source.getLocation(index);
const cell = {
index,
line: location.line,
column: location.column,
codePoint,
display: displayCodePoint(codePoint),
kind: codePointKind(codePoint),
};
cells.push(cell);
maxColumn = Math.max(maxColumn, location.column);
}
rows.push({ lineNo, cells });
}
return { rows, maxColumn };
}
function cellClass(cell: SourceGridCell, hovered: SourceGridCell | undefined, annotations: SourceGridAnnotation[]): string {
const classes = ["grid-cell", `grid-cell-${cell.kind}`];
for (const annotation of annotationsForCell(cell, annotations)) {
classes.push("is-annotated");
if (annotation.cellClass) classes.push(annotation.cellClass);
}
if (hovered) {
if (hovered.line === cell.line) classes.push("is-hover-row");
if (hovered.column === cell.column) classes.push("is-hover-column");
if (hovered.index === cell.index) classes.push("is-hover-cell");
}
return classes.join(" ");
}
function cellStyle(cell: SourceGridCell, annotations: SourceGridAnnotation[]): JSX.CSSProperties {
return annotationsForCell(cell, annotations).reduce<JSX.CSSProperties>(
(style, annotation) => ({ ...style, ...annotation.cellStyle }),
{},
);
}
function cellTitle(cell: SourceGridCell, annotations: SourceGridAnnotation[]): string {
const labels = annotationsForCell(cell, annotations)
.map((annotation) => annotation.label)
.filter((label) => label !== undefined);
const base = `index ${cell.index}, line ${cell.line}, column ${cell.column}`;
return labels.length > 0 ? `${base}\n${labels.join("\n")}` : base;
}
function annotationsForCell(cell: SourceGridCell, annotations: SourceGridAnnotation[]): SourceGridAnnotation[] {
return annotations.filter((annotation) =>
annotation.span.start < annotation.span.end
&& annotation.span.start <= cell.index
&& cell.index < annotation.span.end
);
}
function markerClass(annotation: SourceGridAnnotation): string {
return ["zero-span-marker", annotation.markerClass].filter(Boolean).join(" ");
}
function zeroWidthMarkers(source: SourceText, annotations: SourceGridAnnotation[]): ZeroWidthMarker[] {
return annotations
.filter((annotation) => annotation.span.start === annotation.span.end)
.map((annotation) => {
const location = source.getLocation(annotation.span.start);
return { line: location.line, column: location.column, annotation };
});
}
function displayCodePoint(cp: CodePoint): string {
switch (cp) {
case SPACE:
return "·";
case TAB:
return "⇥";
case NEW_LINE:
return "␊";
case CARRIAGE_RETURN:
return "␍";
default:
if (isCombiningMark(cp)) return `${String.fromCodePoint(cp)}`;
if (cp === 0x200C) return "ZWNJ";
if (cp === 0x200D) return "ZWJ";
if (isVariationSelector(cp)) return "VS";
if (isControlCodePoint(cp)) return codePointLabel(cp);
return String.fromCodePoint(cp);
}
}
function codePointKind(cp: CodePoint): SourceGridCell["kind"] {
switch (cp) {
case SPACE:
return "space";
case TAB:
return "tab";
case NEW_LINE:
return "newline";
case CARRIAGE_RETURN:
return "carriage-return";
default:
if (isCombiningMark(cp)) return "combining-mark";
if (cp === 0x200C || cp === 0x200D || isVariationSelector(cp)) return "format";
if (isControlCodePoint(cp)) return "control";
return "normal";
}
}
function isCombiningMark(cp: CodePoint): boolean {
return (0x0300 <= cp && cp <= 0x036F)
|| (0x1AB0 <= cp && cp <= 0x1AFF)
|| (0x1DC0 <= cp && cp <= 0x1DFF)
|| (0x20D0 <= cp && cp <= 0x20FF)
|| (0xFE20 <= cp && cp <= 0xFE2F);
}
function isVariationSelector(cp: CodePoint): boolean {
return (0xFE00 <= cp && cp <= 0xFE0F)
|| (0xE0100 <= cp && cp <= 0xE01EF);
}
function isControlCodePoint(cp: CodePoint): boolean {
return (0x0000 <= cp && cp <= 0x001F) || (0x007F <= cp && cp <= 0x009F);
}
function codePointLabel(cp: CodePoint): string {
return `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
}

126
src/ui/SyntaxPane.tsx Normal file
View file

@ -0,0 +1,126 @@
import { For, Show } from 'solid-js';
import type { JSX } from 'solid-js';
import type { CodePointSpan } from 'source-region';
import type { ParseError } from '../parser';
import type { ConcreteSyntax } from '../syntax';
import { Expr } from '../syntax';
import { errorDetail, errorLabel, errorTitle } from './format';
import type { HoverTarget } from './types';
export function ErrorList(props: {
errors: ParseError[];
values: ConcreteSyntax[];
onHover: (target: HoverTarget | undefined) => void;
}) {
return (
<div class="scroll-stack">
<div class="section-label">Errors</div>
<div class="error-list">
<For each={props.errors}>
{(error) => (
<HoverBlock
class="error-card"
label={errorLabel(error)}
span={error.span}
onHover={props.onHover}
>
<div class="item-title">{errorTitle(error)}</div>
<div class="item-meta">{errorDetail(error)}</div>
</HoverBlock>
)}
</For>
</div>
<Show when={props.values.length > 0}>
<div class="section-label">Recovered Expressions</div>
<ExpressionList values={props.values} onHover={props.onHover} />
</Show>
</div>
);
}
export function ExpressionList(props: {
values: ConcreteSyntax[];
onHover: (target: HoverTarget | undefined) => void;
}) {
return (
<div class="expr-list">
<For each={props.values}>
{(value, index) => (
<ExprView
expr={value}
label={`expression ${index() + 1}`}
onHover={props.onHover}
/>
)}
</For>
</div>
);
}
function ExprView(props: {
expr: ConcreteSyntax;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
if (props.expr.tag === "literal") {
return (
<HoverBlock
class="expr-node literal-node"
label={`${props.label}: ${props.expr.value.tag}`}
span={props.expr.span}
onHover={props.onHover}
>
<span class="node-kind">{props.expr.value.tag}</span>
<span class="node-value">{literalValue(props.expr)}</span>
</HoverBlock>
);
}
return (
<HoverBlock
class="expr-node list-node"
label={`${props.label}: list`}
span={props.expr.span}
onHover={props.onHover}
>
<div class="list-node-header">
<span class="node-kind">list</span>
<span class="item-meta">{props.expr.values.length} children</span>
</div>
<div class="list-children">
<For each={props.expr.values}>
{(child, index) => (
<ExprView
expr={child}
label={`${props.label}.${index() + 1}`}
onHover={props.onHover}
/>
)}
</For>
</div>
</HoverBlock>
);
}
function HoverBlock(props: {
class: string;
label: string;
span: CodePointSpan;
onHover: (target: HoverTarget | undefined) => void;
children: JSX.Element;
}) {
return (
<div
class={props.class}
onMouseEnter={() => props.onHover({ label: props.label, span: props.span })}
onMouseLeave={() => props.onHover(undefined)}
>
{props.children}
</div>
);
}
function literalValue(expr: ConcreteSyntax): string {
return expr.tag === "literal" ? Expr.show(expr) : "";
}

49
src/ui/format.ts Normal file
View file

@ -0,0 +1,49 @@
import type { CodePointSpan } from 'source-region';
import type { FoundSyntax, ParseError } from '../parser';
export function errorTitle(error: ParseError): string {
switch (error.tag) {
case "expected-expression":
return "Expected expression";
case "expected-close-paren":
return "Expected closing paren";
case "unexpected-close-paren":
return "Unexpected closing paren";
case "unexpected-code-point":
return "Unexpected code point";
case "invalid-number":
return "Invalid number";
}
}
export function errorLabel(error: ParseError): string {
return `${errorTitle(error)} ${spanLabel(error.span)}`;
}
export function errorDetail(error: ParseError): string {
switch (error.tag) {
case "expected-expression":
return `found ${foundLabel(error.found)}`;
case "expected-close-paren":
return `opened at ${spanLabel(error.openParen)}, found ${foundLabel(error.found)}`;
case "unexpected-close-paren":
return spanLabel(error.span);
case "unexpected-code-point":
return `found ${foundLabel(error.found)}`;
case "invalid-number":
return `${error.reason}: ${error.text}`;
}
}
export function foundLabel(found: FoundSyntax): string {
switch (found.tag) {
case "eof":
return `EOF ${spanLabel(found.span)}`;
case "code-point":
return `${String.fromCodePoint(found.value)} ${spanLabel(found.span)}`;
}
}
export function spanLabel(span: CodePointSpan): string {
return `[${span.start}, ${span.end})`;
}

6
src/ui/types.ts Normal file
View file

@ -0,0 +1,6 @@
import type { CodePointSpan } from 'source-region';
export type HoverTarget = {
label: string;
span: CodePointSpan;
};