Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 5 additions & 1 deletion packages/app-web-docs/docusaurus.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ const config: Config = {
],
],

plugins: ["docusaurus-plugin-sass", "./src/plugins/scope-tests-plugin.ts"],
plugins: [
"docusaurus-plugin-sass",
"./src/plugins/recorded-tests-plugin.ts",
"./src/plugins/scope-tests-plugin.ts",
],

themeConfig: {
navbar: {
Expand Down
40 changes: 40 additions & 0 deletions packages/app-web-docs/src/docs/components/Code.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,43 @@
background-color: #444;
}
}

.code-cursor-before,
.code-cursor-after {
position: relative;
}

.code-cursor-before::before,
.code-cursor-after::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 0;
pointer-events: none;
opacity: 1;
animation: code-cursor-blink 1s step-end infinite;
}

.code-cursor-before::before {
left: -1px;
border-left: 2px solid white;
}

.code-cursor-after::after {
right: -1px;
border-right: 2px solid white;
}

@keyframes code-cursor-blink {
50% {
opacity: 0;
}
}

@media (prefers-reduced-motion: reduce) {
.code-cursor-before::before,
.code-cursor-after::after {
animation: none;
}
}
32 changes: 31 additions & 1 deletion packages/app-web-docs/src/docs/components/Code.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import type { DecorationItem } from "shiki";
import type { DecorationItem, OffsetOrPosition } from "shiki";
import { codeToHtml } from "shiki";
import "./Code.css";

Expand Down Expand Up @@ -35,6 +35,29 @@ export function Code({
lang: getFallbackLanguage(languageId),
theme: "nord",
decorations,
transformers: [
// Shiki omits decorations for empty lines. This transformation adds the cursor class to the line itself if needed.
{
name: "cursor-lines",
line(node, line) {
if (node.children.length === 0 && decorations != null) {
const hasDecoration = decorations.some((d) => {
return (
isPositionsEqual(d.start, d.end) &&
typeof d.start !== "number" &&
d.start.line === line - 1 &&
d.start.character === 0
);
});
if (hasDecoration) {
// oxlint-disable-next-line react/todo
this.addClassToHast(node, "code-cursor-after");
}
}
return node;
},
},
],
});
if (renderWhitespace) {
html = html
Expand Down Expand Up @@ -108,3 +131,10 @@ function getFallbackLanguage(languageId: string): string {
return languageId;
}
}

function isPositionsEqual(a: OffsetOrPosition, b: OffsetOrPosition) {
if (typeof a === "number" || typeof b === "number") {
return a === b;
}
return a.line === b.line && a.character === b.character;
}
206 changes: 206 additions & 0 deletions packages/app-web-docs/src/docs/components/RecordedTestVisualizer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { usePluginData } from "@docusaurus/useGlobalData";
import type { Dispatch, JSX, ReactNode, SetStateAction } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import type { DecorationItem } from "shiki";
import type {
SelectionPlainObject,
TestCaseSnapshot,
} from "@cursorless/lib-common";
import { BorderStyle, plainObjectToSelection } from "@cursorless/lib-common";
import { Code } from "./Code";
import { highlightColors } from "./highlightColors";
import { highlightToDecoration } from "./highlightsToDecorations";
import type { RecordedTest } from "./types";

interface RecordedTestVisualizerContextValue {
fixtures: ReadonlyMap<string, RecordedTest>;
renderWhitespace: boolean;
setRenderWhitespace: Dispatch<SetStateAction<boolean>>;
}

const RecordedTestVisualizerContext = createContext<
RecordedTestVisualizerContextValue | undefined
>(undefined);

export function RecordedTestVisualizerProvider({
children,
}: {
children: ReactNode;
}) {
const recordedTests = usePluginData(
"recorded-tests-plugin",
) as RecordedTest[];
const [renderWhitespace, setRenderWhitespace] = useState(true);
const fixtures = useMemo(
() =>
new Map(
recordedTests.map((recordedTest) => [recordedTest.name, recordedTest]),
),
[recordedTests],
);
const value = useMemo(
() => ({
fixtures,
renderWhitespace,
setRenderWhitespace,
}),
[fixtures, renderWhitespace],
);

return (
<RecordedTestVisualizerContext.Provider value={value}>
{children}
</RecordedTestVisualizerContext.Provider>
);
}

export function RecordedTestVisualizerOptions() {
const { renderWhitespace, setRenderWhitespace } = useRecordedTestVisualizer();

return (
<div className="mb-4">
<label className="ms-2">
<input
type="checkbox"
className="me-1"
checked={renderWhitespace}
onChange={(event) => setRenderWhitespace(event.currentTarget.checked)}
/>
Render whitespace
</label>
</div>
);
}

interface ModifierProps {
fixtureName: string;
}

export function RecordedTestVisualizer({ fixtureName }: ModifierProps) {
const { fixtures, renderWhitespace } = useRecordedTestVisualizer();
const test = fixtures.get(fixtureName);

if (test == null) {
throw new Error(`Unknown recorded test fixture: ${fixtureName}`);
}

const { fixture, relativePath } = test;
const { languageId, initialState, finalState } = fixture;

if (finalState == null) {
throw new Error(`Fixture ${fixtureName} does not have a final state`);
}

// oxlint-disable-next-line react_perf/jsx-no-new-object-as-prop
const link = {
name: "GitHub",
url: `https://github.com/cursorless-dev/cursorless/blob/main/resources/fixtures/recorded/docs/${relativePath}`,
};

return (
<div className="row">
<div className="col">
Input
<CodeState
renderWhitespace={renderWhitespace}
languageId={languageId}
link={link}
state={initialState}
/>
</div>
<div className="col">
Output
<CodeState
renderWhitespace={renderWhitespace}
languageId={languageId}
link={link}
state={finalState}
/>
</div>
</div>
);
}

function CodeState({
renderWhitespace,
languageId,
link,
state,
}: {
renderWhitespace: boolean;
languageId: string;
link: {
name: string;
url: string;
};
state: TestCaseSnapshot;
}): JSX.Element {
return (
<Code
link={link}
languageId={languageId}
renderWhitespace={renderWhitespace}
// oxlint-disable-next-line react-perf/jsx-no-new-array-as-prop
decorations={state.selections.map(toDecoration)}
Comment thread
AndreasArvidsson marked this conversation as resolved.
Resolved
>
{state.documentContents}
</Code>
);
}

function toDecoration(plainSelection: SelectionPlainObject): DecorationItem {
const selection = plainObjectToSelection(plainSelection);

if (selection.isEmpty) {
return {
start: selection.start,
end: selection.start,
properties: {
className: ["code-cursor-before"],
},
};
}

const decoration = highlightToDecoration({
range: selection,
style: {
backgroundColor: highlightColors.content.background,
borderColorSolid: highlightColors.content.borderSolid,
borderColorPorous: highlightColors.content.borderPorous,
borderStyle: {
top: BorderStyle.solid,
bottom: BorderStyle.solid,
left: BorderStyle.solid,
right: BorderStyle.solid,
},
borderRadius: {
topLeft: true,
topRight: true,
bottomRight: true,
bottomLeft: true,
},
},
});

const className = selection.isReversed
? "code-cursor-before"
: "code-cursor-after";

return {
...decoration,
properties: {
...decoration.properties,
className: [className],
},
};
}

function useRecordedTestVisualizer(): RecordedTestVisualizerContextValue {
const value = useContext(RecordedTestVisualizerContext);
if (value == null) {
throw new Error(
"Recorded test visualizer components must be used within RecordedTestVisualizerProvider",
);
}
return value;
}
11 changes: 5 additions & 6 deletions packages/app-web-docs/src/docs/components/ScopeVisualizer.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { usePluginData } from "@docusaurus/useGlobalData";
import React, { createContext, useContext, useMemo, useState } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import { generateDecorations } from "./calculateHighlights";
import { Code } from "./Code";
import type { Fixture, RangeType, ScopeTests } from "./types";
import type { Fixture, RangeType } from "./types";
import { getFacetInfo } from "./util";

interface ScopeVisualizerContextValue {
Expand All @@ -19,13 +19,12 @@ const ScopeVisualizerContext = createContext<
>(undefined);

export function ScopeVisualizerProvider({ children }: { children: ReactNode }) {
const scopeTests = usePluginData("scope-tests-plugin") as ScopeTests;
const scopeTests = usePluginData("scope-tests-plugin") as Fixture[];
const [rangeType, setRangeType] = useState<RangeType>("content");
const [renderWhitespace, setRenderWhitespace] = useState(true);
const fixtures = useMemo(
() =>
new Map(scopeTests.fixtures.map((fixture) => [fixture.name, fixture])),
[scopeTests.fixtures],
() => new Map(scopeTests.map((fixture) => [fixture.name, fixture])),
[scopeTests],
);
const value = useMemo(
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,19 @@ import type { BorderRadius, Highlight, Style } from "./types";
export function highlightsToDecorations(
highlights: Highlight[],
): DecorationItem[] {
return highlights.map((highlight): DecorationItem => {
const { start, end } = highlight.range;
return {
start,
end,
alwaysWrap: true,
properties: {
style: getStyleString(highlight.style),
},
};
});
return highlights.map(highlightToDecoration);
}

export function highlightToDecoration(highlight: Highlight): DecorationItem {
const { start, end } = highlight.range;
return {
start,
end,
alwaysWrap: true,
properties: {
style: getStyleString(highlight.style),
},
};
}

function getStyleString(style: Style): string {
Expand Down
9 changes: 6 additions & 3 deletions packages/app-web-docs/src/docs/components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import type {
PlaintextScopeSupportFacet,
ScopeSupportFacet,
Range,
TestCaseFixtureLegacy,
} from "@cursorless/lib-common";

export type RangeType = "content" | "removal" | "blend";
export type FacetValue = ScopeSupportFacet | PlaintextScopeSupportFacet;

export interface ScopeTests {
imports: Record<string, string[]>;
fixtures: Fixture[];
export interface RecordedTest {
path: string;
relativePath: string;
name: string;
fixture: TestCaseFixtureLegacy;
}

export interface Fixture {
Expand Down
Loading
Loading