Compare commits

..

No commits in common. "main" and "0.3.2" have entirely different histories.
main ... 0.3.2

34 changed files with 658 additions and 2853 deletions

41
.github/workflows/deno.yml vendored Normal file
View file

@ -0,0 +1,41 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will install Deno then run `deno lint` and `deno test`.
# For more information see: https://github.com/denoland/setup-deno
name: Deno
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Setup repo
uses: actions/checkout@v4
- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
# Uncomment this step to verify the use of 'deno fmt' on each commit.
- name: Verify formatting
run: deno fmt --check
- name: Run linter
run: deno lint
- name: Run tests
run: deno test -A

View file

@ -1,7 +1,7 @@
name: Publish on JSR name: Publish on JSR
on: on:
workflow_run: workflow_run:
workflows: ["Test"] workflows: ["Deno"]
types: [completed] types: [completed]
branches: branches:
- "main" - "main"
@ -9,7 +9,6 @@ on:
jobs: jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions: permissions:
contents: read contents: read
@ -18,36 +17,5 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup repo - name: Publish package
uses: actions/checkout@v4 run: npx jsr publish
- uses: pnpm/action-setup@v4
with:
run_install: false
- uses: actions/setup-node@v4
with:
node-version: "24.x"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
# This step removes JSR dependencies from package.json before publishing,
# Because apparently now while major npm compatible package managers
# support JSR dependencies in package.json, JSR itself does not.
- name: Remove JSR deps from package.json
run: |
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
for (const dep in pkg.dependencies ?? {}) {
if (pkg.dependencies[dep].startsWith('jsr:')) {
delete pkg.dependencies[dep];
}
}
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
"
- name: Publish to JSR
# Have to use --allow-dirty because we modified package.json in the previous step
run: pnpm dlx jsr publish --allow-dirty

View file

@ -9,21 +9,18 @@ jobs:
contents: read contents: read
id-token: write id-token: write
steps: steps:
- name: Setup repo - uses: actions/checkout@v4
uses: actions/checkout@v4 - name: Setup Deno
- uses: pnpm/action-setup@v4 uses: denoland/setup-deno@v2
with: with:
run_install: false deno-version: v2.x
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "24.x" node-version: "22.x"
cache: "pnpm"
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- run: pnpm install - run: deno run -A scripts/build_npm.ts
- run: pnpm build - run: cd npm && npm ci
- run: pnpm test - run: cd npm && npm publish --provenance --access public
# --no-git-checks because of https://github.com/pnpm/pnpm/issues/5894.
- run: pnpm publish --provenance --access public --no-git-checks
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View file

@ -1,32 +0,0 @@
name: Test
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Setup repo
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
run_install: false
- uses: actions/setup-node@v4
with:
node-version: "24.x"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm test

3
.gitignore vendored
View file

@ -1,3 +1,2 @@
npm
.DS_Store .DS_Store
node_modules
dist

View file

@ -3,11 +3,25 @@
// For a full list of overridable settings, and general information on folder-specific settings, // For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#folder-specific-settings // see the documentation: https://zed.dev/docs/configuring-zed#folder-specific-settings
{ {
"formatter": "prettier",
"format_on_save": "on",
"languages": { "languages": {
"TypeScript": { "TypeScript": {
"language_servers": ["vtsls", "!deno"] "language_servers": ["deno", "!typescript-language-server", "!eslint"],
"formatter": {
"external": {
"command": "deno",
"arguments": ["fmt", "-"]
}
}
},
"TSX": {
"language_servers": ["deno", "!typescript-language-server", "!eslint"],
"formatter": {
"external": {
"command": "deno",
"arguments": ["fmt", "-"]
}
}
} }
} },
"format_on_save": "on"
} }

View file

@ -1,6 +1,10 @@
[ [
{ {
"label": "Test", "label": "Test",
"command": "pnpm test" "command": "deno test"
},
{
"label": "Test - Watch",
"command": "deno test --watch"
} }
] ]

View file

@ -1,5 +1,5 @@
The MIT License (MIT) The MIT License (MIT)
Copyright © 2025 Shibo Lyu <hi@lao.sb> Copyright © 2024 Shibo Lyu <hi@lao.sb>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View file

@ -1,5 +0,0 @@
import { defineBuildConfig } from "obuild/config";
export default defineBuildConfig({
entries: ["src/mod.ts"],
}) as ReturnType<typeof defineBuildConfig>;

View file

@ -1,11 +1,8 @@
{ {
"name": "@textplace/core", "name": "@textplace/core",
"version": "0.6.1", "version": "0.3.2",
"exports": "./src/mod.ts", "exports": "./mod.ts",
"imports": { "imports": {
"@std/cli": "jsr:@std/cli@1" "@deno/dnt": "jsr:@deno/dnt@^0.41.3"
},
"publish": {
"include": ["src/**/*.ts", "LICENSE", "README.md"]
} }
} }

157
deno.lock generated Normal file
View file

@ -0,0 +1,157 @@
{
"version": "4",
"specifiers": {
"jsr:@david/code-block-writer@^13.0.2": "13.0.3",
"jsr:@deno/cache-dir@~0.10.3": "0.10.3",
"jsr:@deno/dnt@~0.41.3": "0.41.3",
"jsr:@std/assert@0.223": "0.223.0",
"jsr:@std/assert@0.226": "0.226.0",
"jsr:@std/bytes@0.223": "0.223.0",
"jsr:@std/fmt@0.223": "0.223.0",
"jsr:@std/fmt@1": "1.0.3",
"jsr:@std/fs@0.223": "0.223.0",
"jsr:@std/fs@1": "1.0.6",
"jsr:@std/fs@~0.229.3": "0.229.3",
"jsr:@std/io@0.223": "0.223.0",
"jsr:@std/path@0.223": "0.223.0",
"jsr:@std/path@1": "1.0.8",
"jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1",
"jsr:@std/path@^1.0.8": "1.0.8",
"jsr:@std/path@~0.225.2": "0.225.2",
"jsr:@ts-morph/bootstrap@0.24": "0.24.0",
"jsr:@ts-morph/common@0.24": "0.24.0"
},
"jsr": {
"@david/code-block-writer@13.0.3": {
"integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f"
},
"@deno/cache-dir@0.10.3": {
"integrity": "eb022f84ecc49c91d9d98131c6e6b118ff63a29e343624d058646b9d50404776",
"dependencies": [
"jsr:@std/fmt@0.223",
"jsr:@std/fs@0.223",
"jsr:@std/io",
"jsr:@std/path@0.223"
]
},
"@deno/dnt@0.41.3": {
"integrity": "b2ef2c8a5111eef86cb5bfcae103d6a2938e8e649e2461634a7befb7fc59d6d2",
"dependencies": [
"jsr:@david/code-block-writer",
"jsr:@deno/cache-dir",
"jsr:@std/fmt@1",
"jsr:@std/fs@1",
"jsr:@std/path@1",
"jsr:@ts-morph/bootstrap"
]
},
"@std/assert@0.223.0": {
"integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24"
},
"@std/assert@0.226.0": {
"integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3"
},
"@std/bytes@0.223.0": {
"integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8"
},
"@std/fmt@0.223.0": {
"integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208"
},
"@std/fmt@1.0.3": {
"integrity": "97765c16aa32245ff4e2204ecf7d8562496a3cb8592340a80e7e554e0bb9149f"
},
"@std/fs@0.223.0": {
"integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c"
},
"@std/fs@0.229.3": {
"integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb",
"dependencies": [
"jsr:@std/path@1.0.0-rc.1"
]
},
"@std/fs@1.0.6": {
"integrity": "42b56e1e41b75583a21d5a37f6a6a27de9f510bcd36c0c85791d685ca0b85fa2",
"dependencies": [
"jsr:@std/path@^1.0.8"
]
},
"@std/io@0.223.0": {
"integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1",
"dependencies": [
"jsr:@std/assert@0.223",
"jsr:@std/bytes"
]
},
"@std/path@0.223.0": {
"integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989",
"dependencies": [
"jsr:@std/assert@0.223"
]
},
"@std/path@0.225.2": {
"integrity": "0f2db41d36b50ef048dcb0399aac720a5348638dd3cb5bf80685bf2a745aa506",
"dependencies": [
"jsr:@std/assert@0.226"
]
},
"@std/path@1.0.0-rc.1": {
"integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6"
},
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
},
"@ts-morph/bootstrap@0.24.0": {
"integrity": "a826a2ef7fa8a7c3f1042df2c034d20744d94da2ee32bf29275bcd4dffd3c060",
"dependencies": [
"jsr:@ts-morph/common"
]
},
"@ts-morph/common@0.24.0": {
"integrity": "12b625b8e562446ba658cdbe9ad77774b4bd96b992ae8bd34c60dbf24d06c1f3",
"dependencies": [
"jsr:@std/fs@~0.229.3",
"jsr:@std/path@~0.225.2"
]
}
},
"remote": {
"https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975",
"https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834",
"https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293",
"https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7",
"https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74",
"https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd",
"https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff",
"https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46",
"https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b",
"https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c",
"https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491",
"https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68",
"https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3",
"https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7",
"https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29",
"https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a",
"https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a",
"https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8",
"https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693",
"https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31",
"https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5",
"https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8",
"https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb",
"https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917",
"https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47",
"https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68",
"https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3",
"https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73",
"https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19",
"https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5",
"https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6",
"https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2",
"https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e"
},
"workspace": {
"dependencies": [
"jsr:@deno/dnt@~0.41.3"
]
}
}

