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
2 changes: 1 addition & 1 deletion web/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ A layered, dockable workspace: nine panels over a layer-stack project. A panel i
| :--- | :--- | :--- |
| Preview, Layers, Code, Knobs, Color Ramp | `panels/` | The editor proper. Layers are code (JS), pixel (RGBA buffer, edited in place and versioned by `rev`) or text; ramps are per code or text layer, knobs are one shared set |
| Pixel | `panels/PixelPanel.tsx` + `panels/pixel/` + `lib/lab/pixelTools.ts` | Pixel-art editor. The panel composes one hook per concern (`usePixelToolState`, `usePixelSelection`, `usePixelViewport`, `usePixelCanvases`, `usePixelPointer`, `usePixelKeyboard`) and two toolbar components; `pixelToCode.ts` turns a sprite into standalone pattern code |
| Text | `panels/TextPanel.tsx` + `lib/lab/text.ts`, `textRaster.ts`, `textFonts.ts` | Text layers. Words, size, place, alignment and font are drawn into the same RGBA buffer a pixel layer holds on every change, so the engine, masks, Graphic Export and both exports read a bitmap and never the font. Fonts are the site's own, the device's generic families, or a file the person picks, read with `FontFace` and kept in this browser's IndexedDB (`patternflow_lab_fonts_v1`) only when they ask. Drag on Preview moves the selected text |
| Text | `panels/TextPanel.tsx` + `lib/lab/text.ts`, `textRaster.ts`, `textFonts.ts` | Text layers. Words, size, place, alignment and font are drawn into the same RGBA buffer a pixel layer holds on every change, so the engine, masks, Graphic Export and both exports read a bitmap and never the font. Fonts are the site's own, the device's generic families, or a file the person picks, read with `FontFace` and kept in this browser's IndexedDB (`patternflow_lab_fonts_v1`) only when they ask. Drag on Preview moves the selected text. Copy as code hands the drawn letters out as a pattern that needs no font, through Pixel's `lib/lab/pixelToCode.ts`. `lib/lab/textExport.test.ts` compiles the To hardware header with the host's C++ compiler and checks it against the preview |
| Graphic Export | `panels/CapturePanel.tsx` + `lib/lab/capture/` | Stills and clips at print sizes, rendered by a second engine in a Web Worker (WebCodecs + `mediabunny`, all client-side), plus a GLSL shader twin for posters. `probe.ts` decides whether a pattern can be re-rendered at another size or must be upscaled |
| Director | `panels/DirectorPanel.tsx` + `panels/director/` + `lib/lab/director/` | Knob automation over time — keyframes, bezier eases — baked to `.pfs` show files (`src/lib/pattern/pfst.ts` is the codec) and exported as MIDI. The panel composes `useTransportView`, `useTimelineZoom`, `useShowEditing`, the file helpers and two bars; the lanes are `DirectorLanes.tsx` |

Expand Down
32 changes: 32 additions & 0 deletions web/src/app/pattern-lab/HardwareModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,38 @@ describe("HardwareModal", () => {
}
});

// Text and pixel layers are in the scaffold as pixels, so with no code
// layer the header is whole. The window used to count "0 layers to
// translate — 0 done" and send the person to a model anyway.
it("a stack of text alone is a finished header, with no model to ask", async () => {
const lab = useLabStore.getState();
const starter = lab.layers[0].id;
lab.addTextLayer();
useLabStore.getState().removeLayer(starter);
expect(useLabStore.getState().layers.map((layer) => layer.type)).toEqual(["text"]);
const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
try {
render(<Lab />);
expect(screen.getByText(/Nothing here needs a model/)).toHaveTextContent("The board needs no font for the letters.");
expect(screen.queryByText(/to translate/)).toBeNull();
expect(screen.queryByText(/Translating it is a model/)).toBeNull();
expect(screen.queryByText("Copy prompt")).toBeNull();

fireEvent.click(screen.getByText("Copy .h"));
expect(await screen.findByText("Copied ✓")).toBeTruthy();
const header = String(writeText.mock.calls[0][0]);
expect(header).toMatch(/^#pragma once$/m);
expect(header).toContain('"Text 1": text as drawn, RLE');
expect(header).not.toContain("TRANSLATION BEGIN");

fireEvent.click(screen.getByText("Next →"));
expect(screen.getByText("On to hardware")).toBeTruthy();
} finally {
delete (navigator as { clipboard?: unknown }).clipboard;
}
});

it("Esc over the Publish modal closes Publish alone and keeps the header", async () => {
render(<Lab />);
pasteHeaderAndContinue();
Expand Down
30 changes: 22 additions & 8 deletions web/src/app/pattern-lab/HardwareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { buildCppPrompt } from "@/lib/lab/cppPrompt";
import { currentPerformanceJson } from "@/lib/lab/director/publish";
import { needsFlatten } from "@/lib/lab/flatten";
import { labPatternName, useLabStore } from "@/lib/lab/store";
import { isCodeLayer } from "@/lib/lab/types";
import { isCodeLayer, isTextLayer } from "@/lib/lab/types";
import styles from "./PatternLab.module.css";
import local from "./HardwareModal.module.css";

Expand Down Expand Up @@ -94,6 +94,10 @@ export default function HardwareModal({
[stacked, exportData.scaffold, pastes, single, name],
);

// Text and pixel layers go into the scaffold as the pixels they show, so
// a stack with no code layer is the whole header, no model in the loop.
const nothingToTranslate = stacked && exportData.units.length === 0;

const translated = exportData.units.filter(
(unit) => (pastes[unit.index] ?? "").trim().length > 0 && cleanPastedUnit(pastes[unit.index] ?? "", unit.index) !== null,
).length;
Expand Down Expand Up @@ -186,8 +190,10 @@ export default function HardwareModal({
<p className={styles.modalNote}>
The board runs C++, so everything below — building an image, installing a module,
publishing as hardware-ready — needs this pattern as a <code>.h</code> header
first. Translating it is a model&rsquo;s job: copy the prompt, paste what comes
back.
first.
{nothingToTranslate
? null
: " Translating it is a model’s job: copy the prompt, paste what comes back."}
</p>

{stacked ? (
Expand All @@ -202,11 +208,19 @@ export default function HardwareModal({
spellCheck={false}
/>
</label>
<p className={styles.modalNote}>
{exportData.units.length} layer{exportData.units.length === 1 ? "" : "s"} to
translate — {translated} done. Untranslated layers keep a compiling stub, so a
partial port still builds.
</p>
{nothingToTranslate ? (
<p className={styles.modalNote}>
Nothing here needs a model. Text and pixel layers go into the header as the
pixels they show, so it is finished as it is.
{layers.some(isTextLayer) ? " The board needs no font for the letters." : null}
</p>
) : (
<p className={styles.modalNote}>
{exportData.units.length} layer{exportData.units.length === 1 ? "" : "s"} to
translate — {translated} done. Untranslated layers keep a compiling stub, so a
partial port still builds.
</p>
)}
{exportData.units.map((unit) => (
<div key={unit.index} className={local.hwUnit}>
<div className={local.hwUnitHead}>
Expand Down
12 changes: 8 additions & 4 deletions web/src/app/pattern-lab/panels/TextPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,16 @@
height: 32px;
}

.fontFile {
.fontFile,
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
}

.fontFile button {
.fontFile button,
.actions button {
min-height: 32px;
border-radius: 4px;
border: 1px solid rgba(23, 21, 18, 0.3);
Expand All @@ -90,7 +92,8 @@
cursor: pointer;
}

.fontFile button:hover:not(:disabled) {
.fontFile button:hover:not(:disabled),
.actions button:hover:not(:disabled) {
background: #171512;
color: #f7f3ea;
}
Expand Down Expand Up @@ -133,7 +136,8 @@
@media (hover: none) {
.field select,
.field input[type="number"],
.fontFile button {
.fontFile button,
.actions button {
min-height: 44px;
}

Expand Down
50 changes: 50 additions & 0 deletions web/src/app/pattern-lab/panels/TextPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,56 @@ describe("TextPanel", () => {
});
});

describe("Copy as code", () => {
function clipboard() {
const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
return writeText;
}

it("copies the letters as they are drawn now, and the new ones after an edit", async () => {
store().addTextLayer();
store().setTextSpec(text().id, { text: "ab", x: 2, y: 4 });
const writeText = clipboard();
render(<TextPanel />);
fireEvent.click(screen.getByRole("button", { name: "Copy as code" }));
await act(async () => {});
expect(screen.getByRole("button", { name: "Copied ✓" })).toBeTruthy();
const first = String(writeText.mock.calls[0][0]);
expect(first).toContain('// Text: "ab"');
// Two letters, a 2×2 block each: a 4 × 2 sprite.
expect(first).toContain("const SPRITE_W = 4, SPRITE_H = 2;");

fireEvent.change(screen.getByRole("textbox", { name: "Text" }), { target: { value: "abcd" } });
fireEvent.click(screen.getByRole("button", { name: "Copied ✓" }));
const second = String(writeText.mock.calls[1][0]);
expect(second).toContain('// Text: "abcd"');
expect(second).toContain("const SPRITE_W = 8, SPRITE_H = 2;");
});

it("says so when no letter is on the frame", () => {
store().addTextLayer();
store().setTextSpec(text().id, { text: "" });
const writeText = clipboard();
render(<TextPanel />);
fireEvent.click(screen.getByRole("button", { name: "Copy as code" }));
expect(writeText).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "No letters on the frame" })).toBeTruthy();
});

it("says so when the browser refuses the clipboard", async () => {
store().addTextLayer();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: () => Promise.reject(new Error("denied")) },
});
render(<TextPanel />);
fireEvent.click(screen.getByRole("button", { name: "Copy as code" }));
await act(async () => {});
expect(screen.getByRole("button", { name: "Copy failed" })).toBeTruthy();
});
});

describe("dragging text on Preview", () => {
it("moves the selected text by whole LED pixels", () => {
store().addTextLayer();
Expand Down
37 changes: 37 additions & 0 deletions web/src/app/pattern-lab/panels/TextPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
//
// Fonts come from the site, the device or a file the person picks
// (lib/lab/textFonts.ts). A file is read in this browser and goes nowhere.
//
// Copy as code takes the letters as they are drawn now, the way Pixel's does
// a drawing (lib/lab/pixelToCode.ts): a pattern that needs no font.

import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { useResettingState } from "@/lib/hooks/useResettingState";
import { buildPixelPatternCode } from "@/lib/lab/pixelToCode";
import { useActiveLayer, useLabStore } from "@/lib/lab/store";
import {
FONT_FILE_ACCEPT,
Expand All @@ -32,6 +37,7 @@ import {
type TextFill,
type TextLayer,
} from "@/lib/lab/types";
import type { CodeCopyState } from "./pixel/PixelToolbars";
import local from "./TextPanel.module.css";
import dock from "../LabPanels.module.css";

Expand All @@ -49,6 +55,13 @@ const FILL_LABEL: Record<TextFill, string> = {

const ALIGN_LABEL = { left: "Left", center: "Center", right: "Right" } as const;

const COPY_LABEL: Record<CodeCopyState, string> = {
idle: "Copy as code",
copied: "Copied ✓",
empty: "No letters on the frame",
failed: "Copy failed",
};

/** The font picker's entries, kept up as fonts are added, remembered or arrive. */
function useFontOptions(): FontOption[] {
const [options, setOptions] = useState(fontOptions);
Expand Down Expand Up @@ -136,9 +149,22 @@ function TextEditor({ layer }: { layer: TextLayer }) {
const [remember, setRemember] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [codeCopy, flashCodeCopy] = useResettingState<CodeCopyState>("idle", 1400);
const { spec } = layer;
const set = (patch: Parameters<typeof setTextSpec>[1]) => setTextSpec(layer.id, patch);

const copyAsCode = () => {
const code = buildPixelPatternCode(layer);
if (!code) {
flashCodeCopy("empty");
return;
}
navigator.clipboard
.writeText(code)
.then(() => flashCodeCopy("copied"))
.catch(() => flashCodeCopy("failed"));
};

const missing = resolveFont(spec.font).missing;
const groups = (["site", "system", "file"] as const).map((group) => ({
group,
Expand Down Expand Up @@ -318,6 +344,17 @@ function TextEditor({ layer }: { layer: TextLayer }) {
? "Drag the text in Preview to move it."
: "Drag the text in Preview to move it. The colors are this layer's Color Ramp."}
</p>

<div className={local.actions}>
<button
type="button"
onClick={copyAsCode}
title="Copy the letters as a standalone pattern (JS) that needs no font. Paste it into an AI chat and ask it to scroll, wave or twinkle them. The text layer stays as it is."
>
{COPY_LABEL[codeCopy]}
</button>
<span className={local.note}>The letters as pixels: the code needs no font. Change the words and copy again.</span>
</div>
</div>
</div>
);
Expand Down
Loading
Loading