Compare commits

...

23 commits
0.3.0 ... main

Author SHA1 Message Date
8746ae1494 fix: mark intl call as pure
Some checks are pending
Test / test (push) Waiting to run
2025-12-16 18:14:37 +08:00
d53e627440 fix: expose render functions. 2025-12-16 17:09:59 +08:00
c703b1870a refactor: extract render entrypoint & add cropRender function 2025-12-16 16:41:30 +08:00
d97546f47c chore: bump deps 2025-12-15 22:08:45 +08:00
9d114182cf fix: Add --no-git-checks to pnpm publish step
Some checks failed
Test / test (push) Has been cancelled
2025-11-30 10:47:02 +08:00
0c901f0144 fix: Add repository checkout to publish NPM workflow 2025-11-30 10:37:44 +08:00
cfe0fb1514 fix: Use --allow-dirty for jsr publish
Allow publishing when package.json is modified earlier in the workflow
2025-11-30 10:33:38 +08:00
78898cc90b fix: Consolidate package metadata into deno.json 2025-11-30 10:31:58 +08:00
b51545ab39 fix: Strip JSR deps before publishing and add deno.json
JSR itself does not accept 'jsr:' dependencies in package.json, remove
them in the publish workflow before publishing. Add $schema to jsr.json.
2025-11-30 10:28:57 +08:00
34e710f036 fix: Update test import to src/logic/board.ts
Some checks are pending
Test / test (push) Waiting to run
2025-11-29 19:20:20 +08:00
6ae18c15b7 fix: Use pnpm and Node 24 for JSR publish 2025-11-29 19:19:17 +08:00
5d862c0bf7 fix: Require workflow_run success for publish job 2025-11-29 19:17:44 +08:00
bf976a8ceb chore: Add TypeScript devDependency and update build entry 2025-11-29 19:15:06 +08:00
4bcbd865d3 chore: Add packageManager field to package.json 2025-11-29 19:12:52 +08:00
32534084df refactor: Migrate project from Deno to pnpm/Node
Replace Deno configs and workflows with pnpm/Node tooling Add
package.json, jsr.json, build.config.ts and pnpm-lock.yaml Remove
deno.json, deno.lock, Deno build scripts and workflow Move source files
into src/ and update imports and tests to vitest Add Test CI workflow
and adapt publish jobs for pnpm/node Update editor settings, tasks,
.gitignore and bump LICENSE year
2025-11-29 19:11:49 +08:00
Shibo Lyu
7923680e80 fix: dirty fix for char width 2025-01-28 17:21:59 +08:00
Shibo Lyu
4dd8121ebb feat: support any unicode grapheme clusters. 2025-01-28 14:31:14 +08:00
Shibo Lyu
8940f26f17 fix: format 2024-12-29 23:14:43 +08:00
Shibo Lyu
97ad480ad8 0.3.2 2024-12-29 23:13:27 +08:00
Shibo Lyu
645b720222 chore: add test watch mode to zed command 2024-12-29 23:13:14 +08:00
Shibo Lyu
46a647441b fix: fill with reference 2024-12-29 23:13:00 +08:00
Shibo Lyu
587e0558e9 fix: 0.3.1 2024-12-29 22:43:36 +08:00
Shibo Lyu
91db478fe9 fix: applying 1-width at odd location 2024-12-29 22:32:59 +08:00
34 changed files with 2863 additions and 652 deletions

View file

@ -1,41 +0,0 @@
# 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
on:
workflow_run:
workflows: ["Deno"]
workflows: ["Test"]
types: [completed]
branches:
- "main"
@ -9,6 +9,7 @@ on:
jobs:
publish:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: read
@ -17,5 +18,36 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Publish package
run: npx jsr publish
- 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
# 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,18 +9,21 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Setup Deno
uses: denoland/setup-deno@v2
- name: Setup repo
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
deno-version: v2.x
run_install: false
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
with:
node-version: "22.x"
node-version: "24.x"
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
- run: deno run -A scripts/build_npm.ts
- run: cd npm && npm ci
- run: cd npm && npm publish --provenance --access public
- run: pnpm install
- run: pnpm build
- run: pnpm test
# --no-git-checks because of https://github.com/pnpm/pnpm/issues/5894.
- run: pnpm publish --provenance --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

32
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,32 @@
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,2 +1,3 @@
npm
.DS_Store
node_modules
dist

View file

@ -3,25 +3,11 @@
// 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
{
"formatter": "prettier",
"format_on_save": "on",
"languages": {
"TypeScript": {
"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", "-"]
}
}
"language_servers": ["vtsls", "!deno"]
}
},
"format_on_save": "on"
}
}

View file

@ -1,9 +1,6 @@
// Static tasks configuration.
//
// Example:
[
{
"label": "Test",
"command": "deno test"
"command": "pnpm test"
}
]

View file

@ -1,5 +1,5 @@
The MIT License (MIT)
Copyright © 2024 Shibo Lyu <hi@lao.sb>
Copyright © 2025 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:

5
build.config.ts Normal file
View file

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

View file

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

157
deno.lock generated
View file

@ -1,157 +0,0 @@
{
"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"
]
}
}

View file

@ -1,114 +0,0 @@
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: ([] as string[]).concat(...chLines).flat(),
color: ([] as string[]).concat(...colorLines).flat(),
bg_color: ([] as string[]).concat(...bgColorLines).flat(),
width: ([] as number[]).concat(...widthLines).flat(),
};
}

View file

@ -1,21 +0,0 @@
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;
}

View file

@ -1,58 +0,0 @@
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(
Array(boardConfig.sectionWidth).fill(boardConfig.defaultCh),
);
const color: string[][] = Array(boardConfig.sectionHeight).fill(
Array(boardConfig.sectionWidth).fill(boardConfig.defaultColor),
);
const bgColor: string[][] = Array(boardConfig.sectionHeight).fill(
Array(boardConfig.sectionWidth).fill(boardConfig.defaultBgColor),
);
const width: number[][] = Array(boardConfig.sectionHeight).fill(
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 % 2;
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
View file

@ -1,7 +0,0 @@
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";

38
package.json Normal file
View file

@ -0,0 +1,38 @@
{
"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 Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

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

View file

@ -1,36 +0,0 @@
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");
},
});

55
src/logic/board.ts Normal file
View file

@ -0,0 +1,55 @@
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);
}

17
src/logic/character.ts Normal file
View file

@ -0,0 +1,17 @@
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);
}

127
src/logic/render.ts Normal file
View file

@ -0,0 +1,127 @@
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,
};
}

74
src/logic/section.ts Normal file
View file

@ -0,0 +1,74 @@
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;
}
}

9
src/mod.ts Normal file
View file

@ -0,0 +1,9 @@
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,26 +1,5 @@
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.
*
@ -33,6 +12,14 @@ export interface CharacterPosition {
y: number;
}
/**
* A structure defining a region on board.
*/
export interface BoardRegion extends CharacterPosition {
width: number;
height: number;
}
export interface BoardConfig {
xSections: number;
ySections: number;

20
src/types/render.ts Normal file
View file

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

View file

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

View file

@ -1,30 +1,42 @@
import { getCharacterWidth } from "../logic/character.ts";
import type { FullBoard } from "../types/board.ts";
function isCorrectWidth(cWd: number, cCh: string): boolean {
return getCharacterWidth(cCh) === cWd;
}
import { getCharacterWidth } from "../src/logic/character";
import type { BoardRender } from "../src/types/render";
function isValidColor(color: string): boolean {
return /^[0-9A-F]$/.test(color);
}
export function checkFullBoard(board: FullBoard) {
interface Options {
/**
* 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 colorLine = "";
let bgColorLine = "";
let widthLine = "";
let lines = 0;
const ch = [...board.ch];
const ch = [...render.ch];
const chLength = ch.length;
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++) {
const cCh = ch[i];
const cCo = board.color[i];
const cBg = board.bg_color[i];
const cWd = board.width[i];
const cCo = render.color[i];
const cBg = render.bg_color[i];
const cWd = render.width[i];
const printSituation = () => {
console.error(
@ -47,6 +59,11 @@ export function checkFullBoard(board: FullBoard) {
console.error("width: ", widthLine);
};
if (typeof cCh !== "string") {
printSituation();
throw new Error("cCh is not string");
}
if (!isValidColor(cCo) || !isValidColor(cBg)) {
printSituation();
throw new Error("cCo or cBg is not valid");
@ -63,7 +80,7 @@ export function checkFullBoard(board: FullBoard) {
widthLine += String(cWd).padEnd(cWd);
unsafeCurrentOffset += cCh.length;
if (colorLine.length === board.w) {
if (colorLine.length === render.w) {
lines++;
chLine = "";
colorLine = "";
@ -72,5 +89,5 @@ export function checkFullBoard(board: FullBoard) {
}
}
if (lines !== board.h) throw new Error("board height error");
if (lines !== render.h) throw new Error("board height error");
}

307
tests/render.test.ts Normal file
View file

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

44
tsconfig.json Normal file
View file

@ -0,0 +1,44 @@
{
// 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,
},
}