114
logic/board.ts Normal file
View file

@ -0,0 +1,114 @@
import type { SectionData, SectionPosition } from "../types/section.ts";
import type {
BoardConfig,
BoardData,
CharacterPosition,
FullBoard,
} from "../types/board.ts";
import { applyChange, createSection } from "./section.ts";
import type { BoardChange } from "../types/change.ts";
export function createBoard(config: BoardConfig): BoardData {
return { config, sections: [] };
}
export function locateSection(
{ x, y }: CharacterPosition,
config: BoardConfig,
): SectionPosition {
return {
sx: Math.floor(x / config.sectionWidth),
sy: Math.floor(y / config.sectionHeight),
};
}
/**
* Get a section from board.
*
* If the section does not exist yet, it will be created and (optionally) added to `board`.
*/
export function getSectionOnBoard(
{ sx, sy }: SectionPosition,
board: BoardData,
options: {
/**
* Whether the section data is only used for reading.
*
* If `true`, this function will return an empty section configured with default values, but will not add it to the board data to save storage.
*/
readOnly: boolean;
} = { readOnly: false },
): SectionData {
let section: SectionData;
if (!board.sections[sy] && !options.readOnly) board.sections[sy] = [];
if (!board.sections[sy]?.[sx]) {
section = createSection({ sx, sy }, board.config);
if (!options.readOnly) board.sections[sy][sx] = section;
} else {
section = board.sections[sy][sx];
}
return section;
}
export function applyChangeOnBoard(change: BoardChange, board: BoardData) {
const sPos = locateSection(change, board.config);
const section = getSectionOnBoard(sPos, board);
applyChange(change, section);
}
export function renderFullBoard(data: BoardData): FullBoard {
const totalLineCount = data.config.sectionHeight * data.config.ySections;
const lineLength = data.config.sectionWidth * data.config.xSections;
const chLines: string[][] = Array(totalLineCount);
const colorLines: string[][] = Array(totalLineCount);
const bgColorLines: string[][] = Array(totalLineCount);
const widthLines: number[][] = Array(totalLineCount);
for (let y = 0; y < totalLineCount; y++) {
const chLine: string[] = [];
const colorLine: string[] = [];
const bgColorLine: string[] = [];
const widthLine: number[] = [];
let charsToSkip = 0;
for (let x = 0; x < lineLength; x++) {
if (charsToSkip > 0) {
charsToSkip--;
continue;
}
const sPos = locateSection({ x, y }, data.config);
const section = getSectionOnBoard(sPos, data, { readOnly: true });
const xInSection = x % data.config.sectionWidth;
const yInSection = y % data.config.sectionHeight;
const cCh = section.ch[yInSection][xInSection];
const cCo = section.color[yInSection][xInSection];
const cBg = section.bgColor[yInSection][xInSection];
const cWd = section.width[yInSection][xInSection];
chLine.push(cCh);
colorLine.push(cCo);
bgColorLine.push(cBg);
widthLine.push(cWd);
charsToSkip += cWd - 1;
}
chLines[y] = chLine;
colorLines[y] = colorLine;
bgColorLines[y] = bgColorLine;
widthLines[y] = widthLine;
}
return {
w: lineLength,
h: totalLineCount,
ch: chLines.flat(),
color: colorLines.flat(),
bg_color: bgColorLines.flat(),
width: widthLines.flat(),
};
}

21
logic/character.ts Normal file
View file

@ -0,0 +1,21 @@
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const cjkRegex =
/[\p{Unified_Ideograph}\u30A0-\u30FF\u3040-\u309F\u31F0-\u31FF]/u;
const printableASCIIRegex = /^[\x20-\x7E]$/;
export function getCharacterWidth(ch: string): number {
const segments = [...segmenter.segment(ch)];
if (segments.length !== 1) {
throw new Error(
`Expected exactly one grapheme cluster, got ${segments.length}.`,
);
}
const matchesASCII = ch.match(printableASCIIRegex);
const matchesCJK = ch.match(cjkRegex);
if (!matchesASCII && !matchesCJK) throw new Error(`Invalid character: ${ch}`);
// TODO: Support Emojis.
return matchesCJK ? 2 : 1;
}

58
logic/section.ts Normal file
View file

