UI and a Lisp experiment
This commit is contained in:
parent
38ff06ea45
commit
f55b437037
24 changed files with 2746 additions and 89 deletions
127
src/ui/App.tsx
Normal file
127
src/ui/App.tsx
Normal 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
50
src/ui/Pane.tsx
Normal 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
261
src/ui/SourceGrid.tsx
Normal 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
126
src/ui/SyntaxPane.tsx
Normal 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
49
src/ui/format.ts
Normal 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
6
src/ui/types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { CodePointSpan } from 'source-region';
|
||||
|
||||
export type HoverTarget = {
|
||||
label: string;
|
||||
span: CodePointSpan;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue