├── .gitattributes
├── docs
├── public
│ ├── Inter.ttf
│ ├── avatar.jpeg
│ ├── favicon.ico
│ ├── blog
│ │ └── 0.1.0.png
│ ├── Inter-Regular.otf
│ ├── Inter-SemiBold.otf
│ └── logo.svg
├── bun.lockb
├── .gitignore
├── .prettierrc
├── postcss.config.js
├── app
│ ├── components
│ │ ├── PackageJson.tsx
│ │ ├── Tabs.tsx
│ │ ├── Markdown.tsx
│ │ ├── withClient.tsx
│ │ ├── CopyCode.tsx
│ │ ├── Blogpost.tsx
│ │ ├── Table.tsx
│ │ ├── BaseEditor.tsx
│ │ ├── Enums.tsx
│ │ ├── Tabs.client.tsx
│ │ ├── TypeTooltip.tsx
│ │ ├── Sidebar.tsx
│ │ ├── HighlightMatches.tsx
│ │ ├── Outline.tsx
│ │ ├── CodeBlock.tsx
│ │ ├── BaseEditor.client.tsx
│ │ └── Body.tsx
│ ├── page.mdx
│ ├── math-library
│ │ ├── page.mdx
│ │ └── Api.tsx
│ ├── blog
│ │ ├── (posts)
│ │ │ └── layout.tsx
│ │ └── page.tsx
│ ├── interactivity
│ │ ├── Api.tsx
│ │ └── page.mdx
│ ├── getMetadata.ts
│ ├── text-rendering
│ │ ├── Api.tsx
│ │ └── page.mdx
│ ├── examples
│ │ ├── page.mdx
│ │ └── editors
│ │ │ ├── Basic.tsx
│ │ │ └── Scrollbars.tsx
│ ├── roadmap
│ │ └── page.mdx
│ ├── renderer
│ │ ├── Api.tsx
│ │ └── page.mdx
│ ├── globals.css
│ ├── styling
│ │ ├── Api.tsx
│ │ └── page.mdx
│ ├── layout-engine
│ │ ├── Api.tsx
│ │ └── page.mdx
│ ├── code-theme.css
│ ├── layout.tsx
│ ├── search
│ │ └── route.tsx
│ └── getting-started
│ │ └── page.mdx
├── index.d.ts
├── .eslintrc.cjs
├── next-env.d.ts
├── tsconfig.json
├── mdx-components.tsx
├── remarkTypography.mjs
├── next.config.mjs
├── remarkUniqueIds.mjs
├── tailwind.config.js
└── package.json
├── .prettierrc
├── bun.lockb
├── vercel.json
├── .npmignore
├── examples
├── public
│ ├── Inter.ttf
│ ├── Inter-Bold.ttf
│ ├── Lora-Regular.ttf
│ ├── ComicNeue-Bold.ttf
│ ├── Inter-SemiBold.ttf
│ ├── Rubik-Regular.ttf
│ └── JetBrainsMono-Regular.ttf
├── measure.ts
├── index.html
└── main.ts
├── .gitignore
├── src
├── utils
│ ├── invariant.ts
│ ├── getByTestId.ts
│ ├── createTextureFromBitmap.ts
│ ├── Tree.ts
│ ├── LRUCache.ts
│ ├── Queue.ts
│ ├── Queue.test.ts
│ ├── parseColor.test.ts
│ ├── Tree.test.ts
│ └── parseColor.ts
├── widgets
│ ├── colors.ts
│ ├── updateText.ts
│ ├── Button.ts
│ └── updateSelection.ts
├── layout
│ ├── Node.ts
│ ├── Text.ts
│ ├── compose.ts
│ ├── paint.test.ts
│ ├── BaseView.ts
│ ├── eventTypes.ts
│ └── View.ts
├── consts.ts
├── math
│ ├── packShelves.test.ts
│ ├── triangulatePolygon.test.ts
│ ├── triangulateLine.ts
│ ├── Vec2.ts
│ ├── Vec3.ts
│ ├── Mat4.test.ts
│ ├── utils.ts
│ ├── Vec4.ts
│ ├── packShelves.ts
│ └── triangulatePolygon.ts
├── renderer
│ ├── Renderer.ts
│ └── CanvasRenderer.ts
├── font
│ ├── shapeText.test.ts
│ ├── generateGlyphToClassMap.ts
│ ├── types.ts
│ ├── calculateGlyphQuads.ts
│ ├── toSDF.ts
│ ├── renderFontAtlas.ts
│ ├── prepareLookups.ts
│ ├── BinaryReader.ts
│ ├── generateKerningFunction.ts
│ └── shapeText.ts
├── hitTest.ts
└── index.ts
├── tsconfig.build.json
├── vitest.config.ts
├── .vscode
├── settings.json
└── launch.json
├── vite.config.ts
├── CONTRIBUTING.md
├── tsconfig.json
├── LICENSE
├── .github
└── workflows
│ ├── ci.yml
│ └── release.yml
├── MAINTAINERS.md
├── package.json
├── .eslintrc.cjs
└── test.html
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.lockb binary diff=lockb
--------------------------------------------------------------------------------
/docs/public/Inter.ttf:
--------------------------------------------------------------------------------
1 | ../../public/Inter.ttf
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100
3 | }
4 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/bun.lockb
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "silent": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/docs/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/bun.lockb
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | .next
2 | app/types.json
3 | node_modules/
4 | tsconfig.tsbuildinfo
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # For some reason interTTF.json from tests was getting copied to dist/.
2 | assets/
3 |
--------------------------------------------------------------------------------
/docs/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-tailwindcss"],
3 | "printWidth": 100
4 | }
5 |
--------------------------------------------------------------------------------
/docs/public/avatar.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/avatar.jpeg
--------------------------------------------------------------------------------
/docs/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/favicon.ico
--------------------------------------------------------------------------------
/examples/public/Inter.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Inter.ttf
--------------------------------------------------------------------------------
/docs/public/blog/0.1.0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/blog/0.1.0.png
--------------------------------------------------------------------------------
/docs/public/Inter-Regular.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/Inter-Regular.otf
--------------------------------------------------------------------------------
/docs/public/Inter-SemiBold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/docs/public/Inter-SemiBold.otf
--------------------------------------------------------------------------------
/examples/public/Inter-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Inter-Bold.ttf
--------------------------------------------------------------------------------
/examples/public/Lora-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Lora-Regular.ttf
--------------------------------------------------------------------------------
/examples/public/ComicNeue-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/ComicNeue-Bold.ttf
--------------------------------------------------------------------------------
/examples/public/Inter-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Inter-SemiBold.ttf
--------------------------------------------------------------------------------
/examples/public/Rubik-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/Rubik-Regular.ttf
--------------------------------------------------------------------------------
/docs/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {},
4 | tailwindcss: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .vercel/
3 | node_modules/
4 | coverage/
5 | dist/
6 | *.tsbuildinfo
7 | .rollup.cache/
8 | .sandpack-files/
9 |
--------------------------------------------------------------------------------
/docs/app/components/PackageJson.tsx:
--------------------------------------------------------------------------------
1 | import _packageJson from "../../../package.json";
2 | export const packageJson = _packageJson;
3 |
--------------------------------------------------------------------------------
/examples/public/JetBrainsMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tchayen/red-otter/HEAD/examples/public/JetBrainsMono-Regular.ttf
--------------------------------------------------------------------------------
/docs/index.d.ts:
--------------------------------------------------------------------------------
1 | import FlexsearchTypes from "@types/flexsearch";
2 |
3 | declare module "flexsearch" {
4 | export = FlexsearchTypes;
5 | }
6 |
--------------------------------------------------------------------------------
/docs/app/components/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import { List, Trigger, Root, Content } from "./Tabs.client";
2 |
3 | export const Tabs = { Content, List, Root, Trigger };
4 |
--------------------------------------------------------------------------------
/src/utils/invariant.ts:
--------------------------------------------------------------------------------
1 | export function invariant(value: unknown, message?: string): asserts value {
2 | if (!value) {
3 | throw new Error(message);
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/docs/app/page.mdx:
--------------------------------------------------------------------------------
1 | import Readme from "../../README.md";
2 | import { getMetadata } from "./getMetadata";
3 | export const metadata = getMetadata("Homepage");
4 |
5 |
6 |
--------------------------------------------------------------------------------
/docs/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ["../.eslintrc.cjs", "next/core-web-vitals"],
3 | parser: "@typescript-eslint/parser",
4 | parserOptions: {
5 | project: "./tsconfig.json",
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/docs/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "dist",
5 | "noEmit": false,
6 | "declaration": true,
7 | "sourceMap": true
8 | },
9 | "include": ["src"]
10 | }
11 |
--------------------------------------------------------------------------------
/examples/measure.ts:
--------------------------------------------------------------------------------
1 | export function measure(tag: string, operation: () => void) {
2 | const start = performance.now();
3 | operation();
4 | const end = performance.now();
5 | console.log(`${tag} took ${(end - start).toFixed(2)}ms.`);
6 | }
7 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 |
3 | export default defineConfig({
4 | test: {
5 | coverage: {
6 | provider: "istanbul",
7 | reporter: ["lcov"],
8 | },
9 | include: ["src/**/*.test.ts"],
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/src/widgets/colors.ts:
--------------------------------------------------------------------------------
1 | export const colors = {
2 | gray: [
3 | "#111111",
4 | "#191919",
5 | "#222222",
6 | "#2A2A2A",
7 | "#313131",
8 | "#3A3A3A",
9 | "#484848",
10 | "#606060",
11 | "#6E6E6E",
12 | "#7B7B7B",
13 | "#B4B4B4",
14 | "#EEEEEE",
15 | ],
16 | } as const;
17 |
--------------------------------------------------------------------------------
/docs/app/math-library/page.mdx:
--------------------------------------------------------------------------------
1 | import { Api } from "./Api";
2 | import { getMetadata } from "../getMetadata";
3 | export const metadata = getMetadata("Math Library");
4 |
5 | # Math Library
6 |
7 | Since the whole project is entirely zero-dependency, it comes with its own math library.
8 |
9 | ## API
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Example
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/docs/app/blog/(posts)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import type { PropsWithChildren } from "react";
3 |
4 | export default function layout({ children }: PropsWithChildren) {
5 | return (
6 | <>
7 |
8 | <- Back to blog
9 |
10 | {children}
11 | >
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.rulers": [{ "column": 100 }],
3 | // Auto-format on save.
4 | "editor.formatOnSave": true,
5 | // Auto-fix eslint errors/warnings on save.
6 | "editor.codeActionsOnSave": {
7 | "source.fixAll.eslint": "explicit"
8 | },
9 | // Inform VSCode that there are multiple ESLint configs.
10 | "eslint.workingDirectories": [".", "docs"]
11 | }
12 |
--------------------------------------------------------------------------------
/docs/app/interactivity/Api.tsx:
--------------------------------------------------------------------------------
1 | import types from "../types.json";
2 | import { ApiBlock, Class } from "../components/ApiBlocks";
3 |
4 | export function Api() {
5 | return (
6 |
7 | {Object.values(types.classes)
8 | .filter((c) => c.source.startsWith("/EventManager"))
9 | .map((c, i) => {
10 | return ;
11 | })}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from "node:path";
2 | import { defineConfig } from "vite";
3 | import dts from "vite-plugin-dts";
4 |
5 | export default defineConfig({
6 | build: {
7 | lib: {
8 | entry: resolve(__dirname, "src/index.ts"),
9 | // fileName: () => `index.js`,
10 | formats: ["es"],
11 | name: "RedOtter",
12 | },
13 | outDir: ".sandpack-files",
14 | },
15 | plugins: [dts({ rollupTypes: true, strictOutput: true })],
16 | });
17 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "chrome",
9 | "request": "launch",
10 | "name": "Launch Chrome against localhost",
11 | "url": "http://localhost:5005",
12 | "webRoot": "${workspaceFolder}"
13 | }
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | It's still early stage and there are some fundamental decisions to be made in which I probably don't expect much external input.
4 |
5 | However, the surface of the library is expanding so there's a lot of things to optimize and improve. See potential to make some function faster? Feel free to open an issue or even a PR.
6 |
7 | See some bigger problem that can't be solved easily without trade-offs? Please open an issue.
8 |
9 | See a bug? Please open an issue, I really appreciate it.
10 |
--------------------------------------------------------------------------------
/src/utils/getByTestId.ts:
--------------------------------------------------------------------------------
1 | import { BaseView } from "../layout/BaseView";
2 | import type { Node } from "../layout/Node";
3 |
4 | export function getByTestId(root: Node, testId: string): Node | null {
5 | let c = root.firstChild;
6 | while (c) {
7 | if (c.testID === testId) {
8 | return c;
9 | }
10 | if (c instanceof BaseView) {
11 | const inSubTree = getByTestId(c, testId);
12 | if (inSubTree) {
13 | return inSubTree;
14 | }
15 | }
16 | c = c.next;
17 | }
18 | return null;
19 | }
20 |
--------------------------------------------------------------------------------
/docs/app/blog/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | // @ts-expect-error MDX posts are not recognized by TS.
4 | import BigReset from "./(posts)/0-1-0-the-big-reset/page.mdx";
5 | import { getMetadata } from "../getMetadata";
6 |
7 | export const metadata = getMetadata("Blog");
8 |
9 | export default function Page() {
10 | return (
11 | <>
12 |
13 |
14 | Direct link ->
15 |
16 | >
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "preserve",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "moduleResolution": "node",
7 | "plugins": [{ "name": "next" }]
8 | },
9 | "include": [
10 | "next-env.d.ts",
11 | "**/*.ts",
12 | "**/*.tsx",
13 | ".next/types/**/*.ts",
14 | ".eslintrc.cjs",
15 | "postcss.config.js",
16 | "tailwind.config.js",
17 | "next.config.mjs",
18 | "remarkTypography.mjs",
19 | "remarkUniqueIds.mjs"
20 | ],
21 | "exclude": ["node_modules"]
22 | }
23 |
--------------------------------------------------------------------------------
/docs/app/components/Markdown.tsx:
--------------------------------------------------------------------------------
1 | import { evaluateSync } from "@mdx-js/mdx";
2 | import { myComponents } from "../../mdx-components";
3 | import { Fragment, createElement } from "react";
4 |
5 | /**
6 | * When Markdown needs to be rendered from a string.
7 | */
8 | export function Markdown({ children }: { children: string }) {
9 | const jsxContent = evaluateSync(children, {
10 | Fragment,
11 | format: "md",
12 | jsx: createElement,
13 | jsxs: createElement,
14 | });
15 |
16 | const Content = jsxContent.default;
17 |
18 | return ;
19 | }
20 |
--------------------------------------------------------------------------------
/src/layout/Node.ts:
--------------------------------------------------------------------------------
1 | import type { ExactLayoutProps, LayoutNodeState } from "./styling";
2 |
3 | /**
4 | * Basic node in the layout tree. Containing its state and style information as well as pointers to
5 | * its children, siblings, and parent.
6 | */
7 | export interface Node {
8 | /**
9 | * State of the node updated by the layout engine.
10 | */
11 | _state: LayoutNodeState;
12 | _style: ExactLayoutProps;
13 | firstChild: Node | null;
14 | lastChild: Node | null;
15 | next: Node | null;
16 | parent: Node | null;
17 | prev: Node | null;
18 | testID: string | null;
19 | }
20 |
--------------------------------------------------------------------------------
/docs/app/components/withClient.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import type { ComponentProps, ComponentType } from "react";
3 | import { useEffect, useState } from "react";
4 |
5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
6 | export function withClient>(Component: T) {
7 | return function ClientComponent(props: ComponentProps) {
8 | const [mounted, setMounted] = useState(false);
9 |
10 | useEffect(() => {
11 | setMounted(true);
12 | }, []);
13 |
14 | if (!mounted) {
15 | return null;
16 | }
17 |
18 | return ;
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/createTextureFromBitmap.ts:
--------------------------------------------------------------------------------
1 | export function createTextureFromImageBitmap(
2 | device: GPUDevice,
3 | imageBitmap: ImageBitmap,
4 | ): GPUTexture {
5 | const size = { height: imageBitmap.height, width: imageBitmap.width };
6 |
7 | const texture = device.createTexture({
8 | format: "rgba8unorm",
9 | label: "image bitmap",
10 | size,
11 | usage:
12 | GPUTextureUsage.TEXTURE_BINDING |
13 | GPUTextureUsage.COPY_DST |
14 | GPUTextureUsage.RENDER_ATTACHMENT,
15 | });
16 |
17 | device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, size);
18 |
19 | return texture;
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "downlevelIteration": true,
5 | "esModuleInterop": true,
6 | "incremental": true,
7 | "isolatedModules": true,
8 | "lib": ["es2022", "dom", "dom.iterable"],
9 | "module": "ESNext",
10 | "moduleResolution": "Bundler",
11 | "noEmit": true,
12 | "noUncheckedIndexedAccess": true,
13 | "resolveJsonModule": true,
14 | "skipLibCheck": true,
15 | "strict": true,
16 | "target": "es2022",
17 | "typeRoots": ["./node_modules/@webgpu/types"]
18 | },
19 | "include": ["src", "examples", ".eslintrc.cjs", "vite.config.ts", "vitest.config.ts"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/docs/app/getMetadata.ts:
--------------------------------------------------------------------------------
1 | import { packageJson } from "./components/PackageJson";
2 |
3 | export function getMetadata(title: string, description?: string, publishedTime?: string) {
4 | let url = `/og?title=${encodeURI(title)}`;
5 | if (publishedTime) {
6 | url += `&publishedTime=${publishedTime}`;
7 | }
8 |
9 | return {
10 | description: description ?? packageJson.description,
11 | openGraph: {
12 | description: description ?? packageJson.description,
13 | images: [
14 | {
15 | height: 630,
16 | url,
17 | width: 1200,
18 | },
19 | ],
20 | locale: "en_US",
21 | title,
22 | type: "website",
23 | },
24 | title,
25 | };
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/Tree.ts:
--------------------------------------------------------------------------------
1 | export class Tree {
2 | next: Tree | null = null;
3 | prev: Tree | null = null;
4 | firstChild: Tree | null = null;
5 | lastChild: Tree | null = null;
6 | parent: Tree | null = null;
7 |
8 | constructor(public value: T) {}
9 |
10 | add(node: Tree): Tree {
11 | node.parent = this;
12 |
13 | if (this.firstChild === null) {
14 | this.firstChild = node;
15 | this.lastChild = node;
16 | } else {
17 | if (this.lastChild === null) {
18 | throw new Error("Last child must be set.");
19 | }
20 |
21 | node.prev = this.lastChild;
22 | this.lastChild.next = node;
23 | this.lastChild = node;
24 | }
25 |
26 | return node;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/consts.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_LINE_HEIGHT_MULTIPLIER = 1.4;
2 |
3 | export const CROSS_AXIS_SIZE = 10;
4 | export const SCROLLBAR_COLOR = "#191919";
5 | export const SCROLLBAR_CORNER_COLOR = "#191919";
6 | export const SCROLLBAR_TRACK_COLOR = "#3A3A3A";
7 | export const SCROLLBAR_TRACK_HOVER_COLOR = "#484848";
8 |
9 | export const isWindowDefined = typeof window !== "undefined";
10 |
11 | const windowWidth = isWindowDefined ? window.innerWidth : 1024;
12 | const windowHeight = isWindowDefined ? window.innerHeight : 768;
13 |
14 | export const settings = {
15 | rectangleBufferSize: 16 * 4096,
16 | sampleCount: 4,
17 | textBufferSize: 16 * 100_000,
18 | windowHeight,
19 | windowWidth,
20 | };
21 |
22 | export type Settings = typeof settings;
23 |
--------------------------------------------------------------------------------
/src/math/packShelves.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { Vec2 } from "./Vec2";
3 | import { packShelves } from "./packShelves";
4 |
5 | describe("packShelves", () => {
6 | it("should work", () => {
7 | const rectangles = [
8 | new Vec2(10, 10),
9 | new Vec2(40, 40),
10 | new Vec2(15, 15),
11 | new Vec2(15, 15),
12 | new Vec2(50, 50),
13 | ];
14 | const packing = packShelves(rectangles);
15 |
16 | expect(packing.width).toBe(128);
17 | expect(packing.height).toBe(128);
18 |
19 | expect(packing.positions[0]?.x).toBe(40);
20 | expect(packing.positions[0]?.y).toBe(65);
21 | expect(packing.positions[3]?.x).toBe(55);
22 | expect(packing.positions[3]?.y).toBe(50);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/math/triangulatePolygon.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { Vec2 } from "./Vec2";
3 | import { triangulatePolygon } from "./triangulatePolygon";
4 |
5 | describe("triangulatePolygon", () => {
6 | it("works", () => {
7 | const data = [
8 | new Vec2(0, 80),
9 | new Vec2(100, 0),
10 | new Vec2(190, 85),
11 | new Vec2(270, 35),
12 | new Vec2(345, 140),
13 | new Vec2(255, 130),
14 | new Vec2(215, 210),
15 | new Vec2(140, 70),
16 | new Vec2(45, 95),
17 | new Vec2(50, 185),
18 | ];
19 | const triangles = triangulatePolygon(data);
20 | expect(triangles).toHaveLength(24);
21 | expect(triangles[0]!.equals(new Vec2(50, 185))).toBeTruthy();
22 | expect(triangles[1]!.equals(new Vec2(45, 95))).toBeTruthy();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/renderer/Renderer.ts:
--------------------------------------------------------------------------------
1 | import type { Vec2 } from "../math/Vec2";
2 | import type { Vec4 } from "../math/Vec4";
3 | import type { TextAlign } from "../layout/styling";
4 |
5 | export interface Renderer {
6 | rectangle(
7 | color: Vec4,
8 | position: Vec2,
9 | size: Vec2,
10 | corners: Vec4,
11 | borderWidth: Vec4,
12 | borderColor: Vec4,
13 | clipStart: Vec2,
14 | clipSize: Vec2,
15 | clipCorners: Vec4,
16 | ): void;
17 |
18 | render(context: CanvasRenderingContext2D | GPUCommandEncoder): void;
19 |
20 | text(
21 | text: string,
22 | position: Vec2,
23 | fontName: string,
24 | fontSize: number,
25 | color: Vec4,
26 | textAlignment: TextAlign,
27 | clipStart: Vec2,
28 | clipSize: Vec2,
29 | options?: { lineHeight?: number; maxWidth?: number; noWrap?: boolean },
30 | ): void;
31 | }
32 |
--------------------------------------------------------------------------------
/src/font/shapeText.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { shapeText } from "./shapeText";
3 | import interTTF from "../../assets/interTTF.json"; // This is a 1.8MB JSON file (95kB gzipped).
4 | import { prepareLookups } from "../font/prepareLookups";
5 | import type { TTF } from "../font/parseTTF";
6 | import { TextAlign } from "../layout/styling";
7 |
8 | const alphabet =
9 | "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890 ,.:•-–()[]{}!?@#$%^&*+=/\\|<>`~’'\";_";
10 | const lookups = prepareLookups(
11 | [{ buffer: new ArrayBuffer(0), name: "Inter", ttf: interTTF as TTF }],
12 | { alphabet, fontSize: 150 },
13 | );
14 |
15 | describe("shapeText", () => {
16 | it("should work", () => {
17 | expect(true).toBe(true);
18 |
19 | shapeText(lookups, "Inter", 15, 20, "Hello, World!", TextAlign.Left);
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/docs/app/components/CopyCode.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { twMerge } from "tailwind-merge";
4 | import { outline } from "./tags";
5 | import { useState } from "react";
6 |
7 | export function CopyCode({ code }: { code: string }) {
8 | const [state, setState] = useState<"idle" | "success">("idle");
9 | return (
10 | {
17 | navigator.clipboard.writeText(code);
18 |
19 | setState("success");
20 | setTimeout(() => {
21 | setState("idle");
22 | }, 2000);
23 | }}
24 | >
25 | {state === "idle" ? "Copy" : "Copied ✓"}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/widgets/updateText.ts:
--------------------------------------------------------------------------------
1 | import type { KeyboardEvent } from "../layout/eventTypes";
2 |
3 | export function updateText(
4 | value: string,
5 | cursor: number,
6 | mark: number,
7 | event: KeyboardEvent,
8 | ): { cursor: number; mark: number; value: string } {
9 | const valueAsNumbers = value.split("").map((character) => character.charCodeAt(0));
10 | const selectionLength = Math.abs(cursor - mark);
11 |
12 | // If selection length is greater than 0, delete the selection first.
13 | if (selectionLength > 0) {
14 | valueAsNumbers.splice(Math.min(cursor, mark), selectionLength);
15 | cursor = Math.min(cursor, mark);
16 | mark = cursor;
17 | }
18 |
19 | valueAsNumbers.splice(cursor, 0, event.code);
20 | cursor += 1;
21 | mark = cursor;
22 |
23 | return {
24 | cursor,
25 | mark,
26 | value: valueAsNumbers.map((code) => String.fromCharCode(code)).join(""),
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/docs/app/components/Blogpost.tsx:
--------------------------------------------------------------------------------
1 | import { format } from "date-fns";
2 | import Image from "next/image";
3 | import Link from "next/link";
4 | import { slugify } from "./tags";
5 |
6 | export function Blogpost({ title, date, author }: { author: string; date: string; title: string }) {
7 | const avatar =
8 | author === "Tomasz Czajecki" ? (
9 |
16 | ) : null;
17 |
18 | return (
19 | <>
20 |
21 | {title}
22 |
23 |
24 | {format(new Date(date), "do LLL y")} by {avatar}
25 | {author}
26 |
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/docs/app/math-library/Api.tsx:
--------------------------------------------------------------------------------
1 | import types from "../types.json";
2 | import { ApiBlock, Class, Function } from "../components/ApiBlocks";
3 | import { Hr } from "../components/tags";
4 |
5 | export function Api() {
6 | return (
7 |
8 | {Object.values(types.classes)
9 | .filter((c) => c.source.startsWith("/math"))
10 | .map((c, i) => {
11 | return (
12 | <>
13 |
14 |
15 | >
16 | );
17 | })}
18 | {Object.values(types.functions)
19 | .filter((f) => f.source.startsWith("/math"))
20 | .map((f, i) => {
21 | return (
22 | <>
23 |
24 |
25 | >
26 | );
27 | })}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/docs/app/text-rendering/Api.tsx:
--------------------------------------------------------------------------------
1 | import types from "../types.json";
2 | import { ApiBlock, Class, Function } from "../components/ApiBlocks";
3 | import { Hr } from "../components/tags";
4 |
5 | export function Api() {
6 | return (
7 |
8 | {Object.values(types.functions)
9 | .filter((f) => f.source.startsWith("/font"))
10 | .map((f, i) => {
11 | return (
12 | <>
13 |
14 |
15 | >
16 | );
17 | })}
18 | {Object.values(types.classes)
19 | .filter((c) => c.source.startsWith("/font"))
20 | .map((c, i) => {
21 | return (
22 | <>
23 |
24 |
25 | >
26 | );
27 | })}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/docs/mdx-components.tsx:
--------------------------------------------------------------------------------
1 | import type { MDXComponents } from "mdx/types";
2 | import {
3 | A,
4 | H2,
5 | H4,
6 | Img,
7 | Li,
8 | Ol,
9 | P,
10 | Strong,
11 | Ul,
12 | Code,
13 | H1,
14 | H3,
15 | Box,
16 | Hr,
17 | } from "./app/components/tags";
18 | import { Enums } from "./app/components/Enums";
19 | import { CodeBlock } from "./app/components/CodeBlock";
20 | import { Blogpost } from "./app/components/Blogpost";
21 |
22 | export const myComponents = {
23 | A,
24 | Blogpost,
25 | Box,
26 | Code,
27 | Enums,
28 | a: A,
29 | blockquote: Box,
30 | code: Code,
31 | h1: H1,
32 | h2: H2,
33 | h3: H3,
34 | h4: H4,
35 | hr: Hr,
36 | img: Img,
37 | li: Li,
38 | ol: Ol,
39 | p: P,
40 | pre: CodeBlock,
41 | strong: Strong,
42 | ul: Ul,
43 | };
44 |
45 | export function useMDXComponents(components: MDXComponents): MDXComponents {
46 | return {
47 | ...components,
48 | ...myComponents,
49 | };
50 | }
51 |
--------------------------------------------------------------------------------
/src/font/generateGlyphToClassMap.ts:
--------------------------------------------------------------------------------
1 | import type { ClassDefFormat1, ClassDefFormat2 } from "./parseTTF";
2 |
3 | /**
4 | * @param classDef class definition table.
5 | * @returns a map from glyph ID to class ID.
6 | */
7 | export function generateGlyphToClassMap(
8 | classDef: ClassDefFormat1 | ClassDefFormat2,
9 | ): Map {
10 | const glyphToClass = new Map();
11 |
12 | if (classDef.format === 1) {
13 | // ClassDefFormat1
14 | let glyphID = classDef.startGlyph;
15 | for (const classValue of classDef.classes) {
16 | glyphToClass.set(glyphID, classValue);
17 | glyphID++;
18 | }
19 | } else if (classDef.format === 2) {
20 | // ClassDefFormat2
21 | for (const range of classDef.ranges) {
22 | for (let glyphID = range.startGlyphID; glyphID <= range.endGlyphID; glyphID++) {
23 | glyphToClass.set(glyphID, range.class);
24 | }
25 | }
26 | }
27 |
28 | return glyphToClass;
29 | }
30 |
--------------------------------------------------------------------------------
/src/widgets/Button.ts:
--------------------------------------------------------------------------------
1 | import type { Lookups } from "../font/types";
2 | import { Text } from "../layout/Text";
3 | import { View } from "../layout/View";
4 | import type { TextStyleProps, ViewStyleProps } from "../layout/styling";
5 |
6 | export class Button extends View {
7 | constructor(props: {
8 | label: string;
9 | lookups: Lookups;
10 | onClick?(): void;
11 | style?: ViewStyleProps;
12 | testID?: string;
13 | textStyle?: Partial;
14 | }) {
15 | // Put default styles here.
16 | const mergedStyle: ViewStyleProps = { backgroundColor: "#ffd000", ...props.style };
17 | super({ ...props, style: mergedStyle });
18 |
19 | this.add(
20 | new Text(props.label, {
21 | lookups: props.lookups,
22 | style: {
23 | color: "#FFFFFF",
24 | fontName: "InterBold",
25 | fontSize: 14,
26 | ...props.textStyle,
27 | },
28 | }),
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/docs/remarkTypography.mjs:
--------------------------------------------------------------------------------
1 | import { visit } from "unist-util-visit";
2 |
3 | /**
4 | * @typedef {import('unist').Node} Node
5 | * @typedef {Object} TextNode
6 | * @property {string} type
7 | * @property {string} value
8 | */
9 |
10 | /**
11 | * Type guard for checking if a node is a TextNode.
12 | *
13 | * @param {Node} node
14 | * @returns {node is TextNode}
15 | */
16 | function isTextNode(node) {
17 | return node.type === "text";
18 | }
19 |
20 | /**
21 | * Replaces straight quotes with curly ones in a TextNode.
22 | *
23 | * @param {TextNode} node
24 | */
25 | function replaceQuotes(node) {
26 | node.value = node.value.replaceAll('"', "“").replaceAll("'", "’");
27 | }
28 |
29 | /**
30 | * Transformer function for the remark plugin.
31 | *
32 | * @returns {import('unified').Transformer}
33 | */
34 | function remarkTypography() {
35 | return (tree) => {
36 | visit(tree, "text", (node) => {
37 | if (isTextNode(node)) {
38 | replaceQuotes(node);
39 | }
40 | });
41 | };
42 | }
43 |
44 | export default remarkTypography;
45 |
--------------------------------------------------------------------------------
/src/utils/LRUCache.ts:
--------------------------------------------------------------------------------
1 | export class LRUCache {
2 | private readonly capacity: number;
3 | private readonly cache: Map;
4 |
5 | constructor(capacity: number) {
6 | this.capacity = capacity;
7 | this.cache = new Map();
8 | }
9 |
10 | get(key: K): V | undefined {
11 | if (!this.cache.has(key)) {
12 | return undefined;
13 | }
14 |
15 | // Move the accessed item to the end of the map (most recently used).
16 | const value = this.cache.get(key);
17 | this.cache.delete(key);
18 | this.cache.set(key, value!);
19 | return value;
20 | }
21 |
22 | put(key: K, value: V): void {
23 | if (this.cache.has(key)) {
24 | this.cache.delete(key);
25 | }
26 |
27 | this.cache.set(key, value);
28 |
29 | // Remove the least recently used item if we're over capacity.
30 | if (this.cache.size > this.capacity) {
31 | const leastRecentlyUsedKey = this.cache.keys().next().value;
32 | this.cache.delete(leastRecentlyUsedKey);
33 | }
34 | }
35 |
36 | size(): number {
37 | return this.cache.size;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/font/types.ts:
--------------------------------------------------------------------------------
1 | import type { Vec2 } from "../math/Vec2";
2 | import type { Vec4 } from "../math/Vec4";
3 | import type { TTF } from "./parseTTF";
4 |
5 | export type Glyph = {
6 | character: string;
7 | height: number;
8 | /**
9 | * Unicode code point. Do not confuse with TTF glyph index.
10 | */
11 | id: number;
12 | /**
13 | * Left side bearing.
14 | */
15 | lsb: number;
16 | /**
17 | * Right side bearing.
18 | */
19 | rsb: number;
20 | width: number;
21 | x: number;
22 | y: number;
23 | };
24 |
25 | export type KerningFunction = (leftGlyph: number, rightGlyph: number) => number;
26 |
27 | export type Lookups = {
28 | atlas: {
29 | fontSize: number;
30 | height: number;
31 | positions: Array;
32 | sizes: Array;
33 | width: number;
34 | };
35 | fonts: Array<{
36 | ascender: number;
37 | buffer: ArrayBuffer;
38 | capHeight: number;
39 | glyphs: Map;
40 | kern: KerningFunction;
41 | name: string;
42 | ttf: TTF;
43 | unitsPerEm: number;
44 | }>;
45 | uvs: Map;
46 | };
47 |
--------------------------------------------------------------------------------
/src/layout/Text.ts:
--------------------------------------------------------------------------------
1 | import type { Lookups } from "../font/types";
2 | import type { Node } from "./Node";
3 | import type { ExactLayoutProps, LayoutNodeState, LayoutProps, TextStyleProps } from "./styling";
4 | import { defaultLayoutNodeState, normalizeLayoutProps } from "./styling";
5 |
6 | /**
7 | * Basic text node. The only way to create text. It cannot have children.
8 | */
9 | export class Text implements Node {
10 | testID: string | null;
11 | next: Node | null = null;
12 | prev: Node | null = null;
13 | firstChild: Node | null = null;
14 | lastChild: Node | null = null;
15 | parent: Node | null = null;
16 | /**
17 | * Should always be normalized.
18 | */
19 | _style: TextStyleProps & ExactLayoutProps;
20 | _state: LayoutNodeState = { ...defaultLayoutNodeState };
21 |
22 | constructor(
23 | public text: string,
24 | readonly props: {
25 | lookups: Lookups;
26 | style: TextStyleProps;
27 | testID?: string;
28 | },
29 | ) {
30 | this.testID = props.testID ?? null;
31 | this._style = normalizeLayoutProps(props.style as LayoutProps);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/renderer/CanvasRenderer.ts:
--------------------------------------------------------------------------------
1 | import type { TextAlign } from "../layout/styling";
2 | import type { Vec2 } from "../math/Vec2";
3 | import type { Vec4 } from "../math/Vec4";
4 | import type { Renderer } from "./Renderer";
5 |
6 | export class CanvasRenderer implements Renderer {
7 | rectangle(
8 | color: Vec4,
9 | position: Vec2,
10 | size: Vec2,
11 | corners: Vec4,
12 | borderWidth: Vec4,
13 | borderColor: Vec4,
14 | clipStart: Vec2,
15 | clipSize: Vec2,
16 | clipCorners: Vec4,
17 | ): void {
18 | throw new Error("Method not implemented.");
19 | }
20 |
21 | render(context: CanvasRenderingContext2D): void {
22 | throw new Error("Method not implemented.");
23 | }
24 |
25 | text(
26 | text: string,
27 | position: Vec2,
28 | fontName: string,
29 | fontSize: number,
30 | color: Vec4,
31 | textAlignment: TextAlign,
32 | clipStart: Vec2,
33 | clipSize: Vec2,
34 | options?: { lineHeight?: number; maxWidth?: number; noWrap?: boolean } | undefined,
35 | ): void {
36 | throw new Error("Method not implemented.");
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/docs/app/examples/page.mdx:
--------------------------------------------------------------------------------
1 | import { getMetadata } from "../getMetadata";
2 | import { ExampleBasic } from "./editors/Basic";
3 | import { ExamplePicker } from "./editors/Picker";
4 | import { ExampleScrollbars } from "./editors/Scrollbars";
5 |
6 | export const metadata = getMetadata("Examples");
7 |
8 | # Examples
9 |
10 | ## Basic rendering
11 |
12 | Example of basic rendering that does not use the layout algorithm.
13 |
14 |
15 |
16 | ## Interactive UIs
17 |
18 | Text inputs focus on click and can be typed into. Text can be selected either with mouse or with shift + arrow keys (or cmd/ctrl to jump to either end).
19 |
20 | Scrollable views can be scrolled with mouse wheel or by dragging the scrollbars with mouse.
21 |
22 | If anything doesn't work or you find a bug, please [open an issue](https://github.com/tchayen/red-otter/issues/new) (but please do check if it hasn't already been reported).
23 |
24 |
25 |
26 | ## Scrollbars
27 |
28 | Example (and a benchmark of sorts) of proper scrolling capabilities in the library – nested scrollable views.
29 |
30 |
31 |
--------------------------------------------------------------------------------
/docs/app/components/Table.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from "react";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | type CellProps = PropsWithChildren<{ className?: string }>;
5 |
6 | function Cell({ children, className }: CellProps) {
7 | return (
8 |
14 | {children}
15 |
16 | );
17 | }
18 |
19 | function Root({ children, columns }: PropsWithChildren<{ columns?: string }>) {
20 | return (
21 |
25 | {children}
26 |
27 | );
28 | }
29 |
30 | export function HeaderCell({ children }: PropsWithChildren) {
31 | return (
32 |
33 | {children}
34 | |
35 | );
36 | }
37 |
38 | export const Table = { Cell, HeaderCell, Root };
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-Present Tomasz Czajęcki
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/docs/app/components/BaseEditor.tsx:
--------------------------------------------------------------------------------
1 | import type { SandpackProps } from "@codesandbox/sandpack-react/types";
2 | import { BaseEditorClient } from "./BaseEditor.client";
3 | // import redOtter from "../../../.sandpack-files/red-otter.js?raw";
4 | import fs from "node:fs";
5 |
6 | const basePath = "/../../../../../.sandpack-files";
7 | const source = fs.promises.readFile(__dirname + basePath + "/red-otter.js", "utf8");
8 | const types = fs.promises.readFile(__dirname + basePath + "/index.d.ts", "utf8");
9 |
10 | export async function BaseEditor(props: SandpackProps) {
11 | return (
12 | \n${await types}`,
23 | },
24 | "/dist/index.js": { code: await source },
25 | "sandbox.config.json": { code: sandboxConfig, hidden: true },
26 | }}
27 | />
28 | );
29 | }
30 |
31 | const sandboxConfig = `{"infiniteLoopProtection":false,"hardReloadOnChange":false,"view":"browser"}`;
32 |
--------------------------------------------------------------------------------
/docs/next.config.mjs:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 | import { fileURLToPath } from "node:url";
3 | import addMdx from "@next/mdx";
4 | import remarkGfm from "remark-gfm";
5 | import rehypeMdxCodeProps from "rehype-mdx-code-props";
6 | import remarkTypography from "./remarkTypography.mjs";
7 | import remarkUniqueIds from "./remarkUniqueIds.mjs";
8 |
9 | const __filename = fileURLToPath(import.meta.url);
10 | const __dirname = path.dirname(__filename);
11 |
12 | /** @type {import('next').NextConfig} */
13 | const nextConfig = {
14 | pageExtensions: ["ts", "tsx", "md", "mdx"],
15 | reactStrictMode: true,
16 | swcMinify: true,
17 | webpack: (config) => {
18 | return {
19 | ...config,
20 | resolve: {
21 | ...config.resolve,
22 | fallback: {
23 | react: path.resolve(__dirname, "node_modules/react"),
24 | "react/jsx-dev-runtime": path.resolve(__dirname, "node_modules/react/jsx-dev-runtime.js"),
25 | },
26 | },
27 | };
28 | },
29 | };
30 |
31 | export default addMdx({
32 | extension: /\.mdx?$/,
33 | options: {
34 | rehypePlugins: [rehypeMdxCodeProps],
35 | remarkPlugins: [remarkGfm, remarkTypography, remarkUniqueIds],
36 | },
37 | })(nextConfig);
38 |
--------------------------------------------------------------------------------
/src/math/triangulateLine.ts:
--------------------------------------------------------------------------------
1 | import { invariant } from "../utils/invariant";
2 | import { Vec2 } from "./Vec2";
3 |
4 | /**
5 | * Given a line defined by a list of points, returns a flat array of points which are vertices of
6 | * triangles.
7 | */
8 | export function triangulateLine(points: Array, thickness: number): Array {
9 | invariant(points.length >= 2, "Line must have at least 2 points.");
10 | invariant(thickness > 0, "Thickness must be positive.");
11 |
12 | const triangles: Array = [];
13 |
14 | for (let i = 0; i < points.length - 1; i++) {
15 | const point = points[i];
16 | const nextPoint = points[i + 1];
17 | invariant(point, "Point is missing.");
18 | invariant(nextPoint, "Next point is missing.");
19 |
20 | const dx = nextPoint.x - point.x;
21 | const dy = nextPoint.y - point.y;
22 | const n1 = new Vec2(dy, -dx).normalize().scale(thickness / 2);
23 | const n2 = new Vec2(-dy, dx).normalize().scale(thickness / 2);
24 |
25 | triangles.push(
26 | nextPoint.add(n2),
27 | point.add(n2),
28 | point.add(n1),
29 | point.add(n1),
30 | nextPoint.add(n1),
31 | nextPoint.add(n2),
32 | );
33 | }
34 |
35 | return triangles;
36 | }
37 |
--------------------------------------------------------------------------------
/docs/app/roadmap/page.mdx:
--------------------------------------------------------------------------------
1 | import { getMetadata } from "../getMetadata";
2 | export const metadata = getMetadata("Roadmap");
3 |
4 | # Roadmap
5 |
6 | Things that would be nice to have, roughly sorted by priority:
7 |
8 | - Lots of small bug fixes that I wish I had done before the release but I ran out of steam.
9 | - zIndex support.
10 | - Proper focus system for inputs and buttons.
11 | - SDF border radius clipping. Currently it's just rectangle, which means that elements can overflow in the corners.
12 | - Better line height and text support. Currently it works, mostly, but is far from perfect and even making a simple button might require manual vertical padding adjustment to make it look good.
13 | - Ability to style scrollbars.
14 | - Font ligatures support.
15 | - More styling options: `outline`, `boxShadow`, background gradients.
16 | - Image rendering.
17 | - Non-SDF font rendering that supports regular font atlases from rasterized fonts.
18 | - Rendering vector shapes. Maybe a mini SVG `` renderer?
19 | - Position `fixed` and `sticky`.
20 | - Animations.
21 | - Custom shaders for views.
22 | - Better OTF file support. Currently the library is only able to parse fonts that follow classic TTF-style declaration of glyphs and their sizes.
23 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI actions
2 | run-name: CI actions (${{ github.actor }})
3 | on: [push]
4 |
5 | jobs:
6 | lib:
7 | name: Run tests, eslint, tsc, and build
8 | runs-on: ubuntu-latest
9 | env:
10 | CI: 1
11 | steps:
12 | - name: Checkout git
13 | uses: actions/checkout@v3
14 | - name: Setup Node.js
15 | uses: actions/setup-node@v3
16 | with:
17 | node-version: 21
18 | - name: Install bun
19 | uses: oven-sh/setup-bun@v1
20 | - name: Install dependencies
21 | run: bun i
22 | - name: Run all checks
23 | run: bun run ci
24 | docs:
25 | name: Run everything in docs too
26 | runs-on: ubuntu-latest
27 | steps:
28 | - name: Checkout git
29 | uses: actions/checkout@v3
30 | - name: Setup Node.js
31 | uses: actions/setup-node@v3
32 | with:
33 | node-version: 21
34 | - name: Install bun
35 | uses: oven-sh/setup-bun@v1
36 | - name: CD into docs
37 | run: cd docs
38 | - name: Install dependencies
39 | run: bun i
40 | - name: eslint
41 | run: bun run lint
42 | - name: tsc
43 | run: bun run tsc
44 | - name: Build
45 | run: bun run build
46 |
--------------------------------------------------------------------------------
/docs/app/interactivity/page.mdx:
--------------------------------------------------------------------------------
1 | import { Api } from "./Api";
2 | import { getMetadata } from "../getMetadata";
3 | export const metadata = getMetadata("Interactivity");
4 |
5 | # Interactivity
6 |
7 | The recent version of Red Otter includes new systems for handling user inputs. This required the layout to become more opinionated.
8 |
9 | ## API
10 |
11 | Example of a simple app loop that reads events:
12 |
13 | ```ts
14 | import { EventManager, WebGPURenderer, View, compose, paint } from "red-otter";
15 |
16 | const eventManager = new EventManager();
17 | const renderer = new WebGPURenderer(/* ... */);
18 | const root = new View(/* ... */);
19 |
20 | function render() {
21 | // Deliver events to views.
22 | eventManager.deliverEvents(root);
23 |
24 | // Update scroll offsets etc. in response to events.
25 | compose(renderer, root);
26 |
27 | // Dispatch commands for renderer.
28 | paint(renderer, root);
29 |
30 | // Prepare rendering instructions and dispatch them to the GPU.
31 | const commandEncoder = device.createCommandEncoder();
32 | renderer.render(commandEncoder);
33 | device.queue.submit([commandEncoder.finish()]);
34 |
35 | // Request next frame.
36 | requestAnimationFrame(render);
37 | }
38 |
39 | render();
40 | ```
41 |
42 | ---
43 |
44 |
45 |
--------------------------------------------------------------------------------
/docs/remarkUniqueIds.mjs:
--------------------------------------------------------------------------------
1 | import { visit } from "unist-util-visit";
2 |
3 | /**
4 | * Transformer function for the remark plugin.
5 | *
6 | * @returns {import('unified').Transformer}
7 | */
8 | function remarkUniqueIds() {
9 | return (tree) => {
10 | // Initialize counters for each heading level
11 | const counters = {};
12 |
13 | visit(tree, "heading", (node) => {
14 | // Initialize the counter for this level if not already done
15 | if (!counters[node.depth]) {
16 | counters[node.depth] = 0;
17 | }
18 |
19 | // Increment counter for this level
20 | counters[node.depth]++;
21 |
22 | // Reset counters of all deeper levels
23 | Object.keys(counters)
24 | .map(Number)
25 | .filter((depth) => depth > node.depth)
26 | .forEach((depth) => (counters[depth] = 0));
27 |
28 | // Generate the ID
29 | let id = "";
30 | for (let i = 1; i <= node.depth; i++) {
31 | id += `${counters[i]}.`;
32 | }
33 | id = id.slice(0, -1); // Remove the last dot
34 |
35 | // Assign the ID to the node data
36 | node.data = node.data || {};
37 | node.data.hProperties = node.data.hProperties || {};
38 | node.data.hProperties.id = id;
39 | });
40 | };
41 | }
42 |
43 | export default remarkUniqueIds;
44 |
--------------------------------------------------------------------------------
/docs/app/renderer/Api.tsx:
--------------------------------------------------------------------------------
1 | import types from "../types.json";
2 | import { ApiBlock, Class, Function, Interface } from "../components/ApiBlocks";
3 | import { Hr } from "../components/tags";
4 |
5 | export function Api() {
6 | return (
7 |
8 | {Object.values(types.functions)
9 | .filter((f) => f.source.startsWith("/renderer"))
10 | .map((f, index) => {
11 | return (
12 | <>
13 |
14 |
15 | >
16 | );
17 | })}
18 | {Object.values(types.interfaces)
19 | .filter((f) => f.source.startsWith("/renderer"))
20 | .map((i, index) => {
21 | return (
22 | <>
23 |
24 |
25 | >
26 | );
27 | })}
28 | {Object.values(types.classes)
29 | .filter((c) => c.source.startsWith("/renderer"))
30 | .map((c, index) => {
31 | return (
32 | <>
33 |
34 |
35 | >
36 | );
37 | })}
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/docs/app/components/Enums.tsx:
--------------------------------------------------------------------------------
1 | import { H2, H3, P } from "./tags";
2 | import types from "../types.json";
3 | import { Table } from "./Table";
4 | import { Fragment } from "react";
5 | import { Markdown } from "./Markdown";
6 |
7 | export function Enums() {
8 | return (
9 | <>
10 | Enums
11 | List of enums used in the types above.
12 | {types.enums
13 | .filter((e) => e.source.startsWith("/layout/styling.ts"))
14 | .map((e) => {
15 | return (
16 | <>
17 | {e.name}
18 | {e.description}
19 |
20 | Value
21 | Description
22 | {e.values.map((v) => {
23 | return (
24 |
25 | {v.name}
26 |
27 | {v.description}
28 |
29 |
30 | );
31 | })}
32 |
33 | >
34 | );
35 | })}
36 | >
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/docs/app/components/Tabs.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { type PropsWithChildren } from "react";
3 | import * as TabsPrimitive from "@radix-ui/react-tabs";
4 | import { twMerge } from "tailwind-merge";
5 | import { outline } from "./tags";
6 |
7 | export function Root({ defaultValue, children }: PropsWithChildren<{ defaultValue: string }>) {
8 | return {children} ;
9 | }
10 |
11 | export function List({ children }: PropsWithChildren) {
12 | return (
13 | {children}
14 | );
15 | }
16 |
17 | export function Trigger({ children, value }: PropsWithChildren<{ value: string }>) {
18 | return (
19 |
26 | {children}
27 |
28 | );
29 | }
30 |
31 | export function Content({ children, value }: PropsWithChildren<{ value: string }>) {
32 | return (
33 |
34 | {children}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/docs/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const {
2 | mauve,
3 | mauveDark,
4 | blue,
5 | blueDark,
6 | orange,
7 | orangeDark,
8 | purple,
9 | purpleDark,
10 | crimson,
11 | crimsonDark,
12 | tomato,
13 | tomatoDark,
14 | amber,
15 | amberDark,
16 | grass,
17 | grassDark,
18 | } = require("@radix-ui/colors");
19 |
20 | /** @type {import('tailwindcss').Config} */
21 | module.exports = {
22 | content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"],
23 | darkMode: "class",
24 | plugins: [],
25 | theme: {
26 | extend: {
27 | colors: {
28 | ...mauve,
29 | ...renameKeys("mauve", mauveDark),
30 | ...blue,
31 | ...renameKeys("blue", blueDark),
32 | ...orange,
33 | ...renameKeys("orange", orangeDark),
34 | ...purple,
35 | ...renameKeys("purple", purpleDark),
36 | ...crimson,
37 | ...renameKeys("crimson", crimsonDark),
38 | ...tomato,
39 | ...renameKeys("tomato", tomatoDark),
40 | ...amber,
41 | ...renameKeys("amber", amberDark),
42 | ...grass,
43 | ...renameKeys("grass", grassDark),
44 | },
45 | },
46 | },
47 | };
48 |
49 | function renameKeys(name, colors) {
50 | return Object.fromEntries(
51 | Object.entries(colors).map(([key, value]) => {
52 | return [`${name}dark${key.replace(name, "")}`, value];
53 | }),
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/utils/Queue.ts:
--------------------------------------------------------------------------------
1 | interface QueueNode {
2 | data: T;
3 | next: QueueNode | null;
4 | prev: QueueNode | null;
5 | }
6 |
7 | export class Queue {
8 | private start: QueueNode | null = null;
9 | private end: QueueNode | null = null;
10 | private size = 0;
11 |
12 | enqueue(value: T): void {
13 | const node: QueueNode = {
14 | data: value,
15 | next: null,
16 | prev: null,
17 | };
18 |
19 | if (this.end) {
20 | this.end.next = node;
21 | node.prev = this.end;
22 | } else {
23 | this.start = node;
24 | }
25 |
26 | this.end = node;
27 | this.size += 1;
28 | }
29 |
30 | dequeue(): T | null {
31 | const node = this.start;
32 | if (node === null) {
33 | return null;
34 | }
35 |
36 | if (node.next) {
37 | this.start = node.next;
38 | } else {
39 | this.start = null;
40 | this.end = null;
41 | }
42 |
43 | this.size -= 1;
44 | return node.data;
45 | }
46 |
47 | dequeueFront(): T | null {
48 | const node = this.end;
49 | if (node === null) {
50 | return null;
51 | }
52 |
53 | if (node.prev) {
54 | this.end = node.prev;
55 | } else {
56 | this.start = null;
57 | this.end = null;
58 | }
59 |
60 | this.size -= 1;
61 | return node.data;
62 | }
63 |
64 | isEmpty(): boolean {
65 | return this.size === 0;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/docs/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | color-scheme: dark;
7 | font-family: Inter, sans-serif;
8 | }
9 |
10 | body {
11 | padding: 0;
12 | margin: 0;
13 |
14 | font-synthesis: none;
15 | text-rendering: optimizeLegibility;
16 | -webkit-font-smoothing: antialiased;
17 | -moz-osx-font-smoothing: grayscale;
18 | -webkit-text-size-adjust: 100%;
19 | font-feature-settings: "ss04", "tnum", "ss08";
20 | }
21 |
22 | canvas {
23 | border-radius: 8px;
24 | }
25 |
26 | .scrollbar::-webkit-scrollbar,
27 | .cm-scroller::-webkit-scrollbar {
28 | width: 8px;
29 | height: 8px;
30 | }
31 |
32 | .scrollbar::-webkit-scrollbar-track,
33 | .cm-scroller::-webkit-scrollbar-track {
34 | background: transparent;
35 | }
36 |
37 | .scrollbar::-webkit-scrollbar-corner,
38 | .cm-scroller::-webkit-scrollbar-corner {
39 | background: transparent;
40 | }
41 |
42 | .scrollbar::-webkit-scrollbar-thumb,
43 | .cm-scroller::-webkit-scrollbar-thumb {
44 | background: var(--scrollbar-thumb);
45 | border-radius: 4px;
46 | }
47 |
48 | .scrollbar::-webkit-scrollbar-thumb:hover,
49 | .cm-scroller::-webkit-scrollbar-thumb:hover {
50 | background: var(--scrollbar-thumb-hover);
51 | }
52 |
53 | .scrollbar::-webkit-scrollbar-thumb:active,
54 | .cm-scroller::-webkit-scrollbar-thumb:active {
55 | background: var(--scrollbar-thumb-active);
56 | }
57 |
58 | * {
59 | box-sizing: border-box;
60 | }
61 |
--------------------------------------------------------------------------------
/docs/app/styling/Api.tsx:
--------------------------------------------------------------------------------
1 | import { ApiBlock, Type } from "../components/ApiBlocks";
2 | import { Enums } from "../components/Enums";
3 | import types from "../types.json";
4 | import { A, Box, Hr, P } from "../components/tags";
5 |
6 | export function Api() {
7 | const layoutProps = types.types.LayoutProps;
8 | const decorativeProps = types.types.DecorativeProps;
9 | const textStyleProps = types.types.TextStyleProps;
10 | const layoutNodeState = types.types.LayoutNodeState;
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | In-depth explanation of text rendering is available on the [Text
21 | Rendering](/text-rendering) page.
22 |
23 |
24 |
25 | The library uses cap size as opposed to line height for calculating bounding box of text
26 | elements (see CapSize for more
27 | explanation). This results in most noticeable differences in buttons which require more
28 | vertical space than in browsers.
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/docs/app/layout-engine/Api.tsx:
--------------------------------------------------------------------------------
1 | import types from "../types.json";
2 | import { ApiBlock, Class, Function, Interface } from "../components/ApiBlocks";
3 | import { Hr } from "../components/tags";
4 |
5 | export function Api() {
6 | return (
7 |
8 | {Object.values(types.interfaces)
9 | .filter((i) => i.source.startsWith("/layout"))
10 | .filter((i) => !i.name.includes("Props")) // Those are covered in styling.
11 | .map((i, index) => {
12 | return (
13 | <>
14 |
15 |
16 | >
17 | );
18 | })}
19 | {Object.values(types.functions)
20 | .filter((f) => f.source.startsWith("/layout"))
21 | .filter((f) => !f.name.includes("Props")) // Those are covered in styling.
22 | .map((f, index) => {
23 | return (
24 | <>
25 |
26 |
27 | >
28 | );
29 | })}
30 | {Object.values(types.classes)
31 | .filter((f) => f.source.startsWith("/layout"))
32 | .map((c, index) => {
33 | return (
34 | <>
35 |
36 |
37 | >
38 | );
39 | })}
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/layout/compose.ts:
--------------------------------------------------------------------------------
1 | import { Vec2 } from "../math/Vec2";
2 | import { Vec4 } from "../math/Vec4";
3 | import { intersection } from "../math/utils";
4 | import { Display } from "./styling";
5 | import type { Renderer } from "../renderer/Renderer";
6 | import type { Node } from "./Node";
7 |
8 | /**
9 | * Takes tree of nodes processed by `layout()` and calculates current positions based on
10 | * accumulated scroll values and calculates parent clipping rectangle.
11 | */
12 | export function compose(
13 | ui: Renderer,
14 | node: Node,
15 | clipStart = new Vec2(0, 0),
16 | clipSize = new Vec2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY),
17 | scrollOffset = new Vec2(0, 0),
18 | ): void {
19 | if (node._style.display === Display.None) {
20 | return;
21 | }
22 |
23 | node._state.clipStart = clipStart;
24 | node._state.clipSize = clipSize;
25 | node._state.totalScrollX = scrollOffset.x;
26 | node._state.totalScrollY = scrollOffset.y;
27 |
28 | const nextScrollOffset = scrollOffset.add(new Vec2(node._state.scrollX, node._state.scrollY));
29 |
30 | const clipped = intersection(
31 | new Vec4(
32 | node._state.x - scrollOffset.x,
33 | node._state.y - scrollOffset.y,
34 | node._state.clientWidth,
35 | node._state.clientHeight,
36 | ),
37 | new Vec4(clipStart.x, clipStart.y, clipSize.x, clipSize.y),
38 | );
39 |
40 | let c = node.firstChild;
41 | while (c) {
42 | compose(ui, c, clipped.xy(), clipped.zw(), nextScrollOffset);
43 | c = c.next;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/docs/app/components/TypeTooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Code, slugify } from "./tags";
3 | import * as Tooltip from "@radix-ui/react-tooltip";
4 | import type { EnumType, FieldType } from "./ApiBlocks";
5 | import { Markdown } from "./Markdown";
6 |
7 | type TypeTooltipProps = {
8 | enumType: EnumType;
9 | field: FieldType;
10 | };
11 |
12 | export function TypeTooltip({ field, enumType }: TypeTooltipProps) {
13 | return (
14 |
15 |
16 |
20 | {field.type}
21 |
22 |
23 |
24 |
25 | {enumType.name}
26 | {enumType.description}
27 | {enumType.values.map((value) => (
28 |
29 |
{value.name}
30 | {value.description && (
31 |
32 | {value.description}
33 |
34 | )}
35 |
36 | ))}
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/docs/app/renderer/page.mdx:
--------------------------------------------------------------------------------
1 | import { Api } from "./Api";
2 | import { getMetadata } from "../getMetadata";
3 | export const metadata = getMetadata("Renderer");
4 |
5 | # Renderer
6 |
7 | The renderer is responsible for rendering shapes and text on the screen. It has a very simple API:
8 |
9 | ```tsx
10 | export interface Renderer {
11 | rectangle(
12 | color: Vec4,
13 | position: Vec2,
14 | size: Vec2,
15 | corners: Vec4,
16 | borderWidth: Vec4,
17 | borderColor: Vec4,
18 | clipStart: Vec2,
19 | clipSize: Vec2,
20 | clipCorners: Vec4,
21 | ): void;
22 |
23 | render(commandEncoder: GPUCommandEncoder): void;
24 |
25 | text(
26 | text: string,
27 | position: Vec2,
28 | fontName: string,
29 | fontSize: number,
30 | color: Vec4,
31 | textAlignment: TextAlign,
32 | clipStart: Vec2,
33 | clipSize: Vec2,
34 | options?: { lineHeight?: number; maxWidth?: number },
35 | ): void;
36 | }
37 | ```
38 |
39 | ## Overview
40 |
41 | For each rectangle on the screen, an instance of a full-screen quad is renderered, which is then assigned to proper positions based on data stored in storage buffer. Fragment shader uses SDF to calculate border radius.
42 |
43 | ## API
44 |
45 |
46 |
47 | ---
48 |
49 | ## Text rendering
50 |
51 | Text uses SDF font rendering to display glyphs from a font atlas. See [Text Rendering](/text-rendering) page for more information.
52 |
53 | I wrote a blogpost that goes more in depth – [Drawing Text in WebGPU Using Just the Font File](https://tchayen.com/drawing-text-in-webgpu-using-just-the-font-file).
54 |
--------------------------------------------------------------------------------
/src/math/Vec2.ts:
--------------------------------------------------------------------------------
1 | const EPSILON = 0.001;
2 |
3 | /**
4 | * A 2D vector.
5 | */
6 | export class Vec2 {
7 | constructor(
8 | public readonly x: number,
9 | public readonly y: number,
10 | ) {}
11 |
12 | add(other: Vec2): Vec2 {
13 | return new Vec2(this.x + other.x, this.y + other.y);
14 | }
15 |
16 | subtract(other: Vec2): Vec2 {
17 | return new Vec2(this.x - other.x, this.y - other.y);
18 | }
19 |
20 | length(): number {
21 | return Math.sqrt(this.x * this.x + this.y * this.y);
22 | }
23 |
24 | normalize(): Vec2 {
25 | const length = this.length();
26 | return new Vec2(this.x / length, this.y / length);
27 | }
28 |
29 | scale(scalar: number): Vec2 {
30 | return new Vec2(this.x * scalar, this.y * scalar);
31 | }
32 |
33 | cross(other: Vec2): number {
34 | return this.x * other.y - this.y * other.x;
35 | }
36 |
37 | dot(other: Vec2): number {
38 | return this.x * other.x + this.y * other.y;
39 | }
40 |
41 | distance(other: Vec2): number {
42 | return this.subtract(other).length();
43 | }
44 |
45 | lerp(other: Vec2, t: number): Vec2 {
46 | return this.add(other.subtract(this).scale(t));
47 | }
48 |
49 | equalsEpsilon(other: Vec2, epsilon: number): boolean {
50 | return Math.abs(this.x - other.x) < epsilon && Math.abs(this.y - other.y) < epsilon;
51 | }
52 |
53 | equals(other: Vec2): boolean {
54 | return this.equalsEpsilon(other, EPSILON);
55 | }
56 |
57 | data(): Array {
58 | return [this.x, this.y];
59 | }
60 |
61 | toString(): string {
62 | return `(${this.x}, ${this.y})`;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/hitTest.ts:
--------------------------------------------------------------------------------
1 | import type { BaseView } from "./layout/BaseView";
2 | import { CROSS_AXIS_SIZE } from "./consts";
3 | import { Vec4 } from "./math/Vec4";
4 | import { intersection as getIntersection, isInside } from "./math/utils";
5 | import type { MouseEvent } from "./layout/eventTypes";
6 | import { Vec2 } from "./math/Vec2";
7 |
8 | /**
9 | * @returns whether event happened within the node's visible rectangle, including scrollbars.
10 | */
11 | export function hitTest(node: BaseView, event: MouseEvent): boolean {
12 | const intersection = getScreenVisibleRectangle(node);
13 | return isInside(event.position, intersection);
14 | }
15 |
16 | /**
17 | * @returns rectangle of the node's visible area in screen space coordinates, including scrollbars.
18 | */
19 | export function getScreenVisibleRectangle(node: BaseView) {
20 | const { totalScrollX, totalScrollY, clipStart, clipSize, clientHeight, clientWidth } =
21 | node._state;
22 | const nodeRectangle = new Vec4(
23 | node._state.x - totalScrollX,
24 | node._state.y - totalScrollY,
25 | clientWidth + (node._state.hasVerticalScrollbar ? CROSS_AXIS_SIZE : 0),
26 | clientHeight + (node._state.hasHorizontalScrollbar ? CROSS_AXIS_SIZE : 0),
27 | );
28 | const boundary = new Vec4(clipStart.x, clipStart.y, clipSize.x, clipSize.y);
29 | const intersection = getIntersection(nodeRectangle, boundary);
30 | return intersection;
31 | }
32 |
33 | export function pointToNodeSpace(node: BaseView, point: Vec2): Vec2 {
34 | const { totalScrollX, totalScrollY } = node._state;
35 | return new Vec2(point.x + totalScrollX - node._state.x, point.y + totalScrollY - node._state.y);
36 | }
37 |
--------------------------------------------------------------------------------
/src/utils/Queue.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from "vitest";
2 | import { Queue } from "./Queue";
3 |
4 | describe("Queue", () => {
5 | describe("enqueue", () => {
6 | it("should add an item to the queue", () => {
7 | const queue = new Queue();
8 | queue.enqueue(1);
9 | expect(queue.isEmpty()).toBe(false);
10 | });
11 | });
12 |
13 | describe("dequeue", () => {
14 | it("should remove and return the first item in the queue", () => {
15 | const queue = new Queue();
16 | queue.enqueue(1);
17 | queue.enqueue(2);
18 | expect(queue.dequeue()).toBe(1);
19 | expect(queue.dequeue()).toBe(2);
20 | });
21 |
22 | it("should return null if the queue is empty", () => {
23 | const queue = new Queue();
24 | expect(queue.dequeue()).toBe(null);
25 | });
26 | });
27 |
28 | describe("dequeueFront", () => {
29 | it("should remove and return the last item in the queue", () => {
30 | const queue = new Queue();
31 | queue.enqueue(1);
32 | queue.enqueue(2);
33 | expect(queue.dequeueFront()).toBe(2);
34 | expect(queue.dequeueFront()).toBe(1);
35 | });
36 |
37 | it("should return null if the queue is empty", () => {
38 | const queue = new Queue();
39 | expect(queue.dequeueFront()).toBe(null);
40 | });
41 | });
42 |
43 | describe("isEmpty", () => {
44 | it("should return true if the queue is empty", () => {
45 | const queue = new Queue();
46 | expect(queue.isEmpty()).toBe(true);
47 | });
48 |
49 | it("should return false if the queue is not empty", () => {
50 | const queue = new Queue();
51 | queue.enqueue(1);
52 | expect(queue.isEmpty()).toBe(false);
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/docs/app/styling/page.mdx:
--------------------------------------------------------------------------------
1 | import { Api } from "./Api";
2 | import { getMetadata } from "../getMetadata";
3 | export const metadata = getMetadata("Styling");
4 |
5 | # Styling
6 |
7 | Everything to know about styling components.
8 |
9 | ---
10 |
11 | ## Edge cases
12 |
13 | Some differences (or unobvious cases) from RN and/or CSS:
14 |
15 | - Default `flexDirection` is `column` (CSS default is `row`).
16 | - Default `alignContent` is `flex-start` (CSS default is `stretch`).
17 | - Default `flexShrink` is `0` (CSS default is `1`).
18 | - `flexBasis` takes precedence over `width` and `height` if defined.
19 | - There's no `margin: auto`.
20 | - Similarly to CSS and RN, if both top and bottom (or left and right) are defined and `height` (or `width`) is _not_ defined, the element will span the distance between those two edges.
21 | - Properties with higher specificity override properties with lower specificity (in CSS it is the that order matters).
22 | In CSS `style="flex-grow: 1; flex: 2"` would use value `2` for `flex-grow` because it is defined later. Here corresponding code would use value `1` for `flex-grow` because it is more specific. Same goes for `margin`, `padding`, `borderWidth`, `gap`.
23 | - `box-sizing` is always `border-box`, which means that `width` and `height` include both `padding` and `border` (CSS default is `content-box`).
24 |
25 | ---
26 |
27 | ## Colors
28 |
29 | Colors are defined as strings, similar to CSS.
30 |
31 | Supported formats are hex (long and short variants – `#f00` and `#ff0000`), RGB, RGBA (`rgba(255, 0, 0, 0.5)`), HSL (`hsl(60 100% 50%)`, `hsl(60, 100%, 50%, 0.8)`), HSLA, HSV.
32 |
33 | RGB and HSV require adding A in the end to support alpha. In HSL it is optional. If color is not recognized, `transparent` is used and warning is logged to the console. For the most up-to-date info check `parseColor()` in the code.
34 |
35 | ---
36 |
37 | ## API
38 |
39 |
40 |
--------------------------------------------------------------------------------
/docs/app/code-theme.css:
--------------------------------------------------------------------------------
1 | /* GitHub dark */
2 |
3 | .hljs {
4 | color: var(--code-theme-main);
5 | }
6 |
7 | .hljs-doctag,
8 | .hljs-keyword,
9 | .hljs-meta .hljs-keyword,
10 | .hljs-template-tag,
11 | .hljs-template-variable,
12 | .hljs-type,
13 | .hljs-variable.language_ {
14 | color: var(--code-theme-keyword);
15 | }
16 |
17 | .hljs-title,
18 | .hljs-title.class_,
19 | .hljs-title.class_.inherited__,
20 | .hljs-title.function_ {
21 | color: var(--code-theme-title);
22 | }
23 |
24 | .hljs-attr,
25 | .hljs-attribute,
26 | .hljs-literal,
27 | .hljs-meta,
28 | .hljs-number,
29 | .hljs-operator,
30 | .hljs-selector-attr,
31 | .hljs-selector-class,
32 | .hljs-selector-id,
33 | .hljs-variable {
34 | color: var(--code-theme-number);
35 | }
36 |
37 | .hljs-meta .hljs-string,
38 | .hljs-regexp,
39 | .hljs-string {
40 | color: var(--code-theme-string);
41 | }
42 |
43 | .hljs-built_in,
44 | .hljs-symbol {
45 | color: var(--code-theme-symbol);
46 | }
47 |
48 | .hljs-code,
49 | .hljs-comment,
50 | .hljs-formula {
51 | color: var(--code-theme-comment);
52 | }
53 |
54 | .hljs-name,
55 | .hljs-quote,
56 | .hljs-selector-pseudo,
57 | .hljs-selector-tag {
58 | color: var(--code-theme-quote);
59 | }
60 |
61 | .hljs-subst {
62 | color: var(--code-theme-subst);
63 | }
64 |
65 | .hljs-section {
66 | color: var(--code-theme-section);
67 | font-weight: 700;
68 | }
69 |
70 | .hljs-bullet {
71 | color: var(--code-theme-bullet);
72 | }
73 |
74 | .hljs-emphasis {
75 | color: var(--code-theme-emphasis);
76 | font-style: italic;
77 | }
78 |
79 | .hljs-strong {
80 | color: var(--code-theme-strong);
81 | font-weight: 700;
82 | }
83 |
84 | .hljs-addition {
85 | color: var(--code-theme-addition);
86 | background-color: var(--code-theme-addition-background);
87 | }
88 |
89 | .hljs-deletion {
90 | color: var(--code-theme-deletion);
91 | background-color: var(--code-theme-deletion-background);
92 | }
93 |
--------------------------------------------------------------------------------
/src/math/Vec3.ts:
--------------------------------------------------------------------------------
1 | const EPSILON = 0.001;
2 |
3 | /**
4 | * A 3-dimensional vector.
5 | */
6 | export class Vec3 {
7 | constructor(
8 | public readonly x: number,
9 | public readonly y: number,
10 | public readonly z: number,
11 | ) {}
12 |
13 | add(other: Vec3): Vec3 {
14 | return new Vec3(this.x + other.x, this.y + other.y, this.z + other.z);
15 | }
16 |
17 | subtract(other: Vec3): Vec3 {
18 | return new Vec3(this.x - other.x, this.y - other.y, this.z - other.z);
19 | }
20 |
21 | length(): number {
22 | return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
23 | }
24 |
25 | normalize(): Vec3 {
26 | const length = this.length();
27 | return new Vec3(this.x / length, this.y / length, this.z / length);
28 | }
29 |
30 | scale(scalar: number): Vec3 {
31 | return new Vec3(this.x * scalar, this.y * scalar, this.z * scalar);
32 | }
33 |
34 | cross(other: Vec3): Vec3 {
35 | return new Vec3(
36 | this.y * other.z - this.z * other.y,
37 | this.z * other.x - this.x * other.z,
38 | this.x * other.y - this.y * other.x,
39 | );
40 | }
41 |
42 | dot(other: Vec3): number {
43 | return this.x * other.x + this.y * other.y + this.z * other.z;
44 | }
45 |
46 | distance(other: Vec3): number {
47 | return this.subtract(other).length();
48 | }
49 |
50 | lerp(other: Vec3, t: number): Vec3 {
51 | return this.add(other.subtract(this).scale(t));
52 | }
53 |
54 | equalsEpsilon(other: Vec3, epsilon: number): boolean {
55 | return (
56 | Math.abs(this.x - other.x) < epsilon &&
57 | Math.abs(this.y - other.y) < epsilon &&
58 | Math.abs(this.z - other.z) < epsilon
59 | );
60 | }
61 |
62 | equals(other: Vec3): boolean {
63 | return this.equalsEpsilon(other, EPSILON);
64 | }
65 |
66 | toString(): string {
67 | return `(${this.x}, ${this.y}, ${this.z})`;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "version": "0.1.0",
4 | "description": "Fancy Next.js app",
5 | "scripts": {
6 | "dev": "next --port 4141",
7 | "build": "bun run prepare-sandpack && bun scripts/extractTypeScript.ts && next build",
8 | "prepare-sandpack": "cd .. && bun i && bun run prepare-sandpack && cd docs",
9 | "start": "next start",
10 | "lint": "eslint .",
11 | "ci": "bun run lint && bun run tsc && bun run build",
12 | "build:static": "next build && next out"
13 | },
14 | "dependencies": {
15 | "@codesandbox/sandpack-react": "^2.11.3",
16 | "@mdx-js/loader": "^3.0.0",
17 | "@mdx-js/mdx": "^3.0.0",
18 | "@mdx-js/react": "^3.0.0",
19 | "@next/mdx": "^14.1.0",
20 | "@radix-ui/colors": "^3.0.0",
21 | "@radix-ui/react-collapsible": "^1.0.3",
22 | "@radix-ui/react-dialog": "^1.0.5",
23 | "@radix-ui/react-tabs": "^1.0.4",
24 | "@radix-ui/react-tooltip": "^1.0.7",
25 | "cheerio": "^1.0.0-rc.12",
26 | "date-fns": "^3.0.6",
27 | "flexsearch": "^0.7.43",
28 | "highlight.js": "^11.9.0",
29 | "lodash": "^4.17.21",
30 | "next": "^14.1.0",
31 | "react": "^18.2.0",
32 | "react-dom": "^18.2.0",
33 | "rehype-mdx-code-props": "^2.0.0",
34 | "unist-util-visit": "^5.0.0"
35 | },
36 | "devDependencies": {
37 | "@total-typescript/ts-reset": "^0.5.1",
38 | "@types/flexsearch": "^0.7.6",
39 | "@types/lodash": "^4.14.202",
40 | "@types/mdx": "^2.0.10",
41 | "@types/react": "18.2.48",
42 | "autoprefixer": "^10.4.17",
43 | "eslint-config-next": "^14.1.0",
44 | "eslint-plugin-react-hooks": "^4.6.0",
45 | "postcss": "^8.4.33",
46 | "prettier": "^3.2.4",
47 | "prettier-plugin-tailwindcss": "^0.5.11",
48 | "remark-gfm": "^4.0.0",
49 | "tailwind-merge": "^2.2.0",
50 | "tailwindcss": "^3.4.1",
51 | "typescript": "^5.3.3",
52 | "webpack": "^5.89.0"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/math/Mat4.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from "vitest";
2 |
3 | import { Mat4 } from "./Mat4";
4 | import { Vec3 } from "./Vec3";
5 |
6 | describe("Mat4", () => {
7 | it("inverse is correct", () => {
8 | const m = new Mat4([2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2]);
9 |
10 | const i = m.invert();
11 | const multipled = m.multiply(i);
12 | const expected = Mat4.identity();
13 |
14 | expect(multipled).toEqual(expected);
15 | });
16 |
17 | it("should correctly transpose the matrix", () => {
18 | const m = new Mat4([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
19 |
20 | const result = m.transpose();
21 |
22 | expect(result.data).toEqual([1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15, 4, 8, 12, 16]);
23 | });
24 |
25 | it("should correctly multiply two matrices", () => {
26 | const m1 = new Mat4([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
27 | const m2 = new Mat4([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
28 |
29 | const result = m1.multiply(m2);
30 |
31 | expect(result.data).toEqual([
32 | 90, 100, 110, 120, 202, 228, 254, 280, 314, 356, 398, 440, 426, 484, 542, 600,
33 | ]);
34 | });
35 |
36 | it("should return a correctly oriented camera matrix", () => {
37 | const cameraPosition = new Vec3(1, 2, 3);
38 | const target = new Vec3(0, 0, 0);
39 | const up = new Vec3(0, 1, 0);
40 |
41 | const result = Mat4.lookAt(cameraPosition, target, up);
42 |
43 | const expected = new Mat4([
44 | 0.948_683_298_050_513_9, -0.169_030_850_945_703_33, 0.267_261_241_912_424_4, 0, 0,
45 | 0.845_154_254_728_516_7, 0.534_522_483_824_848_8, 0, -0.316_227_766_016_837_94,
46 | -0.507_092_552_837_11, 0.801_783_725_737_273_2, 0, -1.110_223_024_625_156_5e-16,
47 | -2.220_446_049_250_313e-16, -3.741_657_386_773_941_3, 1,
48 | ]);
49 |
50 | expect(result.data).toEqual(expected.data);
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/math/utils.ts:
--------------------------------------------------------------------------------
1 | import type { Vec2 } from "./Vec2";
2 | import { Vec4 } from "./Vec4";
3 |
4 | /**
5 | * Makes sure that the given value is within the given range using combination
6 | * of min() and max().
7 | */
8 | export function clamp(value: number, min: number, max: number): number {
9 | return Math.max(Math.min(value, max), min);
10 | }
11 |
12 | /**
13 | * Performs a linear interpolation between two values.
14 | */
15 | export function lerp(a: number, b: number, t: number): number {
16 | return a + (b - a) * t;
17 | }
18 |
19 | /**
20 | * Performs a smooth interpolation between two values.
21 | * `t` should be in the range [0, 1].
22 | */
23 | export function smoothstep(a: number, b: number, t: number): number {
24 | t = clamp((t - a) / (b - a), 0, 1);
25 | return t * t * (3 - 2 * t);
26 | }
27 |
28 | /**
29 | * Converts degrees to radians.
30 | */
31 | export function toRadians(degrees: number): number {
32 | return (degrees * Math.PI) / 180;
33 | }
34 |
35 | /**
36 | * Converts radians to degrees.
37 | */
38 | export function toDegrees(radians: number): number {
39 | return (radians * 180) / Math.PI;
40 | }
41 |
42 | /**
43 | * Returns the next power of two that is greater than or equal to the given
44 | * value.
45 | */
46 | export function nextPowerOfTwo(value: number): number {
47 | return Math.pow(2, Math.ceil(Math.log(value) / Math.log(2)));
48 | }
49 |
50 | /**
51 | * Returns intersection of two rectangles. If there is no intersection, returns a Vec4(0, 0, 0, 0).
52 | */
53 | export function intersection(a: Vec4, b: Vec4): Vec4 {
54 | const x = Math.max(a.x, b.x);
55 | const y = Math.max(a.y, b.y);
56 | const width = Math.min(a.x + a.z, b.x + b.z) - x;
57 | const height = Math.min(a.y + a.w, b.y + b.w) - y;
58 |
59 | if (width <= 0 || height <= 0) {
60 | return new Vec4(0, 0, 0, 0);
61 | }
62 |
63 | return new Vec4(x, y, width, height);
64 | }
65 |
66 | /**
67 | * Checks if the given point is inside the given rectangle.
68 | */
69 | export function isInside(point: Vec2, rectangle: Vec4): boolean {
70 | return (
71 | point.x >= rectangle.x &&
72 | point.x <= rectangle.x + rectangle.z &&
73 | point.y >= rectangle.y &&
74 | point.y <= rectangle.y + rectangle.w
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release package
2 | on:
3 | workflow_run:
4 | branches:
5 | - main
6 | workflows:
7 | - CI actions
8 | types:
9 | - completed
10 | jobs:
11 | check-commit:
12 | runs-on: ubuntu-latest
13 | # Skip if the workflow run for tests, linting etc. is not successful. Without this, the release
14 | # will be triggered after the previous workflow run even if it failed.
15 | if: ${{ github.event.workflow_run.conclusion == 'success' }}
16 | outputs:
17 | skip: ${{ steps.commit-message.outputs.skip }}
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v3
21 | # Check if the commit message is a release commit. Without this, there will be an infinite
22 | # loop of releases.
23 | - name: Get commit message
24 | id: commit-message
25 | run: |
26 | MESSAGE=$(git log --format=%B -n 1 $(git log -1 --pretty=format:"%h"))
27 |
28 | if [[ $MESSAGE == "chore: release "* ]]; then
29 | echo "skip=true" >> $GITHUB_OUTPUT
30 | fi
31 | release:
32 | runs-on: ubuntu-latest
33 | needs: check-commit
34 | # Skip if the commit message is a release commit.
35 | if: ${{ needs.check-commit.outputs.skip != 'true' }}
36 | steps:
37 | - name: Checkout
38 | uses: actions/checkout@v3
39 | with:
40 | # This is needed to generate the changelog from commit messages.
41 | fetch-depth: 0
42 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
43 | - name: Setup Node.js
44 | uses: actions/setup-node@v3
45 | - name: Install bun
46 | uses: oven-sh/setup-bun@v1
47 | - name: Install dependencies
48 | run: bun i --no-save
49 | shell: bash
50 | - name: Configure Git
51 | run: |
52 | git config user.name "${GITHUB_ACTOR}"
53 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
54 | - name: Debug
55 | run: |
56 | git status
57 | - name: Create release
58 | run: |
59 | npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN
60 | bun run release-it --ci
61 | env:
62 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
63 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
64 |
--------------------------------------------------------------------------------
/src/utils/parseColor.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from "vitest";
2 |
3 | import { parseColor } from "./parseColor";
4 | import { Vec4 } from "../math/Vec4";
5 |
6 | describe("parseColor", () => {
7 | describe("hex colors", () => {
8 | it("should parse hex color", () => {
9 | expect(parseColor("#ff0000")).toEqual(new Vec4(1, 0, 0, 1));
10 | });
11 |
12 | it("should parse with alpha", () => {
13 | expect(parseColor("#ff0000aa")).toEqual(new Vec4(1, 0, 0, 0.666_666_666_666_666_6));
14 | });
15 |
16 | it("should parse short hex", () => {
17 | expect(parseColor("#f00")).toEqual(new Vec4(1, 0, 0, 1));
18 | });
19 |
20 | it("should reject other length of hex", () => {
21 | expect(() => parseColor("#ff00")).toThrow();
22 | });
23 | });
24 |
25 | describe("RGB colors", () => {
26 | it("should parse RGB", () => {
27 | expect(parseColor("rgb(255, 0, 0)")).toEqual(new Vec4(1, 0, 0, 1));
28 | });
29 |
30 | it("should parse RGB without commas", () => {
31 | expect(parseColor("rgb(255 0 0)")).toEqual(new Vec4(1, 0, 0, 1));
32 | });
33 |
34 | it("should parse RGBA", () => {
35 | expect(parseColor("rgba(255, 0, 0, 0.5)")).toEqual(new Vec4(1, 0, 0, 0.5));
36 | });
37 |
38 | it("should reject RGB with alpha", () => {
39 | expect(() => parseColor("rgb(255, 0, 0, 0.5)")).toThrow();
40 | });
41 | });
42 |
43 | describe("HSL colors", () => {
44 | it("should parse", () => {
45 | expect(parseColor("hsl(60, 100%, 50%)")).toEqual(new Vec4(1, 1, 0, 1));
46 | });
47 |
48 | it("should parse without commas", () => {
49 | expect(parseColor("hsl(60 100% 50%)")).toEqual(new Vec4(1, 1, 0, 1));
50 | });
51 |
52 | it("should parse with alpha", () => {
53 | expect(parseColor("hsl(60, 100%, 50%, 0.8)")).toEqual(new Vec4(1, 1, 0, 0.8));
54 | });
55 |
56 | it("should parse HSLA", () => {
57 | expect(parseColor("hsla(60, 100%, 50%, 0.8)")).toEqual(new Vec4(1, 1, 0, 0.8));
58 | });
59 |
60 | it("should parse HSLA without commas", () => {
61 | expect(parseColor("hsla(60 100% 50% 0.8)")).toEqual(new Vec4(1, 1, 0, 0.8));
62 | });
63 |
64 | it("should parse HSLA with a slash", () => {
65 | expect(parseColor("hsla(60 100% 50% / 0.8)")).toEqual(new Vec4(1, 1, 0, 0.8));
66 | });
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/docs/app/text-rendering/page.mdx:
--------------------------------------------------------------------------------
1 | import { Api } from "./Api";
2 | import { getMetadata } from "../getMetadata";
3 | export const metadata = getMetadata("Text Rendering");
4 |
5 | # Text Rendering
6 |
7 | Everything about the process of getting text to the user's screen.
8 |
9 | ---
10 |
11 | ## Overview
12 |
13 | Text rendering is based on SDF (Signed Distance Field) font atlas approach. This means that for every font to be rendered, the TTF file must be parsed, glyph information extracted and then the font atlas needs to be generated. The basic and default approach is to use HTML Canvas API to prepare it.
14 |
15 | ---
16 |
17 | ## Font lookups
18 |
19 | All the information about text that is later needed is bundled in what I call `Lookups`:
20 |
21 | ```ts
22 | type Lookups = {
23 | atlas: {
24 | fontSize: number;
25 | height: number;
26 | positions: Array;
27 | sizes: Array;
28 | width: number;
29 | };
30 | fonts: Array<{
31 | ascender: number;
32 | buffer: ArrayBuffer;
33 | capHeight: number;
34 | glyphs: Map;
35 | kern: KerningFunction;
36 | name: string;
37 | ttf: TTF;
38 | unitsPerEm: number;
39 | }>;
40 | uvs: Map;
41 | };
42 | ```
43 |
44 | And this is how it is generated:
45 |
46 | ```ts
47 | // Alphabet is a string containing all characters that should be included in the atlas.
48 | const alphabet = "AaBbCc…";
49 |
50 | const [interTTF, interBoldTTF] = await Promise.all(
51 | ["/Inter.ttf", "/Inter-SemiBold.ttf"].map((url) =>
52 | fetch(url).then((response) => response.arrayBuffer()),
53 | ),
54 | );
55 |
56 | const lookups = prepareLookups(
57 | // Declare all fonts that should be included.
58 | [
59 | {
60 | buffer: interTTF,
61 | name: "Inter",
62 | ttf: parseTTF(interTTF),
63 | },
64 | {
65 | buffer: interBoldTTF,
66 | name: "InterBold",
67 | ttf: parseTTF(interBoldTTF),
68 | },
69 | ],
70 | // Render at 150px size. The size of the font atlas will be determined by packShelves() algorithm.
71 | { alphabet, fontSize: 150 },
72 | );
73 |
74 | // Generate _one_ font atlas texture for all fonts. In future it would make sense to allow splitting for projects that use many fonts.
75 | const fontAtlas = await renderFontAtlas(lookups, { useSDF: true });
76 | ```
77 |
78 | ## API
79 |
80 |
81 |
--------------------------------------------------------------------------------
/docs/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import { Body } from "./components/Body";
3 | import { mauveDark } from "@radix-ui/colors";
4 | import { Hr } from "./components/tags";
5 | import "./code-theme.css";
6 |
7 | export default function RootLayout({ children }: { children: React.ReactNode }) {
8 | return (
9 |
10 |
11 |
12 |
44 |
45 |
46 |
47 |
48 |
49 | {children}
50 |
51 |
52 | Copyright © Tomasz Czajęcki 2023
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/layout/paint.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { layout } from "./layout";
3 | import { Vec2 } from "../math/Vec2";
4 | import { paint } from "./paint";
5 | import type { Renderer } from "../renderer/Renderer";
6 | import type { Vec4 } from "../math/Vec4";
7 | import * as fixtures from "../fixtures";
8 | import { compose } from "./compose";
9 | import { EventManager } from "../EventManager";
10 | import type { TextAlign } from "./styling";
11 | import { getByTestId } from "../utils/getByTestId";
12 | import { UserEventType } from "./eventTypes";
13 |
14 | describe("paint", () => {
15 | it("handles scroll", () => {
16 | const eventManager = new EventManager();
17 | const ui = new MockRenderer();
18 |
19 | const root = fixtures.displayAndOverflow();
20 |
21 | layout(root, null, new Vec2(1000, 1000));
22 |
23 | compose(ui, root);
24 |
25 | function scroll(delta: Vec2, where: Vec2) {
26 | eventManager.dispatchEvent({
27 | bubbles: false,
28 | capturable: true,
29 | delta: delta,
30 | position: where,
31 | type: UserEventType.Scroll,
32 | });
33 |
34 | eventManager.deliverEvents(root);
35 | compose(ui, root);
36 | paint(ui, root);
37 | }
38 |
39 | scroll(new Vec2(0, 100), new Vec2(10, 10));
40 | expect(getByTestId(root, "D-halfWidth")?._state.scrollY).toBe(10);
41 |
42 | scroll(new Vec2(100, 0), new Vec2(10, 10));
43 | expect(getByTestId(root, "D-halfWidth")?._state.scrollY).toBe(10);
44 | expect(getByTestId(root, "D-halfWidth")?._state.scrollX).toBe(10);
45 |
46 | scroll(new Vec2(0, 100), new Vec2(240, 150));
47 | expect(getByTestId(root, "D-outer")?._state.scrollY).toBe(70);
48 | });
49 | });
50 |
51 | class MockRenderer implements Renderer {
52 | rectangle(
53 | _color: Vec4,
54 | _position: Vec2,
55 | _size: Vec2,
56 | _corners: Vec4,
57 | _borderWidth: Vec4,
58 | _borderColor: Vec4,
59 | _clipStart: Vec2,
60 | _clipSize: Vec2,
61 | _clipCorners: Vec4,
62 | ): void {}
63 | render(_commandEncoder: GPUCommandEncoder): void {}
64 | text(
65 | _text: string,
66 | _position: Vec2,
67 | _fontName: string,
68 | _fontSize: number,
69 | _color: Vec4,
70 | _textAlignment: TextAlign,
71 | _clipStart: Vec2,
72 | _clipSize: Vec2,
73 | _options?: { lineHeight?: number | undefined; maxWidth?: number | undefined } | undefined,
74 | ): void {}
75 | }
76 |
--------------------------------------------------------------------------------
/src/layout/BaseView.ts:
--------------------------------------------------------------------------------
1 | import type { Node } from "./Node";
2 | import type {
3 | ExactDecorativeProps,
4 | ExactLayoutProps,
5 | ViewStyleProps,
6 | LayoutNodeState,
7 | } from "./styling";
8 | import { normalizeLayoutProps, normalizeDecorativeProps, defaultLayoutNodeState } from "./styling";
9 |
10 | /**
11 | * Basic building block of the UI. A node in a tree which is mutated by the layout algorithm.
12 | */
13 | export class BaseView implements Node {
14 | testID: string | null;
15 | next: Node | null = null;
16 | prev: Node | null = null;
17 | firstChild: Node | null = null;
18 | lastChild: Node | null = null;
19 | parent: Node | null = null;
20 |
21 | /**
22 | * Internal state of the node. It's public so that you can use it if you need to, but it's ugly
23 | * so that you don't forget it might break at any time.
24 | */
25 | _state: LayoutNodeState = { ...defaultLayoutNodeState };
26 | /**
27 | * Should always be normalized.
28 | */
29 | _style: ExactDecorativeProps & ExactLayoutProps;
30 |
31 | constructor(
32 | readonly props: {
33 | onClick?(): void;
34 | style?: ViewStyleProps;
35 | testID?: string;
36 | },
37 | ) {
38 | this.testID = props.testID ?? null;
39 | this._style = normalizeDecorativeProps(
40 | normalizeLayoutProps(props.style ?? {}) as ViewStyleProps,
41 | );
42 | }
43 |
44 | add(node: Node): Node {
45 | node.parent = this;
46 |
47 | if (this.firstChild === null) {
48 | this.firstChild = node;
49 | this.lastChild = node;
50 | } else {
51 | if (this.lastChild === null) {
52 | throw new Error("Last child must be set.");
53 | }
54 |
55 | node.prev = this.lastChild;
56 | this.lastChild.next = node;
57 | this.lastChild = node;
58 | }
59 |
60 | return node;
61 | }
62 |
63 | remove(node: Node): void {
64 | // Check if node is a child of this node.
65 | if (node.parent !== this) {
66 | console.warn("Node is not a child of this node.");
67 | }
68 |
69 | if (node.prev !== null) {
70 | node.prev.next = node.next;
71 | }
72 |
73 | if (node.next !== null) {
74 | node.next.prev = node.prev;
75 | }
76 |
77 | if (this.firstChild === node) {
78 | this.firstChild = node.next;
79 | }
80 |
81 | if (this.lastChild === node) {
82 | this.lastChild = node.prev;
83 | }
84 |
85 | node.prev = null;
86 | node.next = null;
87 | node.parent = null;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/font/calculateGlyphQuads.ts:
--------------------------------------------------------------------------------
1 | import { invariant } from "../utils/invariant";
2 | import type { TTF } from "./parseTTF";
3 | import type { Glyph } from "./types";
4 |
5 | export const MISSING_GLYPH = "?";
6 | /**
7 | * Calculates glyph information for a given font file and optional alphabet.
8 | *
9 | * @param ttf parsed TTF file (see parseTTF.ts).
10 | * @param alphabet a string of characters to include in the atlas. If not provided, all characters of the font will be included.
11 | * @returns an array of glyph quads.
12 | */
13 | export function calculateGlyphQuads(ttf: TTF, alphabet?: string): Array {
14 | const charCodes = alphabet
15 | ? // Ensure that the characters are unique.
16 | [...new Set(alphabet.split("").map((c) => c.charCodeAt(0)))]
17 | : Object.keys(ttf.cmap.glyphIndexMap).map(Number);
18 |
19 | if (!charCodes.some((code) => code === MISSING_GLYPH.charCodeAt(0))) {
20 | charCodes.push(MISSING_GLYPH.charCodeAt(0));
21 | }
22 |
23 | return charCodes.map((code) => {
24 | invariant(ttf, "TTF is missing.");
25 |
26 | const index =
27 | ttf.cmap.glyphIndexMap[code] ?? ttf.cmap.glyphIndexMap[MISSING_GLYPH.charCodeAt(0)]!;
28 |
29 | if (!ttf.cmap.glyphIndexMap[code]) {
30 | console.warn(
31 | `Couldn't find index for character '${String.fromCharCode(code)}' in glyphIndexMap.`,
32 | );
33 | }
34 |
35 | invariant(index < ttf.glyf.length, "Index is out of bounds for glyf table.");
36 |
37 | const lastMetric = ttf.hmtx.hMetrics.at(-1);
38 | invariant(
39 | lastMetric,
40 | "The last advance is missing, which means that hmtx table is probably empty.",
41 | );
42 |
43 | const hmtx =
44 | index < ttf.hhea.numberOfHMetrics
45 | ? ttf.hmtx.hMetrics[index]
46 | : {
47 | advanceWidth: lastMetric.advanceWidth,
48 | leftSideBearing: ttf.hmtx.leftSideBearings[index - ttf.hhea.numberOfHMetrics] ?? 0,
49 | };
50 | const glyf = ttf.glyf[index];
51 | invariant(glyf, "Glyph is missing.");
52 | invariant(hmtx, "HMTX is missing.");
53 |
54 | const glyph: Glyph = {
55 | character: String.fromCharCode(code),
56 | height: glyf.yMax - glyf.yMin,
57 | id: code,
58 | lsb: hmtx.leftSideBearing,
59 | rsb: hmtx.advanceWidth - hmtx.leftSideBearing - (glyf.xMax - glyf.xMin),
60 | width: glyf.xMax - glyf.xMin,
61 | x: glyf.xMin,
62 | y: glyf.yMin,
63 | };
64 |
65 | return glyph;
66 | });
67 | }
68 |
--------------------------------------------------------------------------------
/src/utils/Tree.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from "vitest";
2 | import { Tree } from "./Tree";
3 |
4 | describe("Tree", () => {
5 | describe("constructor", () => {
6 | it("should create a new Tree node with the given value", () => {
7 | const tree = new Tree(5);
8 |
9 | expect(tree.value).toBe(5);
10 | expect(tree.next).toBeNull();
11 | expect(tree.prev).toBeNull();
12 | expect(tree.firstChild).toBeNull();
13 | expect(tree.lastChild).toBeNull();
14 | expect(tree.parent).toBeNull();
15 | });
16 | });
17 |
18 | describe("addChild", () => {
19 | it("should add a child to the tree when it has no children", () => {
20 | const tree = new Tree(5);
21 |
22 | const child = new Tree(10);
23 | tree.add(child);
24 |
25 | expect(tree.firstChild).toBe(child);
26 | expect(tree.lastChild).toBe(child);
27 | expect(child.parent).toBe(tree);
28 | expect(child.prev).toBeNull();
29 | expect(child.next).toBeNull();
30 | });
31 |
32 | it("should add a second child correctly to the tree", () => {
33 | const tree = new Tree(5);
34 |
35 | const firstChild = new Tree(10);
36 | tree.add(firstChild);
37 |
38 | const secondChild = new Tree(20);
39 | tree.add(secondChild);
40 |
41 | expect(tree.firstChild).toBe(firstChild);
42 | expect(tree.lastChild).toBe(secondChild);
43 | expect(secondChild.prev).toBe(firstChild);
44 | expect(firstChild.next).toBe(secondChild);
45 | expect(secondChild.parent).toBe(tree);
46 | });
47 |
48 | it("should handle adding multiple children", () => {
49 | const tree = new Tree(5);
50 |
51 | const child1 = new Tree(10);
52 | const child2 = new Tree(20);
53 | const child3 = new Tree(30);
54 |
55 | tree.add(child1);
56 | tree.add(child2);
57 | tree.add(child3);
58 |
59 | expect(tree.firstChild).toBe(child1);
60 | expect(tree.lastChild).toBe(child3);
61 |
62 | expect(child1.prev).toBeNull();
63 | expect(child1.next).toBe(child2);
64 | expect(child1.parent).toBe(tree);
65 |
66 | expect(child2.prev).toBe(child1);
67 | expect(child2.next).toBe(child3);
68 | expect(child2.parent).toBe(tree);
69 |
70 | expect(child3.prev).toBe(child2);
71 | expect(child3.next).toBeNull();
72 | expect(child3.parent).toBe(tree);
73 | });
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/src/math/Vec4.ts:
--------------------------------------------------------------------------------
1 | import { Vec2 } from "./Vec2";
2 | import { Vec3 } from "./Vec3";
3 |
4 | const EPSILON = 0.001;
5 |
6 | /**
7 | * A 4-dimensional vector.
8 | */
9 | export class Vec4 {
10 | constructor(
11 | public readonly x: number,
12 | public readonly y: number,
13 | public readonly z: number,
14 | public readonly w: number,
15 | ) {}
16 |
17 | add(other: Vec4): Vec4 {
18 | return new Vec4(this.x + other.x, this.y + other.y, this.z + other.z, this.w + other.w);
19 | }
20 |
21 | subtract(other: Vec4): Vec4 {
22 | return new Vec4(this.x - other.x, this.y - other.y, this.z - other.z, this.w - other.w);
23 | }
24 |
25 | length(): number {
26 | return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w);
27 | }
28 |
29 | normalize(): Vec4 {
30 | const length = this.length();
31 | return new Vec4(this.x / length, this.y / length, this.z / length, this.w / length);
32 | }
33 |
34 | scale(scalar: number): Vec4 {
35 | return new Vec4(this.x * scalar, this.y * scalar, this.z * scalar, this.w * scalar);
36 | }
37 |
38 | cross(other: Vec4): Vec4 {
39 | return new Vec4(
40 | this.y * other.z - this.z * other.y,
41 | this.z * other.x - this.x * other.z,
42 | this.x * other.y - this.y * other.x,
43 | 0,
44 | );
45 | }
46 |
47 | dot(other: Vec4): number {
48 | return this.x * other.x + this.y * other.y + this.z * other.z + this.w * other.w;
49 | }
50 |
51 | distance(other: Vec4): number {
52 | return this.subtract(other).length();
53 | }
54 |
55 | lerp(other: Vec4, t: number): Vec4 {
56 | return this.add(other.subtract(this).scale(t));
57 | }
58 |
59 | xy(): Vec2 {
60 | return new Vec2(this.x, this.y);
61 | }
62 |
63 | zw(): Vec2 {
64 | return new Vec2(this.z, this.w);
65 | }
66 |
67 | xyz(): Vec3 {
68 | return new Vec3(this.x, this.y, this.z);
69 | }
70 |
71 | equalsEpsilon(other: Vec4, epsilon: number): boolean {
72 | return (
73 | Math.abs(this.x - other.x) < epsilon &&
74 | Math.abs(this.y - other.y) < epsilon &&
75 | Math.abs(this.z - other.z) < epsilon &&
76 | Math.abs(this.w - other.w) < epsilon
77 | );
78 | }
79 |
80 | equals(other: Vec4): boolean {
81 | return this.equalsEpsilon(other, EPSILON);
82 | }
83 |
84 | data(): Array {
85 | return [this.x, this.y, this.z, this.w];
86 | }
87 |
88 | toString(): string {
89 | return `(${this.x}, ${this.y}, ${this.z}, ${this.w})`;
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/MAINTAINERS.md:
--------------------------------------------------------------------------------
1 | # For maintainers
2 |
3 | (Currently just me)
4 |
5 | ### Updating logo
6 |
7 | 1. Export from Figma:
8 |
9 | - Website logo: SVG (`docs/public/logo.svg`)
10 | - GitHub logo: 0.25x PNG (`.github/assets/logo.png`)
11 | - Favicon:
12 | - Copy the original frame.
13 | - Scale to 16x16px size.
14 | - Remove moustache 🥸.
15 | - Export as 1x PNG.
16 | - Convert to `*.ico`.
17 | - Replace in `docs/public/favicon.ico`.
18 |
19 | ### Replacing map
20 |
21 | If for any reason the map in the [map example](https://red-otter.dev/#map) needs to be replaced:
22 |
23 | 1. use the following script:
24 |
25 | ```ts
26 | const RADIUS = 6378137.0;
27 |
28 | function degreesToMeters(lat: number, lng: number) {
29 | return {
30 | x: (RADIUS * lng * Math.PI) / 180.0,
31 | y: RADIUS * Math.atanh(Math.sin((lat * Math.PI) / 180.0)),
32 | };
33 | }
34 |
35 | function metersToDegrees(x: number, y: number) {
36 | return {
37 | lng: ((x / RADIUS) * 180.0) / Math.PI,
38 | lat: (Math.asin(Math.tanh(y / RADIUS)) * 180.0) / Math.PI,
39 | };
40 | }
41 |
42 | const position = { lat: 60.26627, lng: 24.98868 };
43 | const squareSide = 400;
44 |
45 | const center = degreesToMeters(position.lat, position.lng);
46 | const ne = metersToDegrees(
47 | center.x + squareSide / 2,
48 | center.y + squareSide / 2
49 | );
50 | const sw = metersToDegrees(
51 | center.x - squareSide / 2,
52 | center.y - squareSide / 2
53 | );
54 |
55 | const boundingBox = `${sw.lat},${sw.lng},${ne.lat},${ne.lng}`;
56 |
57 | const query = `
58 | [out:json][bbox:${boundingBox}];
59 | (
60 | way[building];
61 | relation[building];
62 | way[highway~residential];
63 | );
64 | (._;>;);
65 | out;`;
66 |
67 | console.log(query);
68 | ```
69 |
70 | 1. Copy the output and paste it on [Overpass Turbo](https://overpass-turbo.eu).
71 | 1. Select **Data** in top right corner.
72 | 1. Copy the content and save it to file `osm-map.json`.
73 | 1. Install [`osmtogeojson`](https://github.com/tyrasd/osmtogeojson).
74 | 1. Run: `osmtogeojson osm-map.json > docs/src/map.json`.
75 |
76 | ## Q&A
77 |
78 | - Why `--no-threads` in `yarn test`? [Answer](https://twitter.com/youyuxi/status/1621299180261244928?s=20&t=fyQYZyV2omJHrGjlVrfq6A)
79 |
80 | - Why relative imports in CI example? Vite dev server running inside `run.ts` had problems with resolving paths. Similarly because of the same reason files within `/src` use relative imports with each other.
81 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "red-otter",
3 | "description": "Self-contained UI rendering library for 3D graphics APIs.",
4 | "version": "0.1.15",
5 | "main": "dist/src/index.js",
6 | "type": "module",
7 | "sideEffects": false,
8 | "module": "dist/src/index.js",
9 | "exports": {
10 | ".": {
11 | "import": "./dist/src/index.js",
12 | "require": "./dist/src/index.js"
13 | }
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "homepage": "https://red-otter.dev",
19 | "repository": {
20 | "type": "git",
21 | "url": "https://github.com/tchayen/red-otter"
22 | },
23 | "license": "MIT",
24 | "scripts": {
25 | "ci": "bun run lint && bun run tsc && bun run vitest run && bun run build",
26 | "coverage": "vitest --coverage",
27 | "dev": "cd examples && vite --port 5005",
28 | "build": "bun run tsc -p tsconfig.build.json",
29 | "prepare": "bun run build",
30 | "lint": "eslint src",
31 | "loc": "find src -name \"*.ts\" ! -path \"*/node_modules/*\" -exec cat {} + | wc -l",
32 | "prepare-sandpack": "vite build",
33 | "preview": "vite preview",
34 | "test": "vitest"
35 | },
36 | "dependencies": {
37 | "@petamoriken/float16": "^3.8.4"
38 | },
39 | "devDependencies": {
40 | "@release-it/conventional-changelog": "^8.0.1",
41 | "@rollup/plugin-typescript": "^11.1.6",
42 | "@total-typescript/ts-reset": "^0.5.1",
43 | "@types/node": "^20.11.5",
44 | "@typescript-eslint/eslint-plugin": "^6.19.0",
45 | "@typescript-eslint/parser": "^6.19.0",
46 | "@vitest/coverage-istanbul": "^1.2.1",
47 | "@vitest/coverage-v8": "^1.2.1",
48 | "@vitest/ui": "^1.2.1",
49 | "@webgpu/types": "^0.1.40",
50 | "eslint": "^8.56.0",
51 | "eslint-import-resolver-typescript": "^3.6.1",
52 | "eslint-plugin-comment-length": "^1.7.3",
53 | "eslint-plugin-import": "^2.29.1",
54 | "eslint-plugin-sort-keys-fix": "^1.1.2",
55 | "eslint-plugin-typescript-sort-keys": "^3.1.0",
56 | "eslint-plugin-unicorn": "^50.0.1",
57 | "eslint-plugin-unused-imports": "^3.0.0",
58 | "prettier": "^3.2.4",
59 | "release-it": "^17.0.1",
60 | "typescript": "^5.3.3",
61 | "vite": "^5.0.12",
62 | "vite-plugin-dts": "^3.7.1",
63 | "vitest": "^1.2.1"
64 | },
65 | "release-it": {
66 | "git": {
67 | "commitMessage": "chore: release ${version}",
68 | "tagName": "v${version}"
69 | },
70 | "npm": {
71 | "publish": true
72 | },
73 | "github": {
74 | "release": true
75 | },
76 | "plugins": {
77 | "@release-it/conventional-changelog": {
78 | "preset": {
79 | "name": "conventionalcommits"
80 | },
81 | "infile": "CHANGELOG.md"
82 | }
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/docs/app/components/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Link from "next/link";
3 | import { usePathname } from "next/navigation";
4 | import type { PropsWithChildren } from "react";
5 | import { twMerge } from "tailwind-merge";
6 |
7 | export function Sidebar() {
8 | const path = usePathname();
9 |
10 | return (
11 | <>
12 |
13 |
14 | Home
15 |
16 |
17 | Blog
18 |
19 |
20 | Getting Started
21 |
22 |
23 | Examples
24 |
25 |
26 | Roadmap
27 |
28 |
29 | Styling
30 |
31 |
32 | Text Rendering
33 |
34 |
35 | Layout Engine
36 |
37 |
38 | Interactivity
39 |
40 |
41 | Renderer
42 |
43 |
44 | Math Library
45 |
46 |
47 | >
48 | );
49 | }
50 |
51 | function SidebarLink({
52 | href,
53 | currentPath,
54 | onClick,
55 | children,
56 | }: PropsWithChildren<{ currentPath: string; href: string; onClick?: () => void }>) {
57 | const isExternal = href.startsWith("http");
58 | const isCurrent = getIsCurrent(currentPath, href);
59 |
60 | return (
61 |
71 | {children}
72 | {isExternal && ↗ }
73 |
74 | );
75 | }
76 |
77 | function getIsCurrent(path: string, href: string) {
78 | if (href === "/") {
79 | return path === "/";
80 | }
81 |
82 | return path.startsWith(href);
83 | }
84 |
--------------------------------------------------------------------------------
/docs/app/components/HighlightMatches.tsx:
--------------------------------------------------------------------------------
1 | type HighlightMatchesProps = {
2 | match: string;
3 | value: string;
4 | };
5 |
6 | export function HighlightMatches({ match, value }: HighlightMatchesProps) {
7 | // Create a RegExp with word boundaries to match whole words only, case-insensitive.
8 | const regExp = new RegExp(regExpEscape(match), "ig");
9 | let startIndex = 0;
10 | let endIndex = value.length;
11 | let needsLeadingEllipsis = false;
12 | let needsTrailingEllipsis = false;
13 |
14 | // Check if the string needs to be shortened.
15 | if (value.length > 100) {
16 | const matchIndex = value.search(regExp);
17 |
18 | // If a match is found, and the string is longer than 100 characters, we'll slice around the
19 | // match.
20 | if (matchIndex !== -1) {
21 | startIndex = Math.max(0, matchIndex - 50);
22 | endIndex = Math.min(value.length, matchIndex + 50 + match.length);
23 | needsLeadingEllipsis = startIndex > 0;
24 | needsTrailingEllipsis = endIndex < value.length;
25 | } else {
26 | // If no match is found, we'll slice the first 100 characters.
27 | endIndex = 100;
28 | needsTrailingEllipsis = true;
29 | }
30 | }
31 |
32 | // Slice the value string around the match for highlighting.
33 | let slicedValue = value.slice(startIndex, endIndex);
34 |
35 | // Apply the ellipsis if needed.
36 | if (needsLeadingEllipsis) {
37 | slicedValue = "…" + slicedValue;
38 | }
39 | if (needsTrailingEllipsis) {
40 | slicedValue = slicedValue.replace(/\.$/, ""); // Remove any trailing dot before adding ellipsis.
41 | slicedValue += "…";
42 | }
43 |
44 | // Split the sliced value by the regular expression to get the parts to be joined later.
45 | const parts = slicedValue.split(regExp);
46 |
47 | // Use a map to hold the matched strings in their original case.
48 | const matches = slicedValue.match(regExp) || [];
49 |
50 | // Reconstruct the string with highlighted matches.
51 | const highlightedParts = parts.map((part, index) => {
52 | if (index < matches.length) {
53 | // Get the original matched string to preserve case.
54 | const originalMatch = matches[index];
55 | return (
56 | <>
57 | {replaceHtml(part)}
58 | {originalMatch}
59 | >
60 | );
61 | }
62 | return replaceHtml(part);
63 | });
64 |
65 | return <>{highlightedParts}>;
66 | }
67 |
68 | function regExpEscape(value: string) {
69 | return value.replaceAll(/[\s#$()*+,.?[\\\]^{|}-]/g, "\\$&");
70 | }
71 |
72 | function replaceHtml(html: string) {
73 | return html
74 | .replaceAll(">", ">")
75 | .replaceAll("<", "<")
76 | .replaceAll("&", "&")
77 | .replaceAll(""", '"')
78 | .replaceAll("'", "'");
79 | }
80 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { EventManager } from "./EventManager";
2 |
3 | export { layout } from "./layout/layout";
4 | export { paint } from "./layout/paint";
5 | export { compose } from "./layout/compose";
6 | export type { Node } from "./layout/Node";
7 | export { BaseView } from "./layout/BaseView";
8 | export { View } from "./layout/View";
9 | export { Text } from "./layout/Text";
10 | export type {
11 | DecorativeProps,
12 | LayoutNodeState,
13 | LayoutProps,
14 | TextStyleProps,
15 | ViewStyleProps,
16 | } from "./layout/styling";
17 | export {
18 | AlignItems,
19 | AlignSelf,
20 | JustifyContent,
21 | AlignContent,
22 | FlexDirection,
23 | FlexWrap,
24 | Overflow,
25 | Display,
26 | Position,
27 | TextTransform,
28 | TextAlign,
29 | Whitespace,
30 | } from "./layout/styling";
31 |
32 | export { invariant } from "./utils/invariant";
33 | export { LRUCache } from "./utils/LRUCache";
34 | export { Queue } from "./utils/Queue";
35 |
36 | export { BinaryReader } from "./font/BinaryReader";
37 | export { toSDF } from "./font/toSDF";
38 | export { parseTTF } from "./font/parseTTF";
39 | export { shapeText } from "./font/shapeText";
40 | export { prepareLookups } from "./font/prepareLookups";
41 | export type { Lookups, Glyph, KerningFunction } from "./font/types";
42 | export { renderFontAtlas, fontSizeToGap } from "./font/renderFontAtlas";
43 | export { generateGlyphToClassMap } from "./font/generateGlyphToClassMap";
44 | export { generateKerningFunction } from "./font/generateKerningFunction";
45 | export { calculateGlyphQuads } from "./font/calculateGlyphQuads";
46 |
47 | export type { Renderer } from "./renderer/Renderer";
48 | export { WebGPURenderer } from "./renderer/WebGPURenderer";
49 |
50 | export { Vec2 } from "./math/Vec2";
51 | export { Vec3 } from "./math/Vec3";
52 | export { Vec4 } from "./math/Vec4";
53 | export { Mat4 } from "./math/Mat4";
54 | export { packShelves } from "./math/packShelves";
55 | export { triangulateLine } from "./math/triangulateLine";
56 | export {
57 | clamp,
58 | lerp,
59 | smoothstep,
60 | toRadians,
61 | toDegrees,
62 | nextPowerOfTwo,
63 | intersection,
64 | isInside,
65 | } from "./math/utils";
66 | export { triangulatePolygon } from "./math/triangulatePolygon";
67 |
68 | export { Button } from "./widgets/Button";
69 | export { Input } from "./widgets/Input";
70 |
71 | export type {
72 | MouseClickHandler,
73 | MouseMoveHandler,
74 | MouseEnterHandler,
75 | MouseLeaveHandler,
76 | MouseDownHandler,
77 | MouseUpHandler,
78 | ScrollHandler,
79 | KeyDownHandler,
80 | KeyUpHandler,
81 | KeyPressHandler,
82 | FocusHandler,
83 | BlurHandler,
84 | LayoutHandler,
85 | MouseEvent,
86 | ScrollEvent,
87 | KeyboardEvent,
88 | FocusEvent,
89 | LayoutEvent,
90 | UserEvent,
91 | } from "./layout/eventTypes";
92 | export { UserEventType, isMouseEvent, isKeyboardEvent } from "./layout/eventTypes";
93 |
--------------------------------------------------------------------------------
/docs/app/components/Outline.tsx:
--------------------------------------------------------------------------------
1 | import { usePathname } from "next/navigation";
2 | import { useEffect, useLayoutEffect, useState } from "react";
3 | import _ from "lodash";
4 | import { twMerge } from "tailwind-merge";
5 | import Link from "next/link";
6 | import { outline } from "./tags";
7 |
8 | type Header = {
9 | id: string;
10 | level: number;
11 | text: string;
12 | };
13 |
14 | const SCROLL_OFFSET = 64; // scroll-mt-16
15 |
16 | export function Outline() {
17 | const pathname = usePathname();
18 | const [headers, setHeaders] = useState>([]);
19 | const [active, setActive] = useState(0);
20 |
21 | const onScroll = _.throttle(() => {
22 | const index = headers
23 | .map((header) => {
24 | const element = document.getElementById(header.id);
25 | if (!element) {
26 | return 0;
27 | }
28 | return element.getBoundingClientRect().top - SCROLL_OFFSET;
29 | })
30 | .findIndex((element) => element > 0);
31 | setActive(Math.max(0, index === -1 ? headers.length - 1 : index - 1));
32 | }, 150);
33 |
34 | useEffect(() => {
35 | document.addEventListener("scroll", onScroll);
36 | return () => {
37 | document.removeEventListener("scroll", onScroll);
38 | };
39 | }, [onScroll]);
40 |
41 | useLayoutEffect(() => {
42 | const newHeaders = [...document.querySelectorAll("h1, h2, h3, h4, h5, h6")].map((header) => {
43 | const level = Number.parseInt(header.tagName[1] ?? "", 10);
44 | return {
45 | id: header.id,
46 | level,
47 | text: header.textContent ?? "",
48 | };
49 | });
50 | setHeaders(newHeaders);
51 | }, [pathname]);
52 |
53 | // Execute setActive() in a timeout so that:
54 | // - User clicks, browser takes user to the link.
55 | // - The scroll event above fires and the active link is updated based on scroll (which in the
56 | // bottom of the screen might be something else above rather than what user clicked).
57 | // - The timeout fires and updates the active link to what user clicked.
58 | const onClick = (i: number) => () => {
59 | setTimeout(() => setActive(i), 0);
60 | };
61 |
62 | return (
63 |
64 |
On this page
65 | {headers.map((header, i) => {
66 | return (
67 |
75 | {Array.from({ length: header.level - 1 })
76 | .fill(undefined)
77 | .map((_, i) => (
78 |
79 | ))}
80 |
81 | {header.text}
82 |
83 |
84 | );
85 | })}
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/widgets/updateSelection.ts:
--------------------------------------------------------------------------------
1 | import type { KeyboardEvent } from "../layout/eventTypes";
2 |
3 | export function updateSelection(
4 | value: string,
5 | cursor: number,
6 | mark: number,
7 | event: KeyboardEvent,
8 | ): { cursor: number; mark: number; value: string } {
9 | const valueAsNumbers = value.split("").map((character) => character.charCodeAt(0));
10 |
11 | const isCtrlPressed = event.modifiers.control;
12 | const isShiftPressed = event.modifiers.shift;
13 | const isCmdPressed = event.modifiers.meta;
14 |
15 | const selectionLength = Math.abs(cursor - mark);
16 |
17 | switch (event.code) {
18 | // Arrow left
19 | case 37: {
20 | if ((isCmdPressed || isCtrlPressed) && isShiftPressed) {
21 | cursor = 0;
22 | } else if (isCmdPressed || isCtrlPressed) {
23 | cursor = 0;
24 | mark = 0;
25 | } else if (isShiftPressed) {
26 | cursor = Math.max(0, cursor - 1);
27 | } else if (selectionLength > 0) {
28 | cursor = Math.min(cursor, mark);
29 | mark = cursor;
30 | } else {
31 | cursor = Math.max(0, cursor - 1);
32 | mark = cursor;
33 | }
34 | break;
35 | }
36 | // Arrow right
37 | case 39: {
38 | if ((isCmdPressed || isCtrlPressed) && isShiftPressed) {
39 | cursor = value.length;
40 | } else if (isCmdPressed || isCtrlPressed) {
41 | cursor = value.length;
42 | mark = cursor;
43 | } else if (isShiftPressed) {
44 | cursor = Math.min(value.length, cursor + 1);
45 | } else if (selectionLength > 0) {
46 | cursor = Math.max(cursor, mark);
47 | mark = cursor;
48 | } else {
49 | cursor = Math.min(value.length, cursor + 1);
50 | mark = cursor;
51 | }
52 | break;
53 | }
54 |
55 | // Backspace
56 | case 8: {
57 | if (selectionLength > 0) {
58 | valueAsNumbers.splice(Math.min(cursor, mark), selectionLength);
59 | cursor = Math.min(cursor, mark);
60 | mark = cursor;
61 | } else if (cursor === 0) {
62 | // Do nothing.
63 | } else {
64 | valueAsNumbers.splice(cursor - 1, 1);
65 | mark = cursor - 1;
66 | cursor = mark;
67 | }
68 | break;
69 | }
70 |
71 | // Delete
72 | case 46: {
73 | if (selectionLength > 0) {
74 | valueAsNumbers.splice(Math.min(cursor, mark), selectionLength);
75 | cursor = Math.min(cursor, mark);
76 | mark = cursor;
77 | } else if (cursor === value.length) {
78 | // Do nothing.
79 | } else {
80 | valueAsNumbers.splice(cursor, 1);
81 | mark = cursor;
82 | }
83 | break;
84 | }
85 |
86 | // A
87 | case 65: {
88 | if (isCmdPressed || isCtrlPressed) {
89 | cursor = value.length;
90 | mark = 0;
91 | }
92 | break;
93 | }
94 | }
95 |
96 | return {
97 | cursor,
98 | mark,
99 | value: valueAsNumbers.map((code) => String.fromCharCode(code)).join(""),
100 | };
101 | }
102 |
--------------------------------------------------------------------------------
/src/math/packShelves.ts:
--------------------------------------------------------------------------------
1 | import { invariant } from "../utils/invariant";
2 | import { Vec2 } from "./Vec2";
3 |
4 | export type Packing = {
5 | height: number;
6 | positions: Array;
7 | width: number;
8 | };
9 |
10 | /**
11 | * Takes sizes of rectangles and packs them into a single texture. Width and
12 | * height will be the next power of two.
13 | */
14 | export function packShelves(sizes: Array): Packing {
15 | let area = 0;
16 | let maxWidth = 0;
17 |
18 | const rectangles = sizes.map((rectangle, i) => ({
19 | height: rectangle.y,
20 | id: i,
21 | width: rectangle.x,
22 | x: 0,
23 | y: 0,
24 | }));
25 |
26 | for (const box of rectangles) {
27 | area += box.width * box.height;
28 | maxWidth = Math.max(maxWidth, box.width);
29 | }
30 |
31 | rectangles.sort((a, b) => b.height - a.height);
32 |
33 | // Aim for a squarish resulting container. Slightly adjusted for sub-100%
34 | // space utilization.
35 | const startWidth = Math.max(Math.ceil(Math.sqrt(area / 0.95)), maxWidth);
36 |
37 | const regions = [{ height: Number.POSITIVE_INFINITY, width: startWidth, x: 0, y: 0 }];
38 |
39 | let width = 0;
40 | let height = 0;
41 |
42 | for (const box of rectangles) {
43 | for (let i = regions.length - 1; i >= 0; i--) {
44 | const region = regions[i];
45 | invariant(region, "Region is missing.");
46 | if (box.width > region.width || box.height > region.height) {
47 | continue;
48 | }
49 |
50 | box.x = region.x;
51 | box.y = region.y;
52 | height = Math.max(height, box.y + box.height);
53 | width = Math.max(width, box.x + box.width);
54 |
55 | if (box.width === region.width && box.height === region.height) {
56 | const last = regions.pop();
57 | invariant(last, "Regions array should not be empty.");
58 |
59 | if (i < regions.length) {
60 | regions[i] = last;
61 | }
62 | } else if (box.height === region.height) {
63 | region.x += box.width;
64 | region.width -= box.width;
65 | } else if (box.width === region.width) {
66 | region.y += box.height;
67 | region.height -= box.height;
68 | } else {
69 | regions.push({
70 | height: box.height,
71 | width: region.width - box.width,
72 | x: region.x + box.width,
73 | y: region.y,
74 | });
75 |
76 | region.y += box.height;
77 | region.height -= box.height;
78 | }
79 | break;
80 | }
81 | }
82 |
83 | const size = Math.max(ceilPow2(width), ceilPow2(height));
84 | rectangles.sort((a, b) => a.id - b.id);
85 |
86 | return {
87 | height: size,
88 | positions: rectangles.map((rectangle) => new Vec2(rectangle.x, rectangle.y)),
89 | width: size,
90 | };
91 | }
92 |
93 | function ceilPow2(x: number): number {
94 | let value = x;
95 | value -= 1;
96 | value |= value >> 1;
97 | value |= value >> 2;
98 | value |= value >> 4;
99 | value |= value >> 8;
100 | value |= value >> 16;
101 | value += 1;
102 | return value;
103 | }
104 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | const Rule = {
2 | Error: 2,
3 | Off: 0,
4 | Warn: 1,
5 | };
6 |
7 | module.exports = {
8 | env: {
9 | browser: true,
10 | es2021: true,
11 | node: true,
12 | },
13 | extends: [
14 | "eslint:recommended",
15 | "plugin:@typescript-eslint/recommended",
16 | "plugin:import/errors",
17 | "plugin:import/typescript",
18 | "plugin:comment-length/recommended",
19 | ],
20 | parser: "@typescript-eslint/parser",
21 | parserOptions: {
22 | project: "./tsconfig.json",
23 | },
24 | plugins: [
25 | "@typescript-eslint",
26 | "import",
27 | "sort-keys-fix",
28 | "typescript-sort-keys",
29 | "unused-imports",
30 | "unicorn",
31 | ],
32 | rules: {
33 | "@typescript-eslint/array-type": [Rule.Error, { default: "generic" }],
34 | "@typescript-eslint/consistent-type-exports": Rule.Error,
35 | "@typescript-eslint/consistent-type-imports": Rule.Error,
36 | "@typescript-eslint/no-dynamic-delete": Rule.Error,
37 | "@typescript-eslint/no-invalid-void-type": Rule.Error,
38 | "@typescript-eslint/no-namespace": Rule.Off,
39 | "@typescript-eslint/no-non-null-assertion": Rule.Off,
40 | "@typescript-eslint/no-unused-vars": Rule.Off,
41 | "@typescript-eslint/no-var-requires": Rule.Off,
42 | "@typescript-eslint/restrict-template-expressions": Rule.Error,
43 | "comment-length/limit-multi-line-comments": [Rule.Warn, { maxLength: 100 }],
44 | "comment-length/limit-single-line-comments": [Rule.Warn, { maxLength: 100 }],
45 | curly: Rule.Error,
46 | "import/no-duplicates": Rule.Error,
47 | "import/no-extraneous-dependencies": Rule.Error,
48 | "sort-keys-fix/sort-keys-fix": Rule.Error,
49 | "typescript-sort-keys/interface": Rule.Error,
50 | "typescript-sort-keys/string-enum": Rule.Error,
51 | "unicorn/better-regex": Rule.Error,
52 | "unicorn/catch-error-name": Rule.Error,
53 | "unicorn/consistent-function-scoping": Rule.Error,
54 | "unicorn/no-abusive-eslint-disable": Rule.Error,
55 | "unicorn/no-hex-escape": Rule.Error,
56 | "unicorn/no-typeof-undefined": Rule.Error,
57 | "unicorn/no-useless-promise-resolve-reject": Rule.Error,
58 | "unicorn/no-useless-spread": Rule.Error,
59 | "unicorn/numeric-separators-style": Rule.Error,
60 | "unicorn/prefer-array-flat-map": Rule.Error,
61 | "unicorn/prefer-array-index-of": Rule.Error,
62 | "unicorn/prefer-array-some": Rule.Error,
63 | "unicorn/prefer-at": Rule.Error,
64 | "unicorn/prefer-dom-node-append": Rule.Error,
65 | "unicorn/prefer-native-coercion-functions": Rule.Error,
66 | "unicorn/prefer-node-protocol": Rule.Error,
67 | "unicorn/prefer-number-properties": Rule.Error,
68 | "unicorn/prefer-optional-catch-binding": Rule.Error,
69 | "unicorn/prefer-set-size": Rule.Error,
70 | "unicorn/prefer-string-replace-all": Rule.Error,
71 | "unicorn/prefer-string-slice": Rule.Error,
72 | "unicorn/prefer-ternary": Rule.Error,
73 | "unicorn/prefer-top-level-await": Rule.Error,
74 | "unicorn/text-encoding-identifier-case": Rule.Error,
75 | "unused-imports/no-unused-imports": Rule.Error,
76 | },
77 | settings: {
78 | "import/parsers": {
79 | "@typescript-eslint/parser": [".ts", ".tsx"],
80 | },
81 | },
82 | };
83 |
--------------------------------------------------------------------------------
/src/font/toSDF.ts:
--------------------------------------------------------------------------------
1 | // http://cs.brown.edu/people/pfelzens/papers/dt-final.pdf
2 |
3 | /**
4 | * Takes `ImageData` and returns a `new ImageData()` with the SDF applied.
5 | */
6 | export function toSDF(
7 | imageData: ImageData,
8 | width: number,
9 | height: number,
10 | radius: number,
11 | ): ImageData {
12 | const gridOuter = new Float64Array(width * height);
13 | const gridInner = new Float64Array(width * height);
14 |
15 | const INF = 1e20;
16 | for (let i = 0; i < width * height; i++) {
17 | const a = imageData.data[i * 4 + 3]! / 255; // Alpha value.
18 |
19 | if (a === 1) {
20 | gridOuter[i] = 0;
21 | gridInner[i] = INF;
22 | } else if (a === 0) {
23 | gridOuter[i] = INF;
24 | gridInner[i] = 0;
25 | } else {
26 | const d = 0.5 - a;
27 | gridOuter[i] = d > 0 ? d * d : 0;
28 | gridInner[i] = d < 0 ? d * d : 0;
29 | }
30 | }
31 |
32 | const size = Math.max(width, height);
33 | const f = new Float64Array(size);
34 | const z = new Float64Array(size + 1);
35 | const v = new Uint16Array(size * 2);
36 |
37 | edt(gridOuter, width, height, f, v, z);
38 | edt(gridInner, width, height, f, v, z);
39 |
40 | const alphaChannel = new Uint8ClampedArray(width * height);
41 | for (let i = 0; i < width * height; i++) {
42 | const d = Math.sqrt(gridOuter[i]!) - Math.sqrt(gridInner[i]!);
43 | alphaChannel[i] = Math.round(255 - 255 * (d / radius + 0.25));
44 | }
45 |
46 | const data = new Uint8ClampedArray(width * height * 4);
47 | for (let i = 0; i < width * height; i++) {
48 | data[4 * i + 0] = alphaChannel[i]!;
49 | data[4 * i + 1] = alphaChannel[i]!;
50 | data[4 * i + 2] = alphaChannel[i]!;
51 | data[4 * i + 3] = alphaChannel[i]!;
52 | }
53 |
54 | return new ImageData(data, width, height);
55 | }
56 |
57 | const INF = 1e20;
58 |
59 | // 1D squared distance transform.
60 | function edt1d(
61 | grid: Float64Array,
62 | offset: number,
63 | stride: number,
64 | length: number,
65 | f: Float64Array,
66 | v: Uint16Array,
67 | z: Float64Array,
68 | ): void {
69 | let q: number, k: number, s: number, r: number;
70 |
71 | v[0] = 0;
72 | z[0] = -INF;
73 | z[1] = INF;
74 |
75 | for (q = 0; q < length; q++) {
76 | f[q] = grid[offset + q * stride]!;
77 | }
78 |
79 | for (q = 1, k = 0, s = 0; q < length; q++) {
80 | do {
81 | r = v[k]!;
82 | s = (f[q]! - f[r]! + q * q - r * r) / (q - r) / 2;
83 | } while (s <= z[k]! && --k > -1);
84 |
85 | k++;
86 |
87 | v[k] = q;
88 | z[k] = s;
89 | z[k + 1] = INF;
90 | }
91 | for (q = 0, k = 0; q < length; q++) {
92 | while (z[k + 1]! < q) {
93 | k++;
94 | }
95 |
96 | r = v[k]!;
97 | grid[offset + q * stride] = f[r]! + (q - r) * (q - r);
98 | }
99 | }
100 |
101 | function edt(
102 | data: Float64Array,
103 | width: number,
104 | height: number,
105 | f: Float64Array,
106 | v: Uint16Array,
107 | z: Float64Array,
108 | ): void {
109 | for (let x = 0; x < width; x++) {
110 | edt1d(data, x, width, height, f, v, z);
111 | }
112 | for (let y = 0; y < height; y++) {
113 | edt1d(data, y * width, 1, width, f, v, z);
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/docs/app/search/route.tsx:
--------------------------------------------------------------------------------
1 | import * as cheerio from "cheerio";
2 |
3 | export type Section = {
4 | content: string;
5 | header: string;
6 | level: number;
7 | url: string;
8 | };
9 |
10 | export type Page = {
11 | sections: Array;
12 | title: string;
13 | url: string;
14 | };
15 |
16 | // TODO release: include blog?
17 | const searchablePages = [
18 | "/getting-started",
19 | "/examples",
20 | "/roadmap",
21 | "/styling",
22 | "/text-rendering",
23 | "/layout-engine",
24 | "/renderer",
25 | "/math-library",
26 | "/interactivity",
27 | "/",
28 | ];
29 |
30 | export async function GET(request: Request) {
31 | const start = performance.now();
32 | const baseUrl = new URL(request.url).origin;
33 | const pages = await Promise.all(
34 | searchablePages.map((url) =>
35 | fetch(`${baseUrl}${url}`)
36 | .then((response) => response.text())
37 | .catch((error) => {
38 | console.warn(error);
39 | return "";
40 | }),
41 | ),
42 | );
43 |
44 | const results: Array = pages.map((page, index) => {
45 | const document = cheerio.load(page);
46 | document("script, style").remove();
47 | const sections: Array = [];
48 | let currentSection: Section | null = null;
49 |
50 | function processElement(element: cheerio.Element) {
51 | const tagName = element.tagName;
52 | if (tagName && tagName.match(/^h[1-6]$/i)) {
53 | if (currentSection) {
54 | sections.push(currentSection);
55 | }
56 |
57 | currentSection = {
58 | content: "",
59 | header: document(element).text().trim(),
60 | level: Number.parseInt(tagName.slice(1)),
61 | url: `${baseUrl}${searchablePages[index]}#${document(element).attr("id")}`,
62 | };
63 | } else {
64 | if (currentSection && document(element).children().length === 0) {
65 | // Add text to the current section if it's not another header.
66 | currentSection.content +=
67 | " " +
68 | document(element)
69 | .html()
70 | ?.toString()
71 | .replaceAll(/<(?:"[^"]*"["']*|'[^']*'["']*|[^"'>])+>/g, " ")
72 | .replaceAll(/\s\s+/g, " ")
73 | .trim();
74 | }
75 | // Recursively process child elements.
76 | document(element)
77 | .children()
78 | .each(function () {
79 | processElement(this);
80 | });
81 | }
82 | }
83 |
84 | const main = document("main").get(0);
85 | if (main) {
86 | processElement(main);
87 | }
88 |
89 | if (currentSection) {
90 | sections.push(currentSection);
91 | }
92 |
93 | for (const section of sections) {
94 | // Replace multiple spaces with a single space.
95 | section.content = section.content.replaceAll(/\s\s+/g, " ");
96 | }
97 |
98 | const title = document("title").text();
99 | const url = `${baseUrl}${searchablePages[index]}`;
100 | return { sections, title, url };
101 | });
102 |
103 | console.log(`Search index generated in ${performance.now() - start}ms`);
104 | return Response.json(results);
105 | }
106 |
--------------------------------------------------------------------------------
/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Document
7 |
13 |
14 |
15 |
16 |
24 |
35 |
46 |
57 |
68 |
79 |
90 |
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/src/font/renderFontAtlas.ts:
--------------------------------------------------------------------------------
1 | import { invariant } from "../utils/invariant";
2 | import { toSDF } from "./toSDF";
3 | import type { Lookups } from "./types";
4 |
5 | const DEBUG_SKIP_SDF = false;
6 | const DEBUG_FONT_ATLAS_SHOW_GLYPH_BACKGROUNDS = false;
7 |
8 | /**
9 | * @param lookups see `prepareLookups()`.
10 | * @param options optional parameters like alphabet or whether SDF should be
11 | * used to render the atlas.
12 | * @returns an image bitmap of the font atlas.
13 | */
14 | export async function renderFontAtlas(
15 | lookups: Lookups,
16 | options?: {
17 | alphabet?: string;
18 | useSDF?: boolean;
19 | },
20 | ): Promise {
21 | const fontSize = lookups.atlas.fontSize;
22 | const atlasRadius = options?.useSDF ? fontSizeToGap(fontSize) : 0;
23 | const atlasGap = atlasRadius / 2;
24 |
25 | const canvas = document.createElement("canvas");
26 | canvas.width = lookups.atlas.width;
27 | canvas.height = lookups.atlas.height;
28 |
29 | const context = canvas.getContext("2d");
30 | invariant(context, "Could not get 2D context.");
31 |
32 | if (DEBUG_SKIP_SDF) {
33 | context.fillStyle = "rgba(0, 0, 0, 1)";
34 | context.fillRect(0, 0, lookups.atlas.width, lookups.atlas.height);
35 | }
36 |
37 | let start = 0;
38 | for (const font of lookups.fonts) {
39 | const scale = (1 / font.unitsPerEm) * fontSize;
40 |
41 | const fontName = `${font.name}-atlas`;
42 | const fontFace = new FontFace(fontName, font.buffer);
43 | await fontFace.load();
44 | document.fonts.add(fontFace);
45 | context.font = `100 ${fontSize}px ${fontName}`;
46 |
47 | const glyphs = [...font.glyphs.values()];
48 | for (let i = 0; i < glyphs.length; i++) {
49 | const glyph = glyphs[i];
50 | const position = lookups.atlas.positions[start + i];
51 | const size = lookups.atlas.sizes[start + i];
52 | invariant(glyph, "Could not find glyph.");
53 | invariant(position, "Could not find position for glyph.");
54 | invariant(size, "Could not find size for glyph.");
55 |
56 | if (DEBUG_FONT_ATLAS_SHOW_GLYPH_BACKGROUNDS) {
57 | context.fillStyle = "rgba(255, 0, 255, 0.3)";
58 | context.fillRect(position.x, position.y, size.x, size.y);
59 | }
60 |
61 | context.fillStyle = "rgba(255, 255, 255, 1)";
62 | context.fillText(
63 | String.fromCharCode(glyph.id),
64 | // Additionally offset by glyph (X, Y).
65 | position.x - glyph.x * scale + atlasGap,
66 | position.y + size.y + glyph.y * scale - atlasGap,
67 | );
68 | }
69 | start += glyphs.length;
70 | }
71 |
72 | if (!DEBUG_SKIP_SDF && options?.useSDF) {
73 | const imageData = context.getImageData(0, 0, lookups.atlas.width, lookups.atlas.height);
74 | const sdfData = toSDF(imageData, lookups.atlas.width, lookups.atlas.height, atlasRadius);
75 | context.putImageData(sdfData, 0, 0);
76 | }
77 |
78 | if (DEBUG_SKIP_SDF) {
79 | document.body.append(canvas);
80 | }
81 |
82 | const bitmap = await createImageBitmap(canvas);
83 | canvas.remove();
84 |
85 | return bitmap;
86 | }
87 |
88 | /**
89 | * Helper function for placing glyphs in the font atlas.
90 | *
91 | * @param fontSize
92 | * @returns gap size based on the `fontSize`.
93 | */
94 | export function fontSizeToGap(fontSize: number): number {
95 | return Math.round(fontSize / 6);
96 | }
97 |
--------------------------------------------------------------------------------
/src/layout/eventTypes.ts:
--------------------------------------------------------------------------------
1 | import type { EventManager } from "../EventManager";
2 | import type { Vec2 } from "../math/Vec2";
3 |
4 | export const enum UserEventType {
5 | MouseClick,
6 | MouseDown,
7 | MouseUp,
8 | MouseMove,
9 | MouseEnter,
10 | MouseLeave,
11 |
12 | Scroll,
13 |
14 | KeyDown,
15 | KeyUp,
16 | KeyPress,
17 |
18 | Focus,
19 | Blur,
20 |
21 | InputChange,
22 |
23 | Layout,
24 | }
25 |
26 | export type MouseClickHandler = (event: MouseEvent, eventManager: EventManager) => void;
27 | export type MouseMoveHandler = (event: MouseEvent, eventManager: EventManager) => void;
28 | export type MouseEnterHandler = (event: MouseEvent, eventManager: EventManager) => void;
29 | export type MouseLeaveHandler = (event: MouseEvent, eventManager: EventManager) => void;
30 | export type MouseDownHandler = (event: MouseEvent, eventManager: EventManager) => void;
31 | export type MouseUpHandler = (event: MouseEvent, eventManager: EventManager) => void;
32 | export type ScrollHandler = (event: ScrollEvent, eventManager: EventManager) => void;
33 | export type KeyDownHandler = (event: KeyboardEvent, eventManager: EventManager) => void;
34 | export type KeyUpHandler = (event: KeyboardEvent, eventManager: EventManager) => void;
35 | export type KeyPressHandler = (event: KeyboardEvent, eventManager: EventManager) => void;
36 | export type FocusHandler = (event: FocusEvent, eventManager: EventManager) => void;
37 | export type BlurHandler = (event: FocusEvent, eventManager: EventManager) => void;
38 | export type LayoutHandler = (eventManager: EventManager) => void;
39 |
40 | export type MouseEvent = {
41 | bubbles: boolean;
42 | capturable: boolean;
43 | position: Vec2;
44 | type:
45 | | UserEventType.MouseClick
46 | | UserEventType.MouseDown
47 | | UserEventType.MouseUp
48 | | UserEventType.MouseMove
49 | | UserEventType.MouseEnter
50 | | UserEventType.MouseLeave;
51 | };
52 |
53 | export type ScrollEvent = {
54 | bubbles: boolean;
55 | capturable: boolean;
56 | delta: Vec2;
57 | position: Vec2;
58 | type: UserEventType.Scroll;
59 | };
60 |
61 | export type KeyboardEvent = {
62 | /**
63 | * When even is handled by a listener, should it also be dispatched to any parent listeners (that
64 | * match the type).
65 | */
66 | bubbles: boolean;
67 | /**
68 | * Is the event going to be 'consumed' by the first listener.
69 | */
70 | capturable: boolean;
71 | /**
72 | * The key code of the key that was pressed.
73 | */
74 | character: string;
75 | code: number;
76 | modifiers: {
77 | alt: boolean;
78 | control: boolean;
79 | meta: boolean;
80 | shift: boolean;
81 | };
82 | type: UserEventType.KeyDown | UserEventType.KeyUp | UserEventType.KeyPress;
83 | };
84 |
85 | export type FocusEvent = {
86 | bubbles: boolean;
87 | capturable: boolean;
88 | type: UserEventType.Focus | UserEventType.Blur;
89 | };
90 |
91 | export type LayoutEvent = {
92 | bubbles: boolean;
93 | capturable: boolean;
94 | type: UserEventType.Layout;
95 | };
96 |
97 | export type UserEvent = MouseEvent | ScrollEvent | KeyboardEvent | FocusEvent | LayoutEvent;
98 |
99 | export function isMouseEvent(event: UserEvent): event is MouseEvent {
100 | return "position" in event;
101 | }
102 |
103 | export function isKeyboardEvent(event: UserEvent): event is KeyboardEvent {
104 | return "character" in event;
105 | }
106 |
--------------------------------------------------------------------------------
/docs/app/examples/editors/Basic.tsx:
--------------------------------------------------------------------------------
1 | import { BaseEditor } from "../../components/BaseEditor";
2 |
3 | export function ExampleBasic() {
4 | return (
5 | <>
6 |
11 | >
12 | );
13 | }
14 |
15 | const starterCode = `import {
16 | invariant,
17 | renderFontAtlas,
18 | Vec2,
19 | Vec4,
20 | parseTTF,
21 | prepareLookups,
22 | WebGPURenderer,
23 | } from "./dist/index";
24 |
25 | document.body.setAttribute("style", "margin: 0; min-height: 100dvh;");
26 |
27 | const canvas = document.createElement("canvas");
28 | document.body.appendChild(canvas);
29 |
30 | const parent = canvas.parentElement;
31 | invariant(parent, "No parent element found.");
32 | const WIDTH = parent.clientWidth;
33 | const HEIGHT = parent.clientHeight;
34 |
35 | const settings = {
36 | sampleCount: 4,
37 | windowHeight: HEIGHT,
38 | windowWidth: WIDTH,
39 | rectangleBufferSize: 16 * 4096,
40 | textBufferSize: 16 * 100_000,
41 | };
42 |
43 | canvas.width = WIDTH * window.devicePixelRatio;
44 | canvas.height = HEIGHT * window.devicePixelRatio;
45 | canvas.setAttribute("style", "width: 100%; height: 100%;");
46 |
47 | async function run() {
48 | const interTTF = await fetch("https://tchayen.com/assets/Inter.ttf").then(
49 | (response) => response.arrayBuffer()
50 | );
51 |
52 | const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
53 |
54 | const entry = navigator.gpu;
55 | invariant(entry, "WebGPU is not supported in this browser.");
56 |
57 | const context = canvas.getContext("webgpu");
58 | invariant(context, "WebGPU is not supported in this browser.");
59 |
60 | const adapter = await entry.requestAdapter();
61 | invariant(adapter, "No GPU found on this system.");
62 |
63 | const device = await adapter.requestDevice();
64 |
65 | context.configure({
66 | alphaMode: "opaque",
67 | device: device,
68 | format: navigator.gpu.getPreferredCanvasFormat(),
69 | });
70 |
71 | const lookups = prepareLookups(
72 | [{ buffer: interTTF, name: "Inter", ttf: parseTTF(interTTF) }],
73 | {
74 | alphabet,
75 | fontSize: 150,
76 | }
77 | );
78 |
79 | const fontAtlas = await renderFontAtlas(lookups, { useSDF: true });
80 |
81 | const colorTexture = device.createTexture({
82 | format: "bgra8unorm",
83 | label: "color",
84 | sampleCount: settings.sampleCount,
85 | size: { height: canvas.height, width: canvas.width },
86 | usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
87 | });
88 | const colorTextureView = colorTexture.createView({ label: "color" });
89 |
90 | const renderer = new WebGPURenderer(
91 | device,
92 | context,
93 | colorTextureView,
94 | settings,
95 | lookups,
96 | fontAtlas
97 | );
98 |
99 | renderer.rectangle(
100 | new Vec4(1, 0, 1, 1),
101 | new Vec2(50, 50),
102 | new Vec2(100, 100),
103 | new Vec4(10, 10, 10, 10),
104 | new Vec4(0, 0, 0, 0),
105 | new Vec4(0, 0, 0, 0),
106 | new Vec2(0, 0),
107 | new Vec2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY),
108 | new Vec4(0, 0, 0, 0)
109 | );
110 |
111 | const commandEncoder = device.createCommandEncoder();
112 | renderer.render(commandEncoder);
113 | device.queue.submit([commandEncoder.finish()]);
114 | }
115 |
116 | run();
117 | `;
118 |
--------------------------------------------------------------------------------
/docs/app/layout-engine/page.mdx:
--------------------------------------------------------------------------------
1 | import { Api } from "./Api";
2 | import { getMetadata } from "../getMetadata";
3 | export const metadata = getMetadata("Layout Engine");
4 |
5 | # Layout engine
6 |
7 | The layout algorithm is perhaps the most valuable and unique part of the library and took the most time to develop.
8 |
9 | ## Overview
10 |
11 | Layout engine executes a 4 pass algorithm:
12 |
13 | - _Pass 1:_ Traverse tree in level order and generate the reverse queue.
14 | - _Pass 2:_ Going bottom-up, level order, resolve sizes of elements that base their size on their children.
15 | - _Pass 3:_ Going top-down, level order, resolve flex sizes by splitting available space between children and assign positions of all elements based on specified flex modifiers.
16 | - _Pass 4:_ Going top-down, level order, calculate scroll sizes – the actual space used by children versus available component size.
17 |
18 | ## What is actually happening?
19 |
20 | ### Pass 1
21 |
22 | Internal state of each node is reset (see [LayoutNodeState](/styling#layoutnodestate)).
23 |
24 | If element has defined width or height, it is applied. If it is a text node and parent has defined width, the maximum available width of the text is now known.
25 |
26 | ### Pass 2
27 |
28 | If element has undefined size on the main axis (width for row, height for column), it is calculated as a sum of element's paddings, widths and margins of all children. For cross axis it is the maximum value of children sizes (and element's paddings).
29 |
30 | The children are divided into rows. If `flexWrap` is not defined it will be a single row. If it is defined, sizes of all children are calculated and rows are split based on them and gap values.
31 |
32 | ### Pass 3
33 |
34 | If element has both left and right offsets (or top and bottom) it is used to set its size.
35 |
36 | For each row of children, each child is positioned based on `alignContent`, `alignItems`, `justifyContent` and `alignSelf` properties. Also flex sizes are determined based on `flexGrow`, `flexShrink` and `flexBasis` properties. Min, max sizes and aspect ratio is applied.
37 |
38 | Size and position of each element is rounded using `Math.round()` to a full pixel.
39 |
40 | ### pass 4
41 |
42 | Scroll size of each element is calculated, which is the maximum area needed to display all children. Used for scrolling.
43 |
44 | ## Quick story
45 |
46 | My initial idea was to base it off the Auto Layout system from Figma, but it soon turned out that the CSS flexbox API is more familiar to write. Since there was a similar already successfully developed project by Facebook, [Yoga](https://yogalayout.dev/), I decided to follow the same subset of implemented flexbox features.
47 |
48 | I made first implementation in Zig in June 2022. In July I came with with the a 3-pass tree traversal that allows to resolve all flexbox properties without introducing any recursion. I released Red Otter in early 2023. At the time it was missing flex wrap and scrollable containers.
49 |
50 | I spent a lot of time thinking how approach interactivity. In October 2023 I settled for retained mode UI rendering and what soon followed was a major rewrite of the layout algorithm that enabled flex wrap and shortened the code. In November I finished the implementation and implemented scrolling, which required rethinking other execution layers of the library.
51 |
52 | ## API
53 |
54 |
55 |
56 | ## Want to learn more?
57 |
58 | I wrote a blogpost about implementing similar algorithm – [How to Write a Flexbox Layout Engine](https://tchayen.com/how-to-write-a-flexbox-layout-engine).
59 |
--------------------------------------------------------------------------------
/src/font/prepareLookups.ts:
--------------------------------------------------------------------------------
1 | import { Vec2 } from "../math/Vec2";
2 | import { Vec4 } from "../math/Vec4";
3 | import { packShelves } from "../math/packShelves";
4 | import { invariant } from "../utils/invariant";
5 | import { calculateGlyphQuads } from "./calculateGlyphQuads";
6 | import { generateKerningFunction } from "./generateKerningFunction";
7 | import type { TTF } from "./parseTTF";
8 | import { fontSizeToGap } from "./renderFontAtlas";
9 | import type { Glyph, Lookups } from "./types";
10 |
11 | /**
12 | * This is generally extension of the font parsing process.
13 | *
14 | * @param fontFiles an array of font files to parse.
15 | * @param options optional parameters.
16 | * @returns a set of lookups for the font atlas.
17 | */
18 | export function prepareLookups(
19 | fontFiles: Array<{
20 | buffer: ArrayBuffer;
21 | name: string;
22 | ttf: TTF;
23 | }>,
24 | options?: {
25 | alphabet?: string;
26 | fontSize?: number;
27 | },
28 | ): Lookups {
29 | const atlasFontSize = options?.fontSize ?? 96;
30 | const atlasGap = fontSizeToGap(atlasFontSize);
31 |
32 | const lookups: Lookups = {
33 | atlas: {
34 | fontSize: atlasFontSize,
35 | height: 0,
36 | positions: [],
37 | sizes: [],
38 | width: 0,
39 | },
40 | fonts: [],
41 | uvs: new Map(),
42 | };
43 |
44 | const sizes: Array = [];
45 | for (const { name, ttf, buffer } of fontFiles) {
46 | const scale = (1 / ttf.head.unitsPerEm) * atlasFontSize;
47 | const glyphs = calculateGlyphQuads(ttf, options?.alphabet);
48 |
49 | sizes.push(
50 | ...glyphs.map(
51 | (g) => new Vec2(g.width * scale + atlasGap * 2, g.height * scale + atlasGap * 2),
52 | ),
53 | );
54 |
55 | const glyphMap = new Map();
56 | for (const glyph of glyphs) {
57 | glyphMap.set(glyph.id, glyph);
58 | }
59 |
60 | lookups.fonts.push({
61 | ascender: ttf.hhea.ascender,
62 | buffer,
63 | capHeight: ttf.hhea.ascender + ttf.hhea.descender,
64 | glyphs: glyphMap,
65 | kern: generateKerningFunction(ttf),
66 | name,
67 | ttf,
68 | unitsPerEm: ttf.head.unitsPerEm,
69 | });
70 | }
71 |
72 | const packing = packShelves(sizes);
73 |
74 | lookups.atlas = {
75 | fontSize: atlasFontSize,
76 | height: packing.height,
77 | positions: packing.positions,
78 | sizes,
79 | width: packing.width,
80 | };
81 |
82 | let start = 0;
83 | for (const font of lookups.fonts) {
84 | const uvs: Array = [];
85 | const glyphs = [...font.glyphs.values()];
86 |
87 | for (let i = 0; i < glyphs.length; i++) {
88 | const position = lookups.atlas.positions[start + i];
89 | const size = lookups.atlas.sizes[start + i];
90 | invariant(position, "Could not find position for glyph.");
91 | invariant(size, "Could not find size for glyph.");
92 |
93 | uvs.push(
94 | new Vec4(
95 | position.x / lookups.atlas.width,
96 | position.y / lookups.atlas.height,
97 | size.x / lookups.atlas.width,
98 | size.y / lookups.atlas.height,
99 | ),
100 | );
101 | }
102 | start += glyphs.length;
103 |
104 | for (let i = 0; i < glyphs.length; i++) {
105 | const glyph = glyphs[i];
106 | const uv = uvs[i];
107 | invariant(glyph, "Could not find glyph.");
108 | invariant(uv, "Could not find uv.");
109 | lookups.uvs.set(`${font.name}-${glyph.id}`, uv);
110 | }
111 | }
112 |
113 | return lookups;
114 | }
115 |
--------------------------------------------------------------------------------
/src/font/BinaryReader.ts:
--------------------------------------------------------------------------------
1 | export type Uint8 = number;
2 | export type Uint16 = number;
3 | export type Uint32 = number;
4 | export type Int16 = number;
5 | export type Int32 = number;
6 | export type FWord = Int16;
7 | export type Fixed = number;
8 |
9 | /**
10 | * A module for reading binary data. Used internally by `parseTTF()`. Keeps track of the current
11 | * position, assuming sequential reads.
12 | */
13 | export class BinaryReader {
14 | private readonly view: DataView;
15 | private position = 0;
16 |
17 | constructor(
18 | data: ArrayBuffer,
19 | private readonly options?: { littleEndian?: boolean },
20 | ) {
21 | this.view = new DataView(data);
22 | }
23 |
24 | /**
25 | * Read two bytes as an unsigned integer and advance the position by two bytes.
26 | */
27 | getUint16(): Uint16 {
28 | const value = this.view.getUint16(this.position, this.options?.littleEndian);
29 | this.position += 2;
30 | return value;
31 | }
32 |
33 | /**
34 | * Read two bytes as a signed integer and advance the position by two bytes.
35 | */
36 | getInt16(): Int16 {
37 | const value = this.view.getInt16(this.position, this.options?.littleEndian);
38 | this.position += 2;
39 | return value;
40 | }
41 |
42 | /**
43 | * Read four bytes as an unsigned integer and advance the position by four bytes.
44 | */
45 | getUint32(): Uint32 {
46 | const value = this.view.getUint32(this.position, this.options?.littleEndian);
47 | this.position += 4;
48 | return value;
49 | }
50 |
51 | /**
52 | * Read four bytes as a signed integer and advance the position by four bytes.
53 | */
54 | getInt32(): Int32 {
55 | const value = this.view.getInt32(this.position, this.options?.littleEndian);
56 | this.position += 4;
57 | return value;
58 | }
59 |
60 | /**
61 | * Read four bytes as a fixed-point number (2 bytes integer and 2 byte fraction) and advance the
62 | * position by four bytes.
63 | */
64 | getFixed(): Fixed {
65 | const integer = this.getUint16();
66 | const fraction = this.getUint16();
67 | return integer + fraction / 0x1_00_00;
68 | }
69 |
70 | /**
71 | * Read eight bytes as a date (seconds since 1904-01-01 00:00:00) without advancing the position.
72 | */
73 | getDate(): Date {
74 | const macTime = this.getUint32() * 0x1_00_00_00_00 + this.getUint32();
75 | const utcTime = macTime * 1000 + Date.UTC(1904, 1, 1);
76 | return new Date(utcTime);
77 | }
78 |
79 | /**
80 | * Alias for `getUint16`.
81 | */
82 | getFWord(): FWord {
83 | return this.getInt16();
84 | }
85 |
86 | /**
87 | * Read a string of the given length and advance the position by that length.
88 | */
89 | getString(length: number): string {
90 | const bytes = new Uint8Array(this.view.buffer, this.position, length);
91 | const string = new TextDecoder().decode(bytes);
92 | this.position += length;
93 |
94 | return string;
95 | }
96 |
97 | /**
98 | * Look up array slice of the given length at the current position without advancing it.
99 | */
100 | getDataSlice(offset: number, length: number): Uint8Array {
101 | return new Uint8Array(this.view.buffer, offset, length);
102 | }
103 |
104 | /**
105 | * Get the current position.
106 | */
107 | getPosition(): number {
108 | return this.position;
109 | }
110 |
111 | /**
112 | * Set the current position.
113 | */
114 | setPosition(position: number): void {
115 | this.position = position;
116 | }
117 |
118 | /**
119 | * Run the given action at the given position, restoring the original position afterwards.
120 | */
121 | runAt(position: number, action: () => T): T {
122 | const current = this.position;
123 | this.setPosition(position);
124 | const result = action();
125 | this.setPosition(current);
126 | return result;
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/docs/app/components/CodeBlock.tsx:
--------------------------------------------------------------------------------
1 | import hljs from "highlight.js";
2 | import type { HTMLAttributes, PropsWithChildren } from "react";
3 | import { twMerge } from "tailwind-merge";
4 | import { jetBrainsMono } from "./tags";
5 | import * as prettier from "prettier";
6 | import { CopyCode } from "./CopyCode";
7 |
8 | export const CHARACTER_LIMIT = 76;
9 |
10 | export async function CodeBlock({
11 | className,
12 | children,
13 | }: PropsWithChildren<{ className?: string }>) {
14 | if (!children) {
15 | return null;
16 | }
17 |
18 | let language = null;
19 |
20 | if (typeof children !== "object" || !("props" in children)) {
21 | return (
22 |
23 | {children}
24 |
25 | );
26 | }
27 |
28 | let code = children.props.children;
29 |
30 | try {
31 | language = children.props.className.replace(/language-/, "");
32 | } catch {
33 | /* empty */
34 | }
35 |
36 | // Wrap comments longer than line length.
37 | if (code) {
38 | code = wrapComments(code);
39 | }
40 |
41 | if (language === "zig") {
42 | language = "c";
43 | }
44 |
45 | if (language === "wgsl") {
46 | language = "rust";
47 | }
48 |
49 | if (!language) {
50 | return (
51 |
52 | {code}
53 |
54 | );
55 | }
56 |
57 | if (language === "ts" || language === "tsx") {
58 | try {
59 | code = await prettier.format(code, { parser: "babel-ts", printWidth: 80 });
60 | } catch {
61 | /* empty */
62 | }
63 | }
64 |
65 | try {
66 | code = highlight(code, language);
67 | } catch {
68 | /* empty */
69 | }
70 |
71 | return (
72 |
73 |
74 |
75 | {prettyPrint(language)}
76 |
77 |
78 |
79 | );
80 | }
81 |
82 | function prettyPrint(language: string) {
83 | switch (language) {
84 | case "ts":
85 | return "TypeScript";
86 | case "js":
87 | return "JavaScript";
88 | case "tsx":
89 | return "TypeScript";
90 | }
91 | }
92 |
93 | function wrapComments(code: string) {
94 | return code
95 | .split("\n")
96 | .map((line: string) => {
97 | if (line.match(/^\s*\/\//) && line.length > CHARACTER_LIMIT) {
98 | const words = line.split(" ");
99 | let text = "";
100 | const indentation = line.match(/^\s*/)?.[0] ?? "";
101 |
102 | for (const word of words) {
103 | const lastLineLength = (text.split("\n").pop() ?? []).length;
104 | if (lastLineLength + word.length > CHARACTER_LIMIT) {
105 | text += `\n${indentation}// `;
106 | }
107 | text += word + " ";
108 | }
109 | return text;
110 | }
111 | return line;
112 | })
113 | .join("\n");
114 | }
115 |
116 | function highlight(code: string, language: string) {
117 | return hljs.highlight(code, { language }).value;
118 | }
119 |
120 | function Pre({ children, ...rest }: HTMLAttributes) {
121 | return (
122 |
130 | {children}
131 |
132 | );
133 | }
134 |
135 | function DivWrapper({ className, children }: PropsWithChildren<{ className?: string }>) {
136 | return (
137 |
143 | {children}
144 |
145 | );
146 | }
147 |
--------------------------------------------------------------------------------
/examples/main.ts:
--------------------------------------------------------------------------------
1 | import { EventManager } from "../src/EventManager";
2 | import { WebGPURenderer } from "../src/renderer/WebGPURenderer";
3 | import { isWindowDefined, settings } from "../src/consts";
4 | import { paint } from "../src/layout/paint";
5 | import { parseTTF } from "../src/font/parseTTF";
6 | import { prepareLookups } from "../src/font/prepareLookups";
7 | import { renderFontAtlas } from "../src/font/renderFontAtlas";
8 | import { ui } from "./ui";
9 | import { invariant } from "../src/utils/invariant";
10 | import { compose } from "../src/layout/compose";
11 | import { UserEventType } from "../src/layout/eventTypes";
12 |
13 | const eventManager = new EventManager();
14 |
15 | async function initialize() {
16 | const alphabet =
17 | "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890 ,.:•-–()[]{}!?@#$%^&*+=/\\|<>`~’'\";_▶";
18 | const [interTTF, interBoldTTF, comicNeueTTF, jetBrainsMonoTTF] = await Promise.all(
19 | ["/Inter.ttf", "/Inter-SemiBold.ttf", "/ComicNeue-Bold.ttf", "JetBrainsMono-Regular.ttf"].map(
20 | (url) => fetch(url).then((response) => response.arrayBuffer()),
21 | ),
22 | );
23 | invariant(interTTF, "Inter.ttf not found.");
24 | invariant(interBoldTTF, "Inter-SemiBold.ttf not found.");
25 | invariant(comicNeueTTF, "ComicNeue-Bold.ttf not found.");
26 | invariant(jetBrainsMonoTTF, "JetBrainsMono-Regular.ttf not found.");
27 |
28 | document.body.setAttribute("style", "margin: 0");
29 |
30 | const canvas = document.createElement("canvas");
31 | canvas.width = settings.windowWidth * window.devicePixelRatio;
32 | canvas.height = settings.windowHeight * window.devicePixelRatio;
33 | canvas.setAttribute(
34 | "style",
35 | `width: ${settings.windowWidth}px; height: ${settings.windowHeight}px; display: flex; position: fixed`,
36 | );
37 | document.body.append(canvas);
38 | const entry = navigator.gpu;
39 | invariant(entry, "WebGPU is not supported in this browser.");
40 |
41 | const context = canvas.getContext("webgpu");
42 | invariant(context, "WebGPU is not supported in this browser.");
43 |
44 | const adapter = await entry.requestAdapter();
45 | invariant(adapter, "No GPU found on this system.");
46 |
47 | const device = await adapter.requestDevice();
48 |
49 | context.configure({
50 | alphaMode: "opaque",
51 | device: device,
52 | format: navigator.gpu.getPreferredCanvasFormat(),
53 | });
54 |
55 | const lookups = prepareLookups(
56 | [
57 | { buffer: interTTF, name: "Inter", ttf: parseTTF(interTTF) },
58 | { buffer: interBoldTTF, name: "InterBold", ttf: parseTTF(interBoldTTF) },
59 | { buffer: comicNeueTTF, name: "ComicNeue", ttf: parseTTF(comicNeueTTF) },
60 | { buffer: jetBrainsMonoTTF, name: "JetBrainsMono", ttf: parseTTF(jetBrainsMonoTTF) },
61 | ],
62 | { alphabet, fontSize: 150 },
63 | );
64 |
65 | const fontAtlas = await renderFontAtlas(lookups, { useSDF: true });
66 |
67 | const colorTexture = device.createTexture({
68 | format: "bgra8unorm",
69 | label: "color",
70 | sampleCount: settings.sampleCount,
71 | size: { height: canvas.height, width: canvas.width },
72 | usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
73 | });
74 | const colorTextureView = colorTexture.createView({ label: "color" });
75 |
76 | const renderer = new WebGPURenderer(
77 | device,
78 | context,
79 | colorTextureView,
80 | settings,
81 | lookups,
82 | fontAtlas,
83 | );
84 |
85 | const root = ui(renderer);
86 |
87 | // Notify nodes that layout is ready.
88 | eventManager.dispatchEvent({ bubbles: false, capturable: false, type: UserEventType.Layout });
89 | eventManager.deliverEvents(root);
90 |
91 | function render(): void {
92 | invariant(context, "WebGPU is not supported in this browser.");
93 |
94 | const commandEncoder = device.createCommandEncoder();
95 |
96 | eventManager.deliverEvents(root);
97 | compose(renderer, root);
98 | paint(renderer, root);
99 |
100 | renderer.render(commandEncoder);
101 | device.queue.submit([commandEncoder.finish()]);
102 |
103 | requestAnimationFrame(render);
104 | }
105 |
106 | render();
107 | }
108 |
109 | if (isWindowDefined) {
110 | await initialize();
111 | }
112 |
--------------------------------------------------------------------------------
/src/math/triangulatePolygon.ts:
--------------------------------------------------------------------------------
1 | import { invariant } from "../utils/invariant";
2 | import type { Vec2 } from "./Vec2";
3 |
4 | type RingNode = {
5 | next: RingNode;
6 | position: Vec2;
7 | prev: RingNode;
8 | };
9 |
10 | function earCut(ear: RingNode): Array {
11 | const triangles = [];
12 |
13 | let next = ear.next;
14 | let prev = ear.prev;
15 | let stop = ear;
16 |
17 | while (prev !== next) {
18 | prev = ear.prev;
19 | next = ear.next;
20 | if (isEar(ear)) {
21 | triangles.push(ear.position, prev.position, next.position);
22 | removeNode(ear);
23 | // Skipping next vertex is a handy trick to achieve less so called 'sliver triangles'.
24 | ear = next.next;
25 | stop = next.next;
26 | continue;
27 | }
28 |
29 | ear = next;
30 |
31 | invariant(ear !== stop, "Triangulation failed.");
32 | }
33 | return triangles;
34 | }
35 |
36 | /**
37 | * Triangulates a polygon. Assumes that polygon is clockwise.
38 | */
39 | export function triangulatePolygon(polygon: Array): Array {
40 | invariant(polygon.length >= 3, "Polygon must have at least 3 points.");
41 | const node = createRing(polygon);
42 | invariant(node, "Failed to triangulate polygon.");
43 | return earCut(node);
44 | }
45 |
46 | function insertNode(position: Vec2, last?: RingNode): RingNode {
47 | const p = { position } as RingNode;
48 |
49 | if (!last) {
50 | p.prev = p;
51 | p.next = p;
52 | } else {
53 | p.next = last.next;
54 | p.prev = last;
55 | last.next.prev = p;
56 | last.next = p;
57 | }
58 | return p;
59 | }
60 |
61 | function removeNode(p: RingNode): void {
62 | p.next.prev = p.prev;
63 | p.prev.next = p.next;
64 | }
65 |
66 | function createRing(data: Array): RingNode | null {
67 | let last;
68 | for (const v of data) {
69 | last = insertNode(v, last);
70 | }
71 | return filterPoints(last);
72 | }
73 |
74 | function filterPoints(start?: RingNode, end?: RingNode): RingNode | null {
75 | if (!start) {
76 | return null;
77 | }
78 | if (!end) {
79 | end = start;
80 | }
81 |
82 | let p = start;
83 | let again;
84 | do {
85 | again = false;
86 |
87 | if (
88 | p.position.equals(p.next.position) ||
89 | area(p.prev.position, p.position, p.next.position) === 0
90 | ) {
91 | removeNode(p);
92 | p = end = p.prev;
93 |
94 | if (p === p.next) {
95 | break;
96 | }
97 |
98 | again = true;
99 | } else {
100 | p = p.next;
101 | }
102 | } while (again || p !== end);
103 | return end;
104 | }
105 |
106 | function area(a: Vec2, b: Vec2, c: Vec2): number {
107 | return (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
108 | }
109 |
110 | function isEar(ear: RingNode): boolean {
111 | const a = ear.prev;
112 | const b = ear;
113 | const c = ear.next;
114 |
115 | if (area(a.position, b.position, c.position) >= 0) {
116 | return false;
117 | }
118 |
119 | let p = ear.next.next;
120 | while (p !== ear.prev) {
121 | const inTriangle = isPointInPolygon(p.position, [a.position, b.position, c.position]);
122 |
123 | if (inTriangle && area(p.prev.position, p.position, p.next.position) >= 0) {
124 | return false;
125 | }
126 | p = p.next;
127 | }
128 | return true;
129 | }
130 |
131 | function isPointInPolygon(point: Vec2, points: Array): boolean {
132 | let i = 0;
133 | let j = points.length - 1;
134 | let oddNodes = false;
135 |
136 | while (i < points.length) {
137 | const leftPoint = points[i];
138 | const rightPoint = points[j];
139 | invariant(leftPoint, "Left point is missing.");
140 | invariant(rightPoint, "Right point is missing.");
141 | // Check if the point is between the y coordinates of the two points of the edge.
142 | if (
143 | (leftPoint.y < point.y && rightPoint.y >= point.y) ||
144 | (rightPoint.y < point.y && leftPoint.y >= point.y)
145 | ) {
146 | // Calculate the x coordinate of the point based on the slope of the edge and the y
147 | // coordinate of the point.
148 | if (
149 | leftPoint.x +
150 | ((point.y - leftPoint.y) / (rightPoint.y - leftPoint.y)) * (rightPoint.x - leftPoint.x) <
151 | point.x
152 | ) {
153 | oddNodes = !oddNodes;
154 | }
155 | }
156 |
157 | j = i;
158 | i += 1;
159 | }
160 |
161 | return oddNodes;
162 | }
163 |
--------------------------------------------------------------------------------
/src/font/generateKerningFunction.ts:
--------------------------------------------------------------------------------
1 | import type { TTF, ValueRecord } from "./parseTTF";
2 | import type { KerningFunction } from "./types";
3 | import { generateGlyphToClassMap } from "./generateGlyphToClassMap";
4 | import { invariant } from "../utils/invariant";
5 |
6 | /**
7 | * Generates a kerning function used by `shapeText()`.
8 | *
9 | * @param ttf parsed TTF file (see `parseTTF()`).
10 | * @returns a function that takes two glyph IDs and returns the kerning value.
11 | */
12 | export function generateKerningFunction(ttf: TTF): KerningFunction {
13 | const kerningPairs = new Map>();
14 | let firstGlyphClassMapping = new Map();
15 | let secondGlyphClassMapping = new Map();
16 |
17 | let classRecords: Array<
18 | Array<{
19 | value1?: ValueRecord | undefined;
20 | value2?: ValueRecord | undefined;
21 | }>
22 | > = [];
23 |
24 | const kern = ttf.GPOS?.features.find((f) => f.tag === "kern");
25 | if (kern) {
26 | const lookups = kern.lookupListIndices.map((id) => ttf.GPOS?.lookups[id]);
27 |
28 | for (const lookup of lookups) {
29 | if (lookup && (lookup.lookupType === 2 || lookup.lookupType === 9)) {
30 | // Ensure it's Pair Adjustment.
31 | for (const subtable of lookup.subtables) {
32 | if (lookup.lookupType === 9 && subtable.extensionLookupType === 2) {
33 | const coverage = subtable.extension.coverage;
34 |
35 | if (subtable.extension.posFormat === 1) {
36 | // Adjustment for glyph pairs.
37 | const pairSets = subtable.extension.pairSets;
38 |
39 | if (coverage.coverageFormat === 2) {
40 | let indexCounter = 0;
41 | for (const range of coverage.rangeRecords) {
42 | for (let glyphID = range.startGlyphID; glyphID <= range.endGlyphID; glyphID++) {
43 | const pairs = pairSets[indexCounter];
44 | invariant(pairs, "Could not find pair set.");
45 |
46 | const glyphKernMap = kerningPairs.get(glyphID) || new Map();
47 | for (const pair of pairs) {
48 | if (pair.value1?.xAdvance) {
49 | glyphKernMap.set(pair.secondGlyph, pair.value1.xAdvance);
50 | }
51 | }
52 | if (glyphKernMap.size > 0) {
53 | kerningPairs.set(glyphID, glyphKernMap);
54 | }
55 |
56 | indexCounter++;
57 | }
58 | }
59 | } else {
60 | console.warn(`Coverage format ${coverage.coverageFormat} is not supported.`);
61 | }
62 | } else if (subtable.extension.posFormat === 2) {
63 | // Adjustment for glyph classes.
64 | if (coverage.coverageFormat === 2) {
65 | const { classDef1, classDef2 } = subtable.extension;
66 | firstGlyphClassMapping = generateGlyphToClassMap(classDef1);
67 | secondGlyphClassMapping = generateGlyphToClassMap(classDef2);
68 | classRecords = subtable.extension.classRecords;
69 | } else {
70 | console.warn(`Coverage format ${coverage.coverageFormat} is not supported.`);
71 | }
72 | }
73 | }
74 | }
75 | }
76 | }
77 | }
78 |
79 | return (leftGlyph: number, rightGlyph: number): number => {
80 | if (!ttf.GPOS) {
81 | return 0;
82 | }
83 |
84 | const firstGlyphID = ttf.cmap.glyphIndexMap[leftGlyph];
85 | const secondGlyphID = ttf.cmap.glyphIndexMap[rightGlyph];
86 | if (!firstGlyphID || !secondGlyphID) {
87 | return 0;
88 | }
89 |
90 | const firstMap = kerningPairs.get(firstGlyphID);
91 | if (firstMap) {
92 | if (firstMap.get(secondGlyphID)) {
93 | return firstMap.get(secondGlyphID) ?? 0;
94 | }
95 | }
96 |
97 | if (classRecords.length === 0) {
98 | return 0;
99 | }
100 |
101 | // It's specified in the spec that if class is not defined for a glyph, it should be set to 0.
102 | const firstClass = firstGlyphClassMapping.get(firstGlyphID) ?? 0;
103 | const secondClass = secondGlyphClassMapping.get(secondGlyphID) ?? 0;
104 |
105 | const record = classRecords[firstClass]![secondClass];
106 | return record?.value1?.xAdvance ?? 0;
107 | };
108 | }
109 |
--------------------------------------------------------------------------------
/docs/app/examples/editors/Scrollbars.tsx:
--------------------------------------------------------------------------------
1 | import { BaseEditor } from "../../components/BaseEditor";
2 |
3 | export function ExampleScrollbars() {
4 | return (
5 | <>
6 |
11 | >
12 | );
13 | }
14 |
15 | const starterCode = `import {
16 | compose,
17 | EventManager,
18 | invariant,
19 | JustifyContent,
20 | layout,
21 | Overflow,
22 | paint,
23 | parseTTF,
24 | prepareLookups,
25 | renderFontAtlas,
26 | WebGPURenderer,
27 | Vec2,
28 | View,
29 | } from "./dist/index";
30 |
31 | const colors = {
32 | gray: [
33 | "#111111",
34 | "#191919",
35 | "#222222",
36 | "#2A2A2A",
37 | "#313131",
38 | "#3A3A3A",
39 | "#484848",
40 | "#606060",
41 | "#6E6E6E",
42 | "#7B7B7B",
43 | "#B4B4B4",
44 | "#EEEEEE",
45 | ],
46 | } as const;
47 |
48 | document.body.setAttribute("style", "margin: 0; min-height: 100dvh;");
49 |
50 | const canvas = document.createElement("canvas");
51 | document.body.appendChild(canvas);
52 |
53 | const parent = canvas.parentElement;
54 | invariant(parent, "No parent element found.");
55 | const WIDTH = parent.clientWidth;
56 | const HEIGHT = parent.clientHeight;
57 |
58 | const settings = {
59 | sampleCount: 4,
60 | windowHeight: HEIGHT,
61 | windowWidth: WIDTH,
62 | rectangleBufferSize: 16 * 4096,
63 | textBufferSize: 16 * 100_000,
64 | };
65 |
66 | canvas.width = WIDTH * window.devicePixelRatio;
67 | canvas.height = HEIGHT * window.devicePixelRatio;
68 | canvas.setAttribute("style", "width: 100%; height: 100%;");
69 |
70 | async function run() {
71 | const interTTF = await fetch("https://tchayen.com/assets/Inter.ttf").then(
72 | (response) => response.arrayBuffer()
73 | );
74 | const alphabet =
75 | "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890/.";
76 |
77 | const entry = navigator.gpu;
78 | invariant(entry, "WebGPU is not supported in this browser.");
79 |
80 | const context = canvas.getContext("webgpu");
81 | invariant(context, "WebGPU is not supported in this browser.");
82 |
83 | const adapter = await entry.requestAdapter();
84 | invariant(adapter, "No GPU found on this system.");
85 |
86 | const device = await adapter.requestDevice();
87 |
88 | context.configure({
89 | alphaMode: "opaque",
90 | device: device,
91 | format: navigator.gpu.getPreferredCanvasFormat(),
92 | });
93 |
94 | const lookups = prepareLookups(
95 | [{ buffer: interTTF, name: "Inter", ttf: parseTTF(interTTF) }],
96 | {
97 | alphabet,
98 | fontSize: 150,
99 | }
100 | );
101 |
102 | const fontAtlas = await renderFontAtlas(lookups, { useSDF: true });
103 |
104 | const colorTexture = device.createTexture({
105 | format: "bgra8unorm",
106 | label: "color",
107 | sampleCount: settings.sampleCount,
108 | size: { height: canvas.height, width: canvas.width },
109 | usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
110 | });
111 | const colorTextureView = colorTexture.createView({ label: "color" });
112 |
113 | const eventManager = new EventManager();
114 |
115 | const renderer = new WebGPURenderer(
116 | device,
117 | context,
118 | colorTextureView,
119 | settings,
120 | lookups,
121 | fontAtlas
122 | );
123 |
124 | const root = new View({
125 | style: {
126 | backgroundColor: colors.gray[1],
127 | height: 300,
128 | width: 300,
129 | },
130 | });
131 |
132 | const overflow = new View({
133 | style: {
134 | backgroundColor: colors.gray[2],
135 | height: 300,
136 | overflow: Overflow.Scroll,
137 | width: "100%",
138 | },
139 | });
140 | root.add(overflow);
141 |
142 | const tooTall = new View({
143 | style: {
144 | backgroundColor: colors.gray[4],
145 | overflow: Overflow.Scroll,
146 | width: 180,
147 | },
148 | });
149 | overflow.add(tooTall);
150 |
151 | for (let i = 0; i < 6; i++) {
152 | tooTall.add(
153 | new View({
154 | style: {
155 | backgroundColor: colors.gray[i + 6],
156 | height: 60,
157 | width: 180 - i * 20,
158 | },
159 | })
160 | );
161 | }
162 |
163 | layout(root, lookups, new Vec2(window.innerWidth, window.innerHeight));
164 |
165 | function render() {
166 | const commandEncoder = device.createCommandEncoder();
167 |
168 | eventManager.deliverEvents(root);
169 |
170 | compose(renderer, root);
171 | paint(renderer, root);
172 | renderer.render(commandEncoder);
173 |
174 | device.queue.submit([commandEncoder.finish()]);
175 | requestAnimationFrame(render);
176 | }
177 |
178 | render();
179 | }
180 |
181 | run();
182 | `;
183 |
--------------------------------------------------------------------------------
/src/utils/parseColor.ts:
--------------------------------------------------------------------------------
1 | import { Vec4 } from "../math/Vec4";
2 | import { invariant } from "./invariant";
3 |
4 | /**
5 | * https://stackoverflow.com/a/54014428
6 | */
7 | function hslToRgb(h: number, s: number, l: number): [number, number, number] {
8 | const a = s * Math.min(l, 1 - l);
9 | const f = (n: number, k = (n + h / 30) % 12): number =>
10 | l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
11 |
12 | return [f(0), f(8), f(4)];
13 | }
14 |
15 | function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
16 | const f = (n: number, k = (n + h / 60) % 6): number =>
17 | v - v * s * Math.max(Math.min(k, 4 - k, 1), 0);
18 |
19 | return [f(5), f(3), f(1)];
20 | }
21 |
22 | /**
23 | * Supported formats:
24 | *
25 | * ### Hex
26 | * - `#f00`
27 | * - `#ff0000`
28 | *
29 | * ### RGB
30 | *
31 | * - `rgb(255, 0, 0)`
32 | * - `rgba(255, 0, 0, 0.5)`
33 | *
34 | * ### HSL
35 | *
36 | * - `hsl(60, 100%, 50%)`
37 | * - `hsl(60 100% 50%)`
38 | * - `hsl(60, 100%, 50%, 0.8)`
39 | * - `hsl(60 100% 50% 0.8)`
40 | * - `hsla(60, 100%, 50%, 0.8)`
41 | * - `hsla(60 100% 50% 0.8)`
42 | * - `hsla(60 100% 50% / 0.8)`
43 | *
44 | * ### HSV
45 | *
46 | * See HSL.
47 | */
48 | export function parseColor(color: string): Vec4 {
49 | if (color.startsWith("#")) {
50 | if (color.length === 9) {
51 | // Hex with alpha
52 | const r = Number.parseInt(color.slice(1, 3), 16) / 255;
53 | const g = Number.parseInt(color.slice(3, 5), 16) / 255;
54 | const b = Number.parseInt(color.slice(5, 7), 16) / 255;
55 | const a = Number.parseInt(color.slice(7, 9), 16) / 255;
56 |
57 | return new Vec4(r, g, b, a);
58 | } else if (color.length === 7) {
59 | const r = Number.parseInt(color.slice(1, 3), 16) / 255;
60 | const g = Number.parseInt(color.slice(3, 5), 16) / 255;
61 | const b = Number.parseInt(color.slice(5, 7), 16) / 255;
62 |
63 | return new Vec4(r, g, b, 1);
64 | } else if (color.length === 4) {
65 | const r = Number.parseInt(color.slice(1, 2).repeat(2), 16) / 255;
66 | const g = Number.parseInt(color.slice(2, 3).repeat(2), 16) / 255;
67 | const b = Number.parseInt(color.slice(3, 4).repeat(2), 16) / 255;
68 |
69 | return new Vec4(r, g, b, 1);
70 | } else {
71 | throw new Error(`Unsupported color: ${color}.`);
72 | }
73 | } else if (color.startsWith("rgb")) {
74 | const hasAlpha = color[3] === "a";
75 | const separator = color.includes(",") ? "," : " ";
76 | const channels = color
77 | .slice(hasAlpha ? 5 : 4, -1)
78 | .split(separator)
79 | .map(Number);
80 |
81 | if (!hasAlpha && channels.length === 4) {
82 | throw new Error("Unexpected alpha for RGB color.");
83 | }
84 |
85 | return new Vec4(
86 | channels[0]! / 255,
87 | channels[1]! / 255,
88 | channels[2]! / 255,
89 | hasAlpha ? channels[3]! : 1,
90 | );
91 | } else if (color.startsWith("hsl")) {
92 | const separator = color.includes(",") ? "," : " ";
93 | const channels = color.slice(color.indexOf("(") + 1, -1).split(separator);
94 |
95 | if (color.includes("/")) {
96 | channels[3] = channels[4]!;
97 | channels.pop();
98 | }
99 |
100 | const hasAlpha = channels.length === 4;
101 | if (color[3] === "a" && !hasAlpha) {
102 | throw new Error(`Alpha value is missing for ${color}.`);
103 | }
104 |
105 | const alpha = hasAlpha ? Number(channels[3]) : 1;
106 | const converted = hslToRgb(
107 | Number(channels[0]),
108 | Number(channels[1]!.slice(0, -1)) / 100,
109 | Number(channels[2]!.slice(0, -1)) / 100,
110 | );
111 |
112 | return new Vec4(converted[0], converted[1], converted[2], alpha);
113 | } else if (color.startsWith("hsv")) {
114 | const separator = color.includes(",") ? "," : " ";
115 | const hasAlpha = color[3] === "a";
116 | const channels = color.slice(hasAlpha ? 5 : 4, -1).split(separator);
117 | invariant(channels.length === 3 || channels.length === 4, "Invalid HSV color.");
118 |
119 | if (color.includes("/")) {
120 | channels[3] = channels[4]!;
121 | channels.pop();
122 | }
123 |
124 | const alpha = hasAlpha ? Number(channels[3]) : 1;
125 | const converted = hsvToRgb(
126 | Number(channels[0]),
127 | Number(channels[1]!.slice(0, -1)) / 100,
128 | Number(channels[2]!.slice(0, -1)) / 100,
129 | );
130 |
131 | return new Vec4(converted[0], converted[1], converted[2], alpha);
132 | } else if (color.startsWith("oklab")) {
133 | // Soon :')
134 | console.warn(`Unsupported color: ${color}.`);
135 | return new Vec4(0, 0, 0, 0);
136 | } else if (color === "transparent") {
137 | return new Vec4(0, 0, 0, 0);
138 | } else {
139 | console.warn(`Unsupported color: ${color}.`);
140 | return new Vec4(0, 0, 0, 0);
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/docs/app/components/BaseEditor.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import type { SandpackProps, SandpackTheme } from "@codesandbox/sandpack-react";
3 | import {
4 | SandpackCodeEditor,
5 | SandpackLayout,
6 | SandpackPreview,
7 | SandpackProvider,
8 | } from "@codesandbox/sandpack-react";
9 | import { withClient } from "./withClient";
10 | import { jetBrainsMono, outline } from "./tags";
11 | import * as Collapsible from "@radix-ui/react-collapsible";
12 | import { twMerge } from "tailwind-merge";
13 | import { useEffect, useRef } from "react";
14 |
15 | export const BaseEditorClient = withClient(function BaseEditor({
16 | files,
17 | customSetup,
18 | }: SandpackProps) {
19 | const ref = useRef(null);
20 |
21 | useEffect(() => {
22 | if (!ref.current) {
23 | return;
24 | }
25 |
26 | const div = ref.current;
27 |
28 | const onMouseEnter = () => {
29 | localStorage.setItem("sandpack:editor:active", "true");
30 | toggleScrolling(true);
31 | };
32 |
33 | const onMouseLeave = () => {
34 | localStorage.setItem("sandpack:editor:active", "false");
35 | toggleScrolling(false);
36 | };
37 |
38 | div.addEventListener("mouseenter", onMouseEnter);
39 | div.addEventListener("mouseleave", onMouseLeave);
40 |
41 | return () => {
42 | div.removeEventListener("mouseenter", onMouseEnter);
43 | div.removeEventListener("mouseleave", onMouseLeave);
44 | };
45 | }, [ref]);
46 |
47 | return (
48 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
70 | Show code{" "}
71 |
79 |
86 |
87 |
88 |
89 |
90 |
96 |
97 |
98 |
99 |
100 | );
101 | });
102 |
103 | function toggleScrolling(isPaused: boolean) {
104 | const body = document.body;
105 | if (isPaused) {
106 | body.setAttribute("style", "overflow:hidden");
107 | } else {
108 | body.setAttribute("style", "");
109 | }
110 | }
111 |
112 | const githubDark: SandpackTheme = {
113 | colors: {
114 | accent: "#c9d1d9",
115 | base: "#c9d1d9",
116 | clickable: "#c9d1d9",
117 | disabled: "#4d4d4d",
118 | hover: "#c9d1d9",
119 | surface1: "var(--code-theme-background)",
120 | surface2: "hsl(198, 6.6%, 15.8%)",
121 | surface3: "hsl(198, 6.6%, 15.8%)",
122 | },
123 | font: {
124 | body: "Inter",
125 | lineHeight: "20px",
126 | mono: jetBrainsMono.style.fontFamily,
127 | size: "14px",
128 | },
129 | syntax: {
130 | comment: {
131 | color: "#8b949e",
132 | fontStyle: "normal",
133 | },
134 | definition: "#d2a8ff",
135 | keyword: "#ff7b72",
136 | plain: "#c9d1d9",
137 | property: "#79c0ff",
138 | punctuation: "#c9d1d9",
139 | static: "#a5d6ff",
140 | string: "#a5d6ff",
141 | tag: "#7ee787",
142 | },
143 | };
144 |
--------------------------------------------------------------------------------
/docs/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/font/shapeText.ts:
--------------------------------------------------------------------------------
1 | import { Vec2 } from "../math/Vec2";
2 | import { TextAlign } from "../layout/styling";
3 | import { LRUCache } from "../utils/LRUCache";
4 | import { invariant } from "../utils/invariant";
5 | import { fontSizeToGap } from "./renderFontAtlas";
6 | import type { Lookups } from "./types";
7 | import { MISSING_GLYPH } from "./calculateGlyphQuads";
8 |
9 | export const ENABLE_KERNING = true;
10 |
11 | const cache = new LRUCache(512);
12 |
13 | export type Shape = {
14 | boundingRectangle: { height: number; width: number };
15 | positions: Array;
16 | sizes: Array;
17 | };
18 |
19 | /**
20 | * The most important function for getting text on the screen. Given a string and font data, finds
21 | * the positions and sizes of each character.
22 | *
23 | * @param lookups metadata of fonts.
24 | * @param fontName name of the font.
25 | * @param fontSize size of the font in pixels.
26 | * @param lineHeight height of a line in pixels.
27 | * @param text text to shape.
28 | * @param textAlign alignment of the text.
29 | * @param maxWidth maximum width of the text. If the text is longer than this, it will be wrapped. Defaults to `Number.POSITIVE_INFINITY`.
30 | * @param noWrap if true, the text will not be wrapped.
31 | * @returns a shape object that can be used to render the text.
32 | */
33 | export function shapeText(
34 | lookups: Lookups,
35 | fontName: string,
36 | fontSize: number,
37 | lineHeight: number,
38 | text: string,
39 | textAlign: TextAlign,
40 | maxWidth?: number,
41 | noWrap?: boolean,
42 | ): Shape {
43 | if (noWrap) {
44 | maxWidth = Number.POSITIVE_INFINITY;
45 | }
46 | maxWidth ??= Number.POSITIVE_INFINITY;
47 |
48 | const cached = cache.get(
49 | JSON.stringify({ fontName, fontSize, lineHeight, maxWidth, text, textAlign }),
50 | );
51 |
52 | if (cached) {
53 | return cached;
54 | }
55 |
56 | const font = lookups.fonts.find((font) => font.name === fontName);
57 | invariant(font, `Could not find font ${fontName}.`);
58 |
59 | // Alocate text.length sized array.
60 | const positions: Array = new Array(text.length);
61 | const sizes: Array = new Array(text.length);
62 |
63 | let positionX = 0;
64 | let positionY = 0;
65 | const scale = (1 / font.unitsPerEm) * fontSize;
66 |
67 | // Atlas gap is the gap between glyphs in the atlas.
68 | const atlasGap = fontSizeToGap(lookups.atlas.fontSize);
69 |
70 | // Padding is the additional space around each glyph.
71 | const padding = (atlasGap * fontSize) / lookups.atlas.fontSize;
72 |
73 | let longestLineWidth = 0;
74 | // Index of last character of the last full word that was looped over.
75 | let lastIndex = 0;
76 |
77 | let j = 0;
78 | for (let i = 0; i < text.length; i++) {
79 | // Prevent infinite loops.
80 | j += 1;
81 | if (j > 1000) {
82 | throw new Error(`Infinite loop for text: ${text} with limit ${maxWidth}`);
83 | }
84 |
85 | const character = text[i]!.charCodeAt(0);
86 | const glyph = font.glyphs.get(character) ?? font.glyphs.get(MISSING_GLYPH.charCodeAt(0))!;
87 | const { y, width, height, lsb, rsb } = glyph;
88 | let kerning = 0;
89 | if (i > 0 && ENABLE_KERNING) {
90 | kerning = font.kern(text[i - 1]!.charCodeAt(0), character);
91 | }
92 | const charWidth = (lsb + kerning + width + rsb) * scale;
93 |
94 | positions[i] = new Vec2(
95 | positionX + (lsb + kerning) * scale - padding,
96 | positionY + (font.capHeight - y - height) * scale - padding,
97 | );
98 | positionX += charWidth;
99 | sizes[i] = new Vec2(width * scale + padding * 2, height * scale + padding * 2);
100 |
101 | // If current word ran out of space, move i back to the last character of the last word and
102 | // restart positionX.
103 | if (positionX > maxWidth) {
104 | positionX = 0;
105 | positionY += lineHeight;
106 | i = lastIndex;
107 | lastIndex = i + 1;
108 | }
109 |
110 | if (text[i] !== " " && text[i + 1] === " ") {
111 | lastIndex = i;
112 | }
113 | }
114 |
115 | // Text alignment.
116 | if (text.length > 0) {
117 | const leftSpace = maxWidth
118 | ? maxWidth - longestLineWidth - positions.at(-1)!.x - sizes.at(-1)!.x + padding
119 | : 0;
120 | if (leftSpace > 0) {
121 | const offset =
122 | textAlign === TextAlign.Center
123 | ? leftSpace / 2
124 | : textAlign === TextAlign.Right
125 | ? leftSpace
126 | : 0;
127 | for (let i = 0; i < positions.length; i++) {
128 | const position = positions[i];
129 | invariant(position, `Could not find position.${text} ${i} ${JSON.stringify(positions)}`);
130 | positions[i] = new Vec2(position.x + offset, position.y);
131 | }
132 | }
133 | }
134 |
135 | if (longestLineWidth === 0) {
136 | longestLineWidth = positionX;
137 | }
138 |
139 | let width = longestLineWidth;
140 | if (width > 0) {
141 | width -= padding;
142 | }
143 |
144 | const capHeightInPixels = (font.capHeight * fontSize) / font.unitsPerEm;
145 | const height = positionY + capHeightInPixels;
146 |
147 | const shape = {
148 | // Round up to avoid layout gaps.
149 | boundingRectangle: {
150 | height: Math.ceil(height),
151 | width: Math.ceil(width),
152 | },
153 | positions,
154 | sizes,
155 | };
156 |
157 | cache.put(fontSize + text, shape);
158 |
159 | return shape;
160 | }
161 |
--------------------------------------------------------------------------------
/docs/app/components/Body.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, type PropsWithChildren, useEffect } from "react";
4 | import * as Tooltip from "@radix-ui/react-tooltip";
5 | import { Sidebar } from "./Sidebar";
6 | import * as Dialog from "@radix-ui/react-dialog";
7 | import { Outline } from "./Outline";
8 | import Image from "next/image";
9 | import { packageJson } from "./PackageJson";
10 | import { Search } from "./Search";
11 | import Link from "next/link";
12 | import { twMerge } from "tailwind-merge";
13 | import { outline } from "./tags";
14 | import { usePathname } from "next/navigation";
15 |
16 | export function Body({ children }: PropsWithChildren) {
17 | const pathname = usePathname();
18 | const [showMobileMenu, setShowMobileMenu] = useState(false);
19 |
20 | const closeMenu = () => setShowMobileMenu(false);
21 |
22 | useEffect(() => {
23 | closeMenu();
24 | }, [pathname]);
25 |
26 | const logo = (
27 | setShowMobileMenu(false)}
29 | href="/"
30 | className={twMerge(outline, "flex items-center gap-2 rounded-md px-1")}
31 | >
32 |
33 |
34 |
Red Otter
35 |
{packageJson.version}
36 |
37 |
38 | );
39 |
40 | const mobileNavigation = (
41 | setShowMobileMenu(open)}>
42 |
43 |
47 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | {logo}
64 |
65 |
71 | ✗
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | );
85 |
86 | const topBar = (
87 | // Height is 49px to add 1px for bottom border.
88 |
89 | {logo}
90 |
91 |
92 |
98 |
99 | GitHub
100 |
101 |
102 |
103 |
104 | {mobileNavigation}
105 |
106 | );
107 |
108 | return (
109 | <>
110 |
111 | {/*
*/}
112 | {topBar}
113 |
114 |
115 |
116 |
117 |
{children}
118 |
119 | {!pathname.startsWith("/blog") && }
120 |
121 |
122 |
123 | >
124 | );
125 | }
126 |
--------------------------------------------------------------------------------
/src/layout/View.ts:
--------------------------------------------------------------------------------
1 | import { CROSS_AXIS_SIZE } from "../consts";
2 | import { getScreenVisibleRectangle } from "../hitTest";
3 | import { Vec4 } from "../math/Vec4";
4 | import { intersection, isInside } from "../math/utils";
5 | import { BaseView } from "./BaseView";
6 | import type {
7 | BlurHandler,
8 | FocusHandler,
9 | KeyDownHandler,
10 | KeyPressHandler,
11 | KeyUpHandler,
12 | LayoutHandler,
13 | MouseClickHandler,
14 | MouseDownHandler,
15 | MouseEnterHandler,
16 | MouseEvent,
17 | MouseLeaveHandler,
18 | MouseMoveHandler,
19 | MouseUpHandler,
20 | ScrollEvent,
21 | ScrollHandler,
22 | } from "./eventTypes";
23 | import { UserEventType } from "./eventTypes";
24 | import type { ViewStyleProps } from "./styling";
25 | import { Overflow } from "./styling";
26 |
27 | type UserEventTuple =
28 | | [UserEventType.MouseClick, MouseClickHandler]
29 | | [UserEventType.MouseMove, MouseMoveHandler]
30 | | [UserEventType.MouseEnter, MouseEnterHandler]
31 | | [UserEventType.MouseLeave, MouseLeaveHandler]
32 | | [UserEventType.MouseDown, MouseDownHandler]
33 | | [UserEventType.MouseUp, MouseUpHandler]
34 | | [UserEventType.Scroll, ScrollHandler]
35 | | [UserEventType.KeyDown, KeyDownHandler]
36 | | [UserEventType.KeyUp, KeyUpHandler]
37 | | [UserEventType.KeyPress, KeyPressHandler]
38 | | [UserEventType.Focus, FocusHandler]
39 | | [UserEventType.Blur, BlurHandler]
40 | | [UserEventType.Layout, LayoutHandler];
41 |
42 | /**
43 | * `BaseView` but with event listeners.
44 | */
45 | export class View extends BaseView {
46 | _eventListeners: Array = [];
47 | /**
48 | * Controlled by `EventManager`. Needed for dispatching mouseEnter and mouseLeave events.
49 | */
50 | _isMouseOver = false;
51 |
52 | _scrolling: {
53 | xActive: boolean;
54 | xHovered: boolean;
55 | yActive: boolean;
56 | yHovered: boolean;
57 | };
58 |
59 | constructor(
60 | readonly props: {
61 | onClick?(): void;
62 | onMouseEnter?(): void;
63 | onMouseLeave?(): void;
64 | style?: ViewStyleProps;
65 | testID?: string;
66 | },
67 | ) {
68 | super(props);
69 |
70 | this._scrolling = {
71 | xActive: false,
72 | xHovered: false,
73 | yActive: false,
74 | yHovered: false,
75 | };
76 |
77 | this.onScroll = this.onScroll.bind(this);
78 | this.handleMouseDownScrollStart = this.handleMouseDownScrollStart.bind(this);
79 | this.handleMouseMoveScrollHovering = this.handleMouseMoveScrollHovering.bind(this);
80 | this.onMouseEnter = this.onMouseEnter.bind(this);
81 | this.onMouseLeave = this.onMouseLeave.bind(this);
82 |
83 | // For mouse-interacting with the scrollbar.
84 | // TODO: this is done when creating the node but scrollbar can be added later (like with
85 | // Overflow.Auto). What then?
86 | if (this._style.overflowX === Overflow.Scroll || this._style.overflowY === Overflow.Scroll) {
87 | this._eventListeners.push(
88 | [UserEventType.Scroll, this.onScroll],
89 | [UserEventType.MouseDown, this.handleMouseDownScrollStart],
90 | [UserEventType.MouseMove, this.handleMouseMoveScrollHovering],
91 | [UserEventType.MouseEnter, this.onMouseEnter],
92 | [UserEventType.MouseLeave, this.onMouseLeave],
93 | );
94 | }
95 |
96 | if (props.onClick) {
97 | this._eventListeners.push([UserEventType.MouseClick, props.onClick]);
98 | }
99 | if (props.onMouseEnter) {
100 | this._eventListeners.push([UserEventType.MouseEnter, props.onMouseEnter]);
101 | }
102 | if (props.onMouseLeave) {
103 | this._eventListeners.push([UserEventType.MouseLeave, props.onMouseLeave]);
104 | }
105 | }
106 |
107 | private onScroll(event: ScrollEvent) {
108 | this._state.scrollX = Math.min(
109 | Math.max(this._state.scrollX + Math.round(event.delta.x), 0),
110 | this._state.scrollWidth - this._state.clientWidth,
111 | );
112 | this._state.scrollY = Math.min(
113 | Math.max(this._state.scrollY + Math.round(event.delta.y), 0),
114 | this._state.scrollHeight - this._state.clientHeight,
115 | );
116 | }
117 |
118 | private onMouseEnter(_: MouseEvent) {
119 | // No-op but important to keep this._isMouseOver up to date.
120 | }
121 |
122 | private onMouseLeave(_: MouseEvent) {
123 | this._scrolling.xHovered = false;
124 | this._scrolling.yHovered = false;
125 | }
126 |
127 | private handleMouseDownScrollStart(_: MouseEvent) {
128 | if (this._scrolling.xHovered) {
129 | this._scrolling.xActive = true;
130 | }
131 |
132 | if (this._scrolling.yHovered) {
133 | this._scrolling.yActive = true;
134 | }
135 | }
136 |
137 | private handleMouseMoveScrollHovering(event: MouseEvent) {
138 | if (this._scrolling.xActive || this._scrolling.yActive) {
139 | return;
140 | }
141 |
142 | if (this._isMouseOver) {
143 | // Screen space rectangle of the node's visible area, including scrollbars.
144 | const rectangle = getScreenVisibleRectangle(this);
145 |
146 | if (this._state.hasHorizontalScrollbar) {
147 | const scrollbar = new Vec4(
148 | rectangle.x,
149 | rectangle.y + rectangle.w - CROSS_AXIS_SIZE,
150 | this._state.clientWidth,
151 | CROSS_AXIS_SIZE,
152 | );
153 |
154 | const clippedScrollbar = intersection(rectangle, scrollbar);
155 | this._scrolling.xHovered = isInside(event.position, clippedScrollbar);
156 | }
157 | if (this._state.hasVerticalScrollbar) {
158 | const scrollbar = new Vec4(
159 | rectangle.x + rectangle.z - CROSS_AXIS_SIZE,
160 | rectangle.y,
161 | CROSS_AXIS_SIZE,
162 | this._state.clientHeight,
163 | );
164 | const clippedScrollbar = intersection(rectangle, scrollbar);
165 | this._scrolling.yHovered = isInside(event.position, clippedScrollbar);
166 | }
167 | } else {
168 | this._scrolling.xHovered = false;
169 | this._scrolling.yHovered = false;
170 | }
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/docs/app/getting-started/page.mdx:
--------------------------------------------------------------------------------
1 | import { Tabs } from "../components/Tabs";
2 | import { getMetadata } from "../getMetadata";
3 | export const metadata = getMetadata("Getting Started");
4 |
5 | # Getting Started
6 |
7 | Due to modular and layered architecture, you can start almost anywhere depending on your needs.
8 |
9 | However, the library is still in early stages and there are many rough edges. If you feel brave enough to try it out now, please let me know of any problems in the [GitHub issues](https://github.com/tchayen/red-otter).
10 |
11 | As mentioned in the README, first install the library with package manager of your choice:
12 |
13 | ```bash
14 | npm i red-otter
15 | ```
16 |
17 | ## Setting up the renderer
18 |
19 | Depending on which renderer you use, setup code will differ. There is a bit of boilerplate code that is needed, but the idea was to make it as unopinionated as possible since setup of every app is different and this is not meant to be a framework, but a UI library that co-exists with other parts of the app.
20 |
21 |
22 |
23 | WebGPU
24 | WebGL
25 |
26 |
27 |
28 | There is a bit of setup involved. This is general WebGPU initialization, so if you are doing something similar that boils down to the same thing – it's great, you can skip this part. That is the whole point on not enforcing anything on you.
29 |
30 | ```tsx
31 | const settings = {
32 | sampleCount: 4,
33 | windowHeight: canvas.clientHeight,
34 | windowWidth: canvas.clientWidth,
35 | };
36 |
37 | canvas.width = canvas.clientWidth;
38 | canvas.height = canvas.clientHeight;
39 |
40 | const interTTF = await fetch("/Inter.ttf").then((response) => response.arrayBuffer());
41 |
42 | const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
43 |
44 | const entry = navigator.gpu;
45 | invariant(entry, "WebGPU is not supported in this browser.");
46 |
47 | const context = canvas.getContext("webgpu");
48 | invariant(context, "WebGPU is not supported in this browser.");
49 |
50 | const adapter = await entry.requestAdapter();
51 | invariant(adapter, "No GPU found on this system.");
52 |
53 | const device = await adapter.requestDevice();
54 |
55 | context.configure({
56 | alphaMode: "opaque",
57 | device: device,
58 | format: navigator.gpu.getPreferredCanvasFormat(),
59 | });
60 |
61 | const lookups = prepareLookups(
62 | [
63 | {
64 | buffer: interTTF,
65 | name: "Inter",
66 | ttf: parseTTF(interTTF),
67 | },
68 | ],
69 | {
70 | alphabet,
71 | fontSize: 150,
72 | },
73 | );
74 |
75 | const fontAtlas = await renderFontAtlas(lookups, { useSDF: true });
76 |
77 | const colorTexture = device.createTexture({
78 | format: "bgra8unorm",
79 | label: "color",
80 | sampleCount: settings.sampleCount,
81 | size: { height: canvas.height, width: canvas.width },
82 | usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
83 | });
84 | const colorTextureView = colorTexture.createView({ label: "color" });
85 |
86 | const renderer = new ScrollableRenderer(
87 | device,
88 | context,
89 | colorTextureView,
90 | settings,
91 | lookups,
92 | fontAtlas,
93 | );
94 |
95 | // Our code goes here…
96 |
97 | function render() {
98 | // …or here!
99 |
100 | const commandEncoder = device.createCommandEncoder();
101 | renderer.render(commandEncoder);
102 | device.queue.submit([commandEncoder.finish()]);
103 | requestAnimationFrame(render);
104 | }
105 |
106 | render();
107 | ```
108 |
109 |
110 |
111 |
112 | _WebGL example coming in future._
113 |
114 |
115 |
116 |
117 | ## First layer: calling renderer directly
118 |
119 | First of all, you can abandon all other library features and just use the renderer as a Canvas API replacement (although limited!).
120 |
121 | ```tsx
122 | renderer.rectangle(
123 | new Vec4(1, 0, 1, 1),
124 | new Vec2(50, 50),
125 | new Vec2(100, 100),
126 | new Vec4(10, 10, 10, 10),
127 | new Vec4(0, 0, 0, 0),
128 | new Vec4(0, 0, 0, 0),
129 | new Vec2(0, 0),
130 | new Vec2(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY),
131 | new Vec4(0, 0, 0, 0),
132 | );
133 | ```
134 |
135 | This will get us a pink rounded rectangle on the screen.
136 |
137 | > [!NOTE]
138 | > You might have noticed that this API is uhm… a bit ugly? What are even those parameters? Well, the thing is, this is the most low-level function there is. It is as 'hot path' as it gets here.
139 | >
140 | > Therefore performance takes precedence over ergonomics and most JS engines will deal better with even a large number of arguments rather than with an options object (citation needed).
141 |
142 | But usually this is not what might be useful to us. Therefore let's continue to…
143 |
144 | ## Second layer: using views
145 |
146 | This is the place where layout and user events happens.
147 |
148 | ```tsx
149 | const root = new BaseView({
150 | style: {
151 | height: "100%",
152 | width: "100%",
153 | },
154 | });
155 |
156 | root.add(
157 | new BaseView({
158 | style: {
159 | backgroundColor: "#333",
160 | height: 100,
161 | width: 100,
162 | },
163 | }),
164 | );
165 |
166 | layout(root, lookups, new Vec2(window.innerWidth, window.innerHeight));
167 | ```
168 |
169 | ## Third layer: make it interactive
170 |
171 | Until now we only have a static content, which, while maybe useful for things like OG images or some static parts that need automatic layout, is not a full UI. What is missing is user interaction.
172 |
173 | ```tsx
174 | // Replace BaseView with View.
175 |
176 | // And in your game loop add this:
177 | compose(renderer, root);
178 | paint(renderer, root);
179 | ```
180 |
181 | ### Add event listeners
182 |
183 | Each view accepts `onClick` event listener, but there are more types that can be added in a `constructor()` if you extend the `View` class.
184 |
185 | ```tsx
186 | export class Input extends View {
187 | constructor() {
188 | this.onKeyDown = this.onKeyDown.bind(this);
189 | this._eventListeners.push([UserEventType.KeyDown, this.onKeyDown]);
190 | }
191 |
192 | private onKeyDown(event: KeyboardEvent) {
193 | // ...
194 | }
195 | }
196 | ```
197 |
198 | ## Summary
199 |
200 | This hopefully gives some overview of how the library works.
201 |
--------------------------------------------------------------------------------