@ -0,0 +1,58 @@
import { getCharacterWidth } from "../mod.ts";
import type { BoardConfig } from "../types/board.ts";
import type { BoardChange } from "../types/change.ts";
import type { SectionData, SectionPosition } from "../types/section.ts";
export function createSection(
{ sx, sy }: SectionPosition,
boardConfig: BoardConfig,
): SectionData {
if (boardConfig.sectionWidth % 2 !== 0) {
throw new Error(
"sectionWidth must be multiple of 2 (least common multiples of all character widths)",
);
}
const offsetX = sx * boardConfig.sectionWidth;
const offsetY = sy * boardConfig.sectionHeight;
const ch: string[][] = Array(boardConfig.sectionHeight).fill([]).map(() =>
Array(boardConfig.sectionWidth).fill(boardConfig.defaultCh)
);
const color: string[][] = Array(boardConfig.sectionHeight).fill([]).map(() =>
Array(boardConfig.sectionWidth).fill(boardConfig.defaultColor)
);
const bgColor: string[][] = Array(boardConfig.sectionHeight).fill([]).map(
() => Array(boardConfig.sectionWidth).fill(boardConfig.defaultBgColor),
);
const width: number[][] = Array(boardConfig.sectionHeight).fill([]).map(() =>
Array(boardConfig.sectionWidth).fill(boardConfig.defaultWidth)
);
return { offsetX, offsetY, ch, color, bgColor, width };
}
export function applyChange(change: BoardChange, section: SectionData) {
const xInSection = change.x - section.offsetX;
const yInSection = change.y - section.offsetY;
const validX = xInSection >= 0 && xInSection < section.ch[0].length;
const validY = yInSection >= 0 && yInSection < section.ch.length;
if (!validX || !validY) {
throw new Error("Change does not belong to this section");
}
if (change.ch) {
const chWidth = getCharacterWidth(change.ch);
const xCharacterOffset = xInSection % chWidth;
const offsetAdjustedXInSection = xInSection - xCharacterOffset;
section.ch[yInSection][offsetAdjustedXInSection] = change.ch;
section.width[yInSection][offsetAdjustedXInSection] = chWidth;
}
if (change.color) {
section.color[yInSection][xInSection] = change.color;
}
if (change.bg_color) {
section.bgColor[yInSection][xInSection] = change.bg_color;
}
}

7
mod.ts Normal file
View file

@ -0,0 +1,7 @@
export * from "./types/board.ts";
export * from "./types/section.ts";
export * from "./types/change.ts";
export * from "./logic/board.ts";
export * from "./logic/section.ts";
export * from "./logic/character.ts";

View file

@ -1,38 +0,0 @@
{
"name": "@textplace/core",
"version": "0.6.1",
"description": "The core logic of TextPlace.",
"license": "MIT",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/TextPlace/CoreTextPlace"
},
"bugs": {
"url": "https://github.com/TextPlace/CoreTextPlace/issues"
},
"scripts": {
"test": "vitest",
"build": "obuild",
"typecheck": "tsc --noEmit"
},
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/mod.d.ts",
"import": "./dist/mod.mjs"
}
},
"dependencies": {
"@std/cli": "jsr:^1.0.24"
},
"devDependencies": {
"obuild": "^0.4.3",
"prettier": "^3.7.2",
"typescript": "^5.9.3",
"vitest": "^4.0.14"
},
"packageManager": "pnpm@10.25.0"
}

1897
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
onlyBuiltDependencies:
- esbuild

36
scripts/build_npm.ts Normal file
View file

@ -0,0 +1,36 @@
import { build, emptyDir } from "@deno/dnt";
import denoJson from "../deno.json" with { type: "json" };
await emptyDir("./npm");
await build({
entryPoints: ["./mod.ts"],
outDir: "./npm",
shims: {
// see JS docs for overview and more options
deno: true,
},
compilerOptions: {
lib: ["ES2022"],
},
package: {
// package.json properties
name: "@textplace/core",
version: denoJson.version,
description: "The core logic of TextPlace.",
license: "MIT",
repository: {
type: "git",
url: "https://github.com/TextPlace/CoreTextPlace",
},
bugs: {
url: "https://github.com/TextPlace/CoreTextPlace/issues",
},
},
postBuild() {
// steps to run after building and before running the tests
Deno.copyFileSync("LICENSE", "npm/LICENSE");
Deno.copyFileSync("README.md", "npm/README.md");
},
});

View file

@ -1,55 +0,0 @@
import type { SectionData, SectionPosition } from "../types/section";
import type { BoardConfig, BoardData, CharacterPosition } from "../types/board";
import { applyChange, createSection } from "./section";
import type { BoardChange } from "../types/change";
export function createBoard(config: BoardConfig): BoardData {
return { config, sections: [] };
}
export function locateSection(
{ x, y }: CharacterPosition,
config: BoardConfig,
): SectionPosition {
return {
sx: Math.floor(x / config.sectionWidth),
sy: Math.floor(y / config.sectionHeight),
};
}
/**
* Get a section from board.
*
* If the section does not exist yet, it will be created and (optionally) added to `board`.
*/
export function getSectionOnBoard(
{ sx, sy }: SectionPosition,
board: BoardData,
options: {
/**
* Whether the section data is only used for reading.
*
* If `true`, this function will return an empty section configured with default values, but will not add it to the board data to save storage.
*/
readOnly: boolean;
} = { readOnly: false },
): SectionData {
if (!board.sections[sy]) {
if (options.readOnly) return createSection({ sx, sy }, board.config);
board.sections[sy] = [];
}
const row = board.sections[sy];
const existing = row[sx];
if (existing) return existing;
const section = createSection({ sx, sy }, board.config);
if (!options.readOnly) row[sx] = section;
return section;
}
export function applyChangeOnBoard(change: BoardChange, board: BoardData) {
const sPos = locateSection(change, board.config);
const section = getSectionOnBoard(sPos, board);
applyChange(change, section);
}

View file

@ -1,17 +0,0 @@
import { unicodeWidth } from "@std/cli/unicode-width";
const segmenter = /*#__PURE__*/ new Intl.Segmenter("en", {
granularity: "grapheme",
});
export function getCharacterWidth(ch: string): number {
const segments = [...segmenter.segment(ch)];
if (segments.length !== 1) {
throw new Error(
`Expected exactly one grapheme cluster, got ${segments.length}.`,
);
}
// TODO: Properly fix this.
return Math.min(unicodeWidth(ch), 2);
}

View file

@ -1,127 +0,0 @@
import type { BoardData, BoardRegion } from "../types/board";
import type { BoardRender } from "../types/render";
import { getSectionOnBoard, locateSection } from "./board";
export function render(data: BoardData): BoardRender {
const totalLineCount = data.config.sectionHeight * data.config.ySections;
const lineLength = data.config.sectionWidth * data.config.xSections;
const chLines: string[][] = Array(totalLineCount);
const colorLines: string[][] = Array(totalLineCount);
const bgColorLines: string[][] = Array(totalLineCount);
const widthLines: number[][] = Array(totalLineCount);
for (let y = 0; y < totalLineCount; y++) {
const chLine: string[] = [];
const colorLine: string[] = [];
const bgColorLine: string[] = [];
const widthLine: number[] = [];
let charsToSkip = 0;
for (let x = 0; x < lineLength; x++) {
if (charsToSkip > 0) {
charsToSkip--;
continue;
}
const sPos = locateSection({ x, y }, data.config);
const section = getSectionOnBoard(sPos, data, { readOnly: true });
const xInSection = x % data.config.sectionWidth;
const yInSection = y % data.config.sectionHeight;
const cCh = section.ch[yInSection]?.[xInSection] ?? " ";
const cCo = section.color[yInSection]?.[xInSection] ?? "";
const cBg = section.bgColor[yInSection]?.[xInSection] ?? "";
const cWd = section.width[yInSection]?.[xInSection] ?? 1;
chLine.push(cCh);
colorLine.push(cCo);
bgColorLine.push(cBg);
widthLine.push(cWd);
charsToSkip += cWd - 1;
}
chLines[y] = chLine;
colorLines[y] = colorLine;
bgColorLines[y] = bgColorLine;
widthLines[y] = widthLine;
}
return {
w: lineLength,
h: totalLineCount,
ch: chLines.flat(),
color: colorLines.flat(),
bg_color: bgColorLines.flat(),
width: widthLines.flat(),
};
}
export function cropRender(
render: BoardRender,
region: BoardRegion,
): BoardRender {
const ch: string[] = [];
const color: string[] = [];
const bg_color: string[] = [];
const width: number[] = [];
const regionEndX = region.x + region.width;
const regionEndY = region.y + region.height;
let srcIdx = 0;
let displayX = 0;
let displayY = 0;
while (srcIdx < render.ch.length) {
const cCh = render.ch[srcIdx];
const cCo = render.color[srcIdx];
const cBg = render.bg_color[srcIdx];
const cWd = render.width[srcIdx];
if (
typeof cCh !== "string" ||
typeof cCo !== "string" ||
typeof cBg !== "string" ||
typeof cWd !== "number"
) {
throw new Error("Invalid render data");
}
const charEndX = displayX + cWd;
if (displayY >= region.y && displayY < regionEndY) {
// Check if this character overlaps with the crop region horizontally
if (charEndX > region.x && displayX < regionEndX) {
// Clamp the width to fit within the crop region
const clampedStartX = Math.max(displayX, region.x);
const clampedEndX = Math.min(charEndX, regionEndX);
const clampedWidth = clampedEndX - clampedStartX;
ch.push(cCh);
color.push(cCo);
bg_color.push(cBg);
width.push(clampedWidth);
}
}
// Advance display position
displayX += cWd;
if (displayX >= render.w) {
displayX = 0;
displayY++;
}
srcIdx++;
}
return {
w: region.width,
h: region.height,
ch,
color,
bg_color,
width,
};
}

View file

@ -1,74 +0,0 @@
import { getCharacterWidth } from "../mod";
import type { BoardConfig } from "../types/board";
import type { BoardChange } from "../types/change";
import type { SectionData, SectionPosition } from "../types/section";
export function createSection(
{ sx, sy }: SectionPosition,
boardConfig: BoardConfig,
): SectionData {
if (boardConfig.sectionWidth % 2 !== 0) {
throw new Error(
"sectionWidth must be multiple of 2 (least common multiples of all character widths)",
);
}
const offsetX = sx * boardConfig.sectionWidth;
const offsetY = sy * boardConfig.sectionHeight;
const ch: string[][] = Array(boardConfig.sectionHeight)
.fill([])
.map(() => Array(boardConfig.sectionWidth).fill(boardConfig.defaultCh));
const color: string[][] = Array(boardConfig.sectionHeight)
.fill([])
.map(() => Array(boardConfig.sectionWidth).fill(boardConfig.defaultColor));
const bgColor: string[][] = Array(boardConfig.sectionHeight)
.fill([])
.map(() =>
Array(boardConfig.sectionWidth).fill(boardConfig.defaultBgColor),
);
const width: number[][] = Array(boardConfig.sectionHeight)
.fill([])
.map(() => Array(boardConfig.sectionWidth).fill(boardConfig.defaultWidth));
return { offsetX, offsetY, ch, color, bgColor, width };
}
export function applyChange(change: BoardChange, section: SectionData) {
const xInSection = change.x - section.offsetX;
const yInSection = change.y - section.offsetY;
const row0 = section.ch[0];
const validX =
xInSection >= 0 && row0 !== undefined && xInSection < row0.length;
const validY = yInSection >= 0 && yInSection < section.ch.length;
const chRow = section.ch[yInSection];
const widthRow = section.width[yInSection];
const colorRow = section.color[yInSection];
const bgColorRow = section.bgColor[yInSection];
const hasRowsForY =
chRow !== undefined &&
widthRow !== undefined &&
colorRow !== undefined &&
bgColorRow !== undefined;
if (!validX || !validY || !hasRowsForY) {
throw new Error("Change does not belong to this section");
}
if (change.ch) {
const chWidth = getCharacterWidth(change.ch);
const xCharacterOffset = xInSection % chWidth;
const offsetAdjustedXInSection = xInSection - xCharacterOffset;
chRow[offsetAdjustedXInSection] = change.ch;
widthRow[offsetAdjustedXInSection] = chWidth;
}
if (change.color) {
colorRow[xInSection] = change.color;
}
if (change.bg_color) {
bgColorRow[xInSection] = change.bg_color;
}
}

View file

@ -1,9 +0,0 @@
export * from "./types/board";
export * from "./types/change";
export * from "./types/render";
export * from "./types/section";
export * from "./logic/board";
export * from "./logic/character";
export * from "./logic/render";
export * from "./logic/section";

View file

@ -1,20 +0,0 @@
/**
* A compact form to represent a render of a board or a part of.
*
* Note that this form is not designed for manipulation. It's designed for transmission and rendering, and can not be converted back to `BoardData` as all "over-shadowed" characters are removed.
*/
export interface BoardRender {
/** The total width of the render, in display characters (`ch`). */
w: number;
/** The total height of the render, in `ch`. */
h: number;
/** Compact array of characters on board. */
ch: string[];
/** Compact array of color, for each character. */
color: string[];
/** Compact array of background color, for each character. */
bg_color: string[];
/** Compact array of width indicator for each character. */
width: number[];
}

View file

@ -1,14 +1,22 @@
import { describe, it, expect } from "vitest"; import {
assert,
assertEquals,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { createBoard, getSectionOnBoard } from "../src/logic/board"; import {
import type { BoardData } from "../src/types/board"; createBoard,
import { locateSection } from "../src/logic/board"; getSectionOnBoard,
import { applyChangeOnBoard } from "../src/logic/board"; renderFullBoard,
} from "../logic/board.ts";
import type { BoardData } from "../types/board.ts";
import { checkFullBoard } from "./checkFullBoard.ts";
import { locateSection } from "../logic/board.ts";
import { applyChangeOnBoard } from "../logic/board.ts";
describe("board", () => { Deno.test("board", async (t) => {
let board: BoardData | undefined; let board: BoardData | undefined;
it("createBoard", () => { await t.step("createBoard", () => {
board = createBoard({ board = createBoard({
xSections: 3, xSections: 3,
ySections: 3, ySections: 3,
@ -21,89 +29,96 @@ describe("board", () => {
}); });
// Sections are created on demand. // Sections are created on demand.
expect(board.sections.length).toBe(0); assertEquals(board.sections.length, 0);
}); });
it("locateSection", () => { await t.step("locateSection", () => {
expect(board).toBeDefined(); assert(board);
const { sx, sy } = locateSection({ x: 0, y: 0 }, board!.config); const { sx, sy } = locateSection({ x: 0, y: 0 }, board.config);
expect(sx).toBe(0); assertEquals(sx, 0);
expect(sy).toBe(0); assertEquals(sy, 0);
const { sx: sx2, sy: sy2 } = locateSection({ x: 4, y: 0 }, board!.config); const { sx: sx2, sy: sy2 } = locateSection({ x: 4, y: 0 }, board.config);
expect(sx2).toBe(1); assertEquals(sx2, 1);
expect(sy2).toBe(0); assertEquals(sy2, 0);
}); });
it("applyChangeOnBoard", () => { await t.step("applyChangeOnBoard", () => {
expect(board).toBeDefined(); assert(board);
applyChangeOnBoard({ x: 0, y: 0, ch: "A" }, board!); applyChangeOnBoard({ x: 0, y: 0, ch: "A" }, board);
applyChangeOnBoard({ x: 4, y: 0, ch: "B" }, board!); applyChangeOnBoard({ x: 4, y: 0, ch: "B" }, board);
applyChangeOnBoard({ x: 0, y: 3, ch: "C" }, board!); applyChangeOnBoard({ x: 0, y: 3, ch: "C" }, board);
applyChangeOnBoard({ x: 4, y: 3, ch: "D" }, board!); applyChangeOnBoard({ x: 4, y: 3, ch: "D" }, board);
applyChangeOnBoard({ x: 5, y: 3, ch: "E" }, board!); applyChangeOnBoard({ x: 5, y: 3, ch: "E" }, board);
expect(board!.sections[0]![0]!.ch[0]![0]).toBe("A"); assertEquals(board.sections[0][0].ch[0][0], "A");
expect(board!.sections[0]![1]!.ch[0]![0]).toBe("B"); assertEquals(board.sections[0][1].ch[0][0], "B");
expect(board!.sections[1]![0]!.ch[0]![0]).toBe("C"); assertEquals(board.sections[1][0].ch[0][0], "C");
expect(board!.sections[1]![1]!.ch[0]).toEqual(["D", "E", " ", " "]); assertEquals(board.sections[1][1].ch[0], ["D", "E", " ", " "]);
applyChangeOnBoard({ x: 0, y: 1, ch: "你" }, board!); applyChangeOnBoard({ x: 0, y: 1, ch: "你" }, board);
applyChangeOnBoard({ x: 4, y: 2, ch: "好" }, board!); applyChangeOnBoard({ x: 4, y: 2, ch: "好" }, board);
applyChangeOnBoard({ x: 0, y: 4, ch: "嗎" }, board!); applyChangeOnBoard({ x: 0, y: 4, ch: "嗎" }, board);
applyChangeOnBoard({ x: 4, y: 4, ch: "嘛" }, board!); applyChangeOnBoard({ x: 4, y: 4, ch: "嘛" }, board);
expect(board!.sections[0]![0]!.ch[1]![0]).toBe("你"); assertEquals(board.sections[0][0].ch[1][0], "你");
expect(board!.sections[0]![1]!.ch[2]![0]).toBe("好"); assertEquals(board.sections[0][1].ch[2][0], "好");
expect(board!.sections[1]![0]!.ch[1]![0]).toBe("嗎"); assertEquals(board.sections[1][0].ch[1][0], "嗎");
expect(board!.sections[1]![1]!.ch[1]).toEqual(["嘛", " ", " ", " "]); assertEquals(board.sections[1][1].ch[1], ["嘛", " ", " ", " "]);
applyChangeOnBoard({ x: 5, y: 4, ch: "啊" }, board!); applyChangeOnBoard({ x: 5, y: 4, ch: "啊" }, board);
expect(board!.sections[1]![1]!.ch[1]).toEqual(["啊", " ", " ", " "]); assertEquals(board.sections[1][1].ch[1], ["啊", " ", " ", " "]);
}); });
it("getSectionOnBoard: existing section", () => { await t.step("getSectionOnBoard: existing section", () => {
expect(board).toBeDefined(); assert(board);
const section = getSectionOnBoard({ sx: 1, sy: 1 }, board!, { const section = getSectionOnBoard({ sx: 1, sy: 1 }, board, {
readOnly: true, readOnly: true,
}); });
expect(section.ch[0]).toEqual(["D", "E", " ", " "]); assertEquals(section.ch[0], ["D", "E", " ", " "]);
expect(section.color[0]![0]).toBe("F"); assertEquals(section.color[0][0], "F");
expect(section.bgColor[0]![0]).toBe("0"); assertEquals(section.bgColor[0][0], "0");
expect(section.width[0]).toEqual([1, 1, 1, 1]); assertEquals(section.width[0], [1, 1, 1, 1]);
}); });
it("getSectionOnBoard: non-existing row", () => { await t.step("getSectionOnBoard: non-existing row", () => {
expect(board).toBeDefined(); assert(board);
const section = getSectionOnBoard({ sx: 1, sy: 2 }, board!, { const section = getSectionOnBoard({ sx: 1, sy: 2 }, board, {
readOnly: true, readOnly: true,
}); });
expect(section.ch[0]![0]).toBe(" "); assertEquals(section.ch[0][0], " ");
expect(section.color[0]![0]).toBe("F"); assertEquals(section.color[0][0], "F");
expect(section.bgColor[0]![0]).toBe("0"); assertEquals(section.bgColor[0][0], "0");
expect(section.width[0]![0]).toBe(1); assertEquals(section.width[0][0], 1);
}); });
it("getSectionOnBoard: non-existing section", () => { await t.step("getSectionOnBoard: non-existing section", () => {
expect(board).toBeDefined(); assert(board);
const section = getSectionOnBoard({ sx: 2, sy: 1 }, board!, { const section = getSectionOnBoard({ sx: 2, sy: 1 }, board, {
readOnly: true, readOnly: true,
}); });
expect(section.ch[0]![0]).toBe(" "); assertEquals(section.ch[0][0], " ");
expect(section.color[0]![0]).toBe("F"); assertEquals(section.color[0][0], "F");
expect(section.bgColor[0]![0]).toBe("0"); assertEquals(section.bgColor[0][0], "0");
expect(section.width[0]![0]).toBe(1); assertEquals(section.width[0][0], 1);
}); });
it("on-demand creation: only changed sections are saved", () => { await t.step("renderFullBoard", () => {
expect(board).toBeDefined(); assert(board);
expect(board!.sections[2]).toBeUndefined(); const rendered = renderFullBoard(board);
expect(board!.sections[0]![2]).toBeUndefined(); checkFullBoard(rendered);
});
await t.step("on-demand creation: only changed sections are saved", () => {
assert(board);
assertEquals(board.sections[2], undefined);
assertEquals(board.sections[0][2], undefined);
}); });
}); });

View file

@ -1,40 +1,39 @@
import { it, expect } from "vitest"; import {
assertEquals,
assertThrows,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { getCharacterWidth } from "../src/mod"; import { getCharacterWidth } from "../mod.ts";
it("getCharacterWidth ASCII", () => { Deno.test("getCharacterWidth ASCII", () => {
expect(getCharacterWidth("a")).toBe(1); assertEquals(getCharacterWidth("a"), 1);
expect(getCharacterWidth("A")).toBe(1); assertEquals(getCharacterWidth("A"), 1);
expect(getCharacterWidth("1")).toBe(1); assertEquals(getCharacterWidth("1"), 1);
expect(getCharacterWidth("@")).toBe(1); assertEquals(getCharacterWidth("@"), 1);
expect(getCharacterWidth(" ")).toBe(1); assertEquals(getCharacterWidth(" "), 1);
expect(() => getCharacterWidth("")).toThrow(); assertThrows(() => getCharacterWidth(""));
expect(() => getCharacterWidth("ab")).toThrow(); assertThrows(() => getCharacterWidth("ab"));
}); });
it("getCharacterWidth CJK", () => { Deno.test("getCharacterWidth CJK", () => {
expect(getCharacterWidth("你")).toBe(2); assertEquals(getCharacterWidth("你"), 2);
expect(getCharacterWidth("好")).toBe(2); assertEquals(getCharacterWidth("好"), 2);
expect(getCharacterWidth("吗")).toBe(2); assertEquals(getCharacterWidth("吗"), 2);
expect(getCharacterWidth("ガ")).toBe(2); assertEquals(getCharacterWidth("ガ"), 2);
expect(getCharacterWidth("ギ")).toBe(2); assertEquals(getCharacterWidth("ギ"), 2);
expect(getCharacterWidth("グ")).toBe(2); assertEquals(getCharacterWidth("グ"), 2);
expect(getCharacterWidth("ソ")).toBe(2); assertEquals(getCharacterWidth("ソ"), 2);
expect(getCharacterWidth("")).toBe(2); assertThrows(() => getCharacterWidth(""));
expect(getCharacterWidth("")).toBe(2); assertThrows(() => getCharacterWidth(""));
expect(() => getCharacterWidth("你好")).toThrow(); assertThrows(() => getCharacterWidth("你好"));
expect(() => getCharacterWidth("ヨスガノ")).toThrow(); assertThrows(() => getCharacterWidth("ヨスガノ"));
}); });
it("getCharacterWidth Emoji", () => { Deno.test("getCharacterWidth previously faulty cases", () => {
expect(getCharacterWidth("👋")).toBe(2); assertEquals(getCharacterWidth("𤲶"), 2);
expect(getCharacterWidth("🌲️")).toBe(2);
expect(getCharacterWidth("👨‍👩‍👧‍👦")).toBe(2);
});
it("getCharacterWidth previously faulty cases", () => { assertThrows(() => getCharacterWidth("𤲶"[0]));
expect(getCharacterWidth("𤲶")).toBe(2);
}); });

View file

@ -1,42 +1,30 @@
import { getCharacterWidth } from "../src/logic/character"; import { getCharacterWidth } from "../logic/character.ts";
import type { BoardRender } from "../src/types/render"; import type { FullBoard } from "../types/board.ts";
function isCorrectWidth(cWd: number, cCh: string): boolean {
return getCharacterWidth(cCh) === cWd;
}
function isValidColor(color: string): boolean { function isValidColor(color: string): boolean {
return /^[0-9A-F]$/.test(color); return /^[0-9A-F]$/.test(color);
} }
interface Options { export function checkFullBoard(board: FullBoard) {
/**
* Whether to allow the width of a character to be narrower than the "correct" width.
*
* For partial renders, some wide characters may be clipped to a narrower width. This option allows for that.
*/
allowsNarrowerWidth?: boolean;
}
export function checkBoardRender(render: BoardRender, options?: Options) {
let chLine = ""; let chLine = "";
let colorLine = ""; let colorLine = "";
let bgColorLine = ""; let bgColorLine = "";
let widthLine = ""; let widthLine = "";
let lines = 0; let lines = 0;
const ch = [...render.ch]; const ch = [...board.ch];
const chLength = ch.length; const chLength = ch.length;
let unsafeCurrentOffset = 0; let unsafeCurrentOffset = 0;
function isCorrectWidth(cWd: number, cCh: string): boolean {
const correctWidth = getCharacterWidth(cCh);
return options?.allowsNarrowerWidth
? cWd <= correctWidth && cWd > 0
: cWd === correctWidth;
}
for (let i = 0; i < chLength; i++) { for (let i = 0; i < chLength; i++) {
const cCh = ch[i]; const cCh = ch[i];
const cCo = render.color[i]; const cCo = board.color[i];
const cBg = render.bg_color[i]; const cBg = board.bg_color[i];
const cWd = render.width[i]; const cWd = board.width[i];
const printSituation = () => { const printSituation = () => {
console.error( console.error(
@ -80,7 +68,7 @@ export function checkBoardRender(render: BoardRender, options?: Options) {
widthLine += String(cWd).padEnd(cWd); widthLine += String(cWd).padEnd(cWd);
unsafeCurrentOffset += cCh.length; unsafeCurrentOffset += cCh.length;
if (colorLine.length === render.w) { if (colorLine.length === board.w) {
lines++; lines++;
chLine = ""; chLine = "";
colorLine = ""; colorLine = "";
@ -89,5 +77,5 @@ export function checkBoardRender(render: BoardRender, options?: Options) {
} }
} }
if (lines !== render.h) throw new Error("board height error"); if (lines !== board.h) throw new Error("board height error");
} }

View file

@ -1,307 +0,0 @@
import { describe, expect, it } from "vitest";
import { render, cropRender } from "../src/logic/render";
import { checkBoardRender } from "./checkBoardRender";
import { applyChangeOnBoard, createBoard } from "../src/logic/board";
import type { BoardData } from "../src/types/board";
import type { BoardRender } from "../src/types/render";
const board: BoardData = createBoard({
xSections: 1,
ySections: 1,
sectionWidth: 10,
sectionHeight: 5,
defaultCh: " ",
defaultColor: "F",
defaultBgColor: "0",
defaultWidth: 1,
});
// Add CJK characters (width 2)
applyChangeOnBoard({ x: 0, y: 0, ch: "你" }, board);
applyChangeOnBoard({ x: 2, y: 0, ch: "好" }, board);
applyChangeOnBoard({ x: 4, y: 0, ch: "世" }, board);
applyChangeOnBoard({ x: 6, y: 0, ch: "界" }, board);
// Add cell with non-default foreground color
applyChangeOnBoard({ x: 0, y: 1, ch: "A" }, board);
applyChangeOnBoard({ x: 0, y: 1, color: "C" }, board);
// Add cell with non-default background color
applyChangeOnBoard({ x: 2, y: 1, ch: "B" }, board);
applyChangeOnBoard({ x: 2, y: 1, bg_color: "A" }, board);
// Add cell with both non-default foreground and background colors
applyChangeOnBoard({ x: 4, y: 1, ch: "C" }, board);
applyChangeOnBoard({ x: 4, y: 1, color: "9" }, board);
applyChangeOnBoard({ x: 4, y: 1, bg_color: "E" }, board);
// Add CJK character with custom colors
applyChangeOnBoard({ x: 0, y: 2, ch: "中" }, board);
applyChangeOnBoard({ x: 0, y: 2, color: "D" }, board);
applyChangeOnBoard({ x: 0, y: 2, bg_color: "B" }, board);
describe("render", () => {
it("render", () => {
expect(board).toBeDefined();
const rendered = render(board!);
checkBoardRender(rendered);
});
});
describe("cropRender", () => {
// Helper to create a simple render for testing
function createTestRender(): BoardRender {
// Create a 10x5 board with:
// Row 0: "你好世界 " (4 CJK chars = 8 display cols + 2 spaces)
// Row 1: "A B C " (ASCII with spaces)
// Row 2: "中 " (1 CJK char + 8 spaces)
// Row 3: " " (all spaces)
// Row 4: " " (all spaces)
return render(board);
}
describe("basic cropping with width-1 characters", () => {
it("crops a region containing only width-1 characters", () => {
const rendered = createTestRender();
// Crop row 1 (ASCII chars), columns 0-5
const cropped = cropRender(rendered, { x: 0, y: 1, width: 5, height: 1 });
expect(cropped.w).toBe(5);
expect(cropped.h).toBe(1);
checkBoardRender(cropped);
});
it("crops middle section of width-1 characters", () => {
const rendered = createTestRender();
// Crop row 1, columns 1-4 (should get " B C")
const cropped = cropRender(rendered, { x: 1, y: 1, width: 4, height: 1 });
expect(cropped.w).toBe(4);
expect(cropped.h).toBe(1);
checkBoardRender(cropped);
});
});
describe("wide characters (width 2)", () => {
it("includes wide character entirely within crop region", () => {
const rendered = createTestRender();
// Crop row 0, columns 0-4 (should include "你好")
const cropped = cropRender(rendered, { x: 0, y: 0, width: 4, height: 1 });
expect(cropped.w).toBe(4);
expect(cropped.h).toBe(1);
expect(cropped.ch).toContain("你");
expect(cropped.ch).toContain("好");
checkBoardRender(cropped);
});
it("handles wide character at crop start boundary (character starts before region)", () => {
const rendered = createTestRender();
// Crop starting at x=1 - "你" starts at x=0 and extends to x=2
// The crop should include "你" with clamped width of 1
const cropped = cropRender(rendered, { x: 1, y: 0, width: 3, height: 1 });
expect(cropped.w).toBe(3);
expect(cropped.h).toBe(1);
// "你" should be included but with width clamped to 1 (only 1 col visible)
expect(cropped.ch).toContain("你");
checkBoardRender(cropped, { allowsNarrowerWidth: true });
});
it("handles wide character at crop end boundary (character extends beyond region)", () => {
const rendered = createTestRender();
// Crop ending at x=3 - "好" starts at x=2 and extends to x=4
// The crop should include "好" with clamped width of 1
const cropped = cropRender(rendered, { x: 0, y: 0, width: 3, height: 1 });
expect(cropped.w).toBe(3);
expect(cropped.h).toBe(1);
// "你" should be included with full width, "好" should be clamped
expect(cropped.ch).toContain("你");
expect(cropped.ch).toContain("好");
checkBoardRender(cropped, { allowsNarrowerWidth: true });
});
it("handles wide character with both boundaries clamped", () => {
const rendered = createTestRender();
// Crop from x=1 to x=2 (width 1) - only partial view of "你"
const cropped = cropRender(rendered, { x: 1, y: 0, width: 1, height: 1 });
expect(cropped.w).toBe(1);
expect(cropped.h).toBe(1);
checkBoardRender(cropped, { allowsNarrowerWidth: true });
});
it("excludes wide character entirely outside crop region", () => {
const rendered = createTestRender();
// Crop row 0, columns 8-10 (should only get spaces, no CJK)
const cropped = cropRender(rendered, { x: 8, y: 0, width: 2, height: 1 });
expect(cropped.w).toBe(2);
expect(cropped.h).toBe(1);
// Should only contain spaces
expect(cropped.ch.every((c) => c === " ")).toBe(true);
checkBoardRender(cropped);
});
});
describe("multi-row cropping", () => {
it("crops multiple rows correctly", () => {
const rendered = createTestRender();
// Crop rows 0-2, columns 0-4
const cropped = cropRender(rendered, { x: 0, y: 0, width: 4, height: 3 });
expect(cropped.w).toBe(4);
expect(cropped.h).toBe(3);
checkBoardRender(cropped);
});
it("crops rows from middle of board", () => {
const rendered = createTestRender();
// Crop rows 1-3, columns 2-6
const cropped = cropRender(rendered, { x: 2, y: 1, width: 4, height: 3 });
expect(cropped.w).toBe(4);
expect(cropped.h).toBe(3);
checkBoardRender(cropped);
});
it("handles wide characters across multiple rows", () => {
const rendered = createTestRender();
// Crop rows 0 and 2 which both have CJK characters
const cropped = cropRender(rendered, { x: 0, y: 0, width: 4, height: 3 });
expect(cropped.w).toBe(4);
expect(cropped.h).toBe(3);
expect(cropped.ch).toContain("你");
expect(cropped.ch).toContain("中");
checkBoardRender(cropped);
});
});
describe("full board crop", () => {
it("returns equivalent data when cropping entire board", () => {
const rendered = createTestRender();
const cropped = cropRender(rendered, {
x: 0,
y: 0,
width: rendered.w,
height: rendered.h,
});
expect(cropped.w).toBe(rendered.w);
expect(cropped.h).toBe(rendered.h);
expect(cropped.ch).toEqual(rendered.ch);
expect(cropped.color).toEqual(rendered.color);
expect(cropped.bg_color).toEqual(rendered.bg_color);
expect(cropped.width).toEqual(rendered.width);
checkBoardRender(cropped);
});
});
describe("edge cases", () => {
it("handles zero-width region", () => {
const rendered = createTestRender();
const cropped = cropRender(rendered, { x: 0, y: 0, width: 0, height: 1 });
expect(cropped.w).toBe(0);
expect(cropped.h).toBe(1);
expect(cropped.ch).toEqual([]);
});
it("handles zero-height region", () => {
const rendered = createTestRender();
const cropped = cropRender(rendered, { x: 0, y: 0, width: 5, height: 0 });
expect(cropped.w).toBe(5);
expect(cropped.h).toBe(0);
expect(cropped.ch).toEqual([]);
});
it("handles single cell crop", () => {
const rendered = createTestRender();
const cropped = cropRender(rendered, { x: 0, y: 1, width: 1, height: 1 });
expect(cropped.w).toBe(1);
expect(cropped.h).toBe(1);
expect(cropped.ch.length).toBe(1);
expect(cropped.ch[0]).toBe("A");
checkBoardRender(cropped);
});
it("handles crop at bottom-right corner", () => {
const rendered = createTestRender();
const cropped = cropRender(rendered, { x: 8, y: 4, width: 2, height: 1 });
expect(cropped.w).toBe(2);
expect(cropped.h).toBe(1);
checkBoardRender(cropped);
});
it("handles crop region starting beyond first row", () => {
const rendered = createTestRender();
const cropped = cropRender(rendered, { x: 0, y: 3, width: 5, height: 2 });
expect(cropped.w).toBe(5);
expect(cropped.h).toBe(2);
// Should only contain spaces (rows 3-4 are empty)
expect(cropped.ch.every((c) => c === " ")).toBe(true);
checkBoardRender(cropped);
});
});
describe("color preservation", () => {
it("preserves foreground color when cropping", () => {
const rendered = createTestRender();
// Crop to get cell with custom foreground color (A at 0,1 with color C)
const cropped = cropRender(rendered, { x: 0, y: 1, width: 1, height: 1 });
expect(cropped.color[0]).toBe("C");
checkBoardRender(cropped);
});
it("preserves background color when cropping", () => {
const rendered = createTestRender();
// Crop to get cell with custom background color (B at 2,1 with bg_color A)
const cropped = cropRender(rendered, { x: 2, y: 1, width: 1, height: 1 });
expect(cropped.bg_color[0]).toBe("A");
checkBoardRender(cropped);
});
it("preserves both colors on CJK character when cropping", () => {
const rendered = createTestRender();
// Crop to get CJK with custom colors (中 at 0,2 with color D, bg_color B)
const cropped = cropRender(rendered, { x: 0, y: 2, width: 2, height: 1 });
expect(cropped.ch[0]).toBe("中");
expect(cropped.color[0]).toBe("D");
expect(cropped.bg_color[0]).toBe("B");
checkBoardRender(cropped);
});
});
describe("consecutive wide characters", () => {
it("handles crop in middle of consecutive wide characters", () => {
const rendered = createTestRender();
// Row 0 has: 你(0-1)好(2-3)世(4-5)界(6-7)
// Crop from x=2 to x=6 should get 好世
const cropped = cropRender(rendered, { x: 2, y: 0, width: 4, height: 1 });
expect(cropped.w).toBe(4);
expect(cropped.ch).toContain("好");
expect(cropped.ch).toContain("世");
checkBoardRender(cropped);
});
it("handles crop splitting multiple wide characters", () => {
const rendered = createTestRender();
// Crop from x=1 to x=7 - should clip 你 at start and 界 at end
const cropped = cropRender(rendered, { x: 1, y: 0, width: 6, height: 1 });
expect(cropped.w).toBe(6);
checkBoardRender(cropped, { allowsNarrowerWidth: true });
});
});
});

View file

@ -1,13 +1,17 @@
import { describe, it, expect } from "vitest"; import {
assert,
assertEquals,
assertThrows,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { applyChange, createSection } from "../src/logic/section"; import { applyChange, createSection } from "../logic/section.ts";
import type { SectionData } from "../src/types/section"; import type { SectionData } from "../types/section.ts";
describe("section", () => { Deno.test("section", async (t) => {
let section: SectionData | undefined; let section: SectionData | undefined;
it("createSection non-lcm", () => { await t.step("createSection non-lcm", () => {
expect(() => { assertThrows(() => {
createSection( createSection(
{ sx: 0, sy: 0 }, { sx: 0, sy: 0 },
{ {
@ -21,10 +25,10 @@ describe("section", () => {
defaultWidth: 1, defaultWidth: 1,
}, },
); );
}).toThrow(); });
}); });
it("createSection non-origin section", () => { await t.step("createSection non-origin section", () => {
section = createSection( section = createSection(
{ sx: 1, sy: 1 }, { sx: 1, sy: 1 },
{ {
@ -39,11 +43,11 @@ describe("section", () => {
}, },
); );
expect(section.offsetX).toBe(4); assertEquals(section.offsetX, 4);
expect(section.offsetY).toBe(3); assertEquals(section.offsetY, 3);
}); });
it("createSection", () => { await t.step("createSection", () => {
section = createSection( section = createSection(
{ sx: 0, sy: 0 }, { sx: 0, sy: 0 },
{ {
@ -58,8 +62,8 @@ describe("section", () => {
}, },
); );
expect(section.offsetX).toBe(0); assertEquals(section.offsetX, 0);
expect(section.offsetY).toBe(0); assertEquals(section.offsetY, 0);
function assertSectionContent<T>( function assertSectionContent<T>(
content: T[][], content: T[][],
@ -67,11 +71,11 @@ describe("section", () => {
columnCount: number, columnCount: number,
value: T, value: T,
) { ) {
expect(content.length).toBe(rowCount); assertEquals(content.length, rowCount);
for (const row of content) { for (const row of content) {
expect(row.length).toBe(columnCount); assertEquals(row.length, columnCount);
for (const item of row) { for (const item of row) {
expect(item).toBe(value); assertEquals(item, value);
} }
} }
} }
@ -82,44 +86,44 @@ describe("section", () => {
assertSectionContent(section.width, 3, 4, 1); assertSectionContent(section.width, 3, 4, 1);
}); });
it("applyChange 1-width", () => { await t.step("applyChange 1-width", () => {
expect(section).toBeDefined(); assert(section);
applyChange({ x: 0, y: 0, ch: "t" }, section!); applyChange({ x: 0, y: 0, ch: "t" }, section);
expect(section!.ch[0]).toEqual(["t", " ", " ", " "]); assertEquals(section.ch[0], ["t", " ", " ", " "]);
expect(section!.ch[1]).toEqual([" ", " ", " ", " "]); assertEquals(section.ch[1], [" ", " ", " ", " "]);
expect(section!.width[0]).toEqual([1, 1, 1, 1]); assertEquals(section.width[0], [1, 1, 1, 1]);
}); });
it("applyChange 1-width at odd position", () => { await t.step("applyChange 1-width at odd position", () => {
expect(section).toBeDefined(); assert(section);
applyChange({ x: 1, y: 0, ch: "t" }, section!); applyChange({ x: 1, y: 0, ch: "t" }, section);
expect(section!.ch[0]).toEqual(["t", "t", " ", " "]); assertEquals(section.ch[0], ["t", "t", " ", " "]);
expect(section!.width[0]).toEqual([1, 1, 1, 1]); assertEquals(section.width[0], [1, 1, 1, 1]);
}); });
it("applyChange 2-width at a correct position", () => { await t.step("applyChange 2-width at a correct position", () => {
expect(section).toBeDefined(); assert(section);
applyChange({ x: 0, y: 0, ch: "あ" }, section!); applyChange({ x: 0, y: 0, ch: "あ" }, section);
expect(section!.ch[0]).toEqual(["あ", "t", " ", " "]); assertEquals(section.ch[0], ["あ", "t", " ", " "]);
expect(section!.width[0]).toEqual([2, 1, 1, 1]); assertEquals(section.width[0], [2, 1, 1, 1]);
}); });
it("applyChange 2-width at an alternate position", () => { await t.step("applyChange 2-width at an alternate position", () => {
expect(section).toBeDefined(); assert(section);
applyChange({ x: 1, y: 0, ch: "あ" }, section!); applyChange({ x: 1, y: 0, ch: "あ" }, section);
expect(section!.ch[0]).toEqual(["あ", "t", " ", " "]); assertEquals(section.ch[0], ["あ", "t", " ", " "]);
expect(section!.width[0]).toEqual([2, 1, 1, 1]); assertEquals(section.width[0], [2, 1, 1, 1]);
}); });
it("applyChange incorrect section", () => { await t.step("applyChange incorrect section", () => {
expect(section).toBeDefined(); assertThrows(() => {
assert(section);
expect(() => { applyChange({ x: 6, y: 3, ch: "あ" }, section);
applyChange({ x: 6, y: 3, ch: "あ" }, section!); });
}).toThrow();
}); });
}); });

View file

@ -1,44 +0,0 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "ES2022",
"target": "ES2022",
"types": [],
"moduleResolution": "bundler",
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
},
}

View file

@ -1,5 +1,26 @@
import type { SectionData } from "./section.ts"; import type { SectionData } from "./section.ts";
/**
* A compact form to represent the whole game board.
*
* Note that this form is not designed for manipulation. It's designed for transmission and rendering, and can not be converted back to `BoardData` as all "over-shadowed" characters are removed.
*/
export interface FullBoard {
/** The total width of the board, in display characters (`ch`). */
w: number;
/** The total height of the board, in `ch`. */
h: number;
/** Compact array of characters on board. */
ch: string[];
/** Compact array of color, for each character. */
color: string[];
/** Compact array of background color, for each character. */
bg_color: string[];
/** Compact array of width indicator for each character. */
width: number[];
}
/** /**
* A structure defining a character position on board. * A structure defining a character position on board.
* *
@ -12,14 +33,6 @@ export interface CharacterPosition {
y: number; y: number;
} }
/**
* A structure defining a region on board.
*/
export interface BoardRegion extends CharacterPosition {
width: number;
height: number;
}
export interface BoardConfig { export interface BoardConfig {
xSections: number; xSections: number;
ySections: number; ySections: number;