Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 25a4f833cf fix(simulation): align vertical box drawing 2026-07-15 11:21:59 -04:00
Kit Langton 04fdf59db8 feat(simulation): expose normalized terminal frames 2026-07-15 11:09:53 -04:00
6 changed files with 100 additions and 0 deletions
@@ -110,6 +110,25 @@ export function matches(harness: Pick<Harness, "screen">, text: string) {
return harness.screen().includes(text)
}
export const capture = Effect.fn("SimulationActions.capture")(function* (harness: Harness) {
yield* Effect.tryPromise(() => harness.renderOnce())
const buffer = harness.renderer.currentRenderBuffer
return {
cols: buffer.width,
rows: buffer.height,
cursor: [0, 0] as const,
lines: buffer.getSpanLines().map((line) => ({
spans: line.spans.map((span) => ({
text: span.text,
fg: span.fg.toInts(),
bg: span.bg.toInts(),
attributes: span.attributes,
width: span.width,
})),
})),
} satisfies SimulationProtocol.Frontend.CapturedFrame
})
export const screenshot = Effect.fn("SimulationActions.screenshot")(function* (harness: Harness, name?: string) {
const filename = name ?? `screenshot-${crypto.randomUUID()}`
if (!filename || filename.includes("/") || filename.includes("\\") || extname(filename))
+2
View File
@@ -90,6 +90,8 @@ function drawBlockElement(context: SKRSContext2D, char: string, x: number, y: nu
if (char === "█") context.fillRect(x, y, width, CellHeight)
else if (char === "▀") context.fillRect(x, y, width, CellHeight / 2)
else if (char === "▄") context.fillRect(x, y + CellHeight / 2, width, CellHeight / 2)
else if (char === "┃") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight)
else if (char === "╹") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight / 2)
else return false
return true
}
@@ -6,6 +6,8 @@ import { SimulationRenderer } from "./renderer"
function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) {
switch (request.method) {
case "ui.capture":
return SimulationActions.capture(harness)
case "ui.screenshot":
return SimulationActions.screenshot(harness, request.params?.name)
case "ui.state":
+24
View File
@@ -95,6 +95,29 @@ export namespace Frontend {
export const Screenshot = Schema.String
export type Screenshot = Schema.Schema.Type<typeof Screenshot>
export const Color = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])
export type Color = Schema.Schema.Type<typeof Color>
export const CapturedFrame = Schema.Struct({
cols: Schema.Number,
rows: Schema.Number,
cursor: Schema.Tuple([Schema.Number, Schema.Number]),
lines: Schema.Array(
Schema.Struct({
spans: Schema.Array(
Schema.Struct({
text: Schema.String,
fg: Color,
bg: Color,
attributes: Schema.Number,
width: Schema.Number,
}),
),
}),
),
})
export interface CapturedFrame extends Schema.Schema.Type<typeof CapturedFrame> {}
export const RecordingFinish = Schema.String
export type RecordingFinish = Schema.Schema.Type<typeof RecordingFinish>
@@ -142,6 +165,7 @@ export namespace Frontend {
...JsonRpc.RequestFields,
method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]),
}),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.capture") }),
])
export type Request = Schema.Schema.Type<typeof Request>
export const decodeRequest = Schema.decodeUnknownSync(Request)
@@ -25,6 +25,17 @@ test("scopes the frontend control server and reports malformed JSON", async () =
result: { focused: { editor: false }, elements: [] },
})
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "ui.capture" }))
expect(yield* Queue.take(messages)).toMatchObject({
id: 2,
result: {
cols: 100,
rows: 40,
cursor: [0, 0],
lines: expect.any(Array),
},
})
socket.send("{")
expect(yield* Queue.take(messages)).toMatchObject({
id: null,
+42
View File
@@ -59,3 +59,45 @@ test("fills adjacent block elements without glyph gaps", async () => {
Array.from({ length: image.width }, () => [0, 0, 0, 255]).flat(),
)
})
test("draws heavy vertical box elements on cell boundaries", async () => {
const image = SimulationPng.screenshotFrame({
cols: 1,
rows: 2,
cursor: [0, 0],
lines: [
{
spans: [
{
text: "┃",
width: 1,
fg: RGBA.fromInts(255, 255, 255),
bg: RGBA.fromInts(0, 0, 0),
attributes: 0,
},
],
},
{
spans: [
{
text: "╹",
width: 1,
fg: RGBA.fromInts(255, 255, 255),
bg: RGBA.fromInts(0, 0, 0),
attributes: 0,
},
],
},
],
})
const canvas = createCanvas(image.width, image.height)
const context = canvas.getContext("2d")
context.drawImage(await loadImage(image.data), 0, 0)
expect([...context.getImageData(4, 0, 2, 30).data]).toEqual(
Array.from({ length: 60 }, () => [255, 255, 255, 255]).flat(),
)
expect([...context.getImageData(4, 30, 2, 10).data]).toEqual(
Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat(),
)
})