Compare commits

..

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

19 changed files with 808 additions and 1243 deletions

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "@textplace/core", "name": "@textplace/core",
"version": "0.6.1", "version": "0.5.0",
"exports": "./src/mod.ts", "exports": "./src/mod.ts",
"imports": { "imports": {
"@std/cli": "jsr:@std/cli@1" "@std/cli": "jsr:@std/cli@1"

View file

@ -1,9 +1,8 @@
{ {
"name": "@textplace/core", "name": "@textplace/core",
"version": "0.6.1", "version": "0.5.0",
"description": "The core logic of TextPlace.", "description": "The core logic of TextPlace.",
"license": "MIT", "license": "MIT",
"type": "module",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/TextPlace/CoreTextPlace" "url": "https://github.com/TextPlace/CoreTextPlace"
@ -13,8 +12,7 @@
}, },
"scripts": { "scripts": {
"test": "vitest", "test": "vitest",
"build": "obuild", "build": "obuild"
"typecheck": "tsc --noEmit"
}, },
"files": [ "files": [
"dist" "dist"
@ -34,5 +32,5 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.14" "vitest": "^4.0.14"
}, },
"packageManager": "pnpm@10.25.0" "packageManager": "pnpm@10.23.0"
} }

1234
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,7 +1,12 @@
import type { SectionData, SectionPosition } from "../types/section"; import type { SectionData, SectionPosition } from "../types/section.ts";
import type { BoardConfig, BoardData, CharacterPosition } from "../types/board"; import type {
import { applyChange, createSection } from "./section"; BoardConfig,
import type { BoardChange } from "../types/change"; 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 { export function createBoard(config: BoardConfig): BoardData {
return { config, sections: [] }; return { config, sections: [] };
@ -34,17 +39,15 @@ export function getSectionOnBoard(
readOnly: boolean; readOnly: boolean;
} = { readOnly: false }, } = { readOnly: false },
): SectionData { ): SectionData {
if (!board.sections[sy]) { let section: SectionData;
if (options.readOnly) return createSection({ sx, sy }, board.config); if (!board.sections[sy] && !options.readOnly) board.sections[sy] = [];
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];
} }
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; return section;
} }
@ -53,3 +56,59 @@ export function applyChangeOnBoard(change: BoardChange, board: BoardData) {
const section = getSectionOnBoard(sPos, board); const section = getSectionOnBoard(sPos, board);
applyChange(change, section); 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(),
};
}

View file

@ -1,8 +1,6 @@
import { unicodeWidth } from "@std/cli/unicode-width"; import { unicodeWidth } from "@std/cli/unicode-width";
const segmenter = /*#__PURE__*/ new Intl.Segmenter("en", { const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
granularity: "grapheme",
});
export function getCharacterWidth(ch: string): number { export function getCharacterWidth(ch: string): number {
const segments = [...segmenter.segment(ch)]; const segments = [...segmenter.segment(ch)];

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,7 +1,7 @@
import { getCharacterWidth } from "../mod"; import { getCharacterWidth } from "../mod.ts";
import type { BoardConfig } from "../types/board"; import type { BoardConfig } from "../types/board.ts";
import type { BoardChange } from "../types/change"; import type { BoardChange } from "../types/change.ts";
import type { SectionData, SectionPosition } from "../types/section"; import type { SectionData, SectionPosition } from "../types/section.ts";
export function createSection( export function createSection(
{ sx, sy }: SectionPosition, { sx, sy }: SectionPosition,
@ -16,20 +16,18 @@ export function createSection(
const offsetX = sx * boardConfig.sectionWidth; const offsetX = sx * boardConfig.sectionWidth;
const offsetY = sy * boardConfig.sectionHeight; const offsetY = sy * boardConfig.sectionHeight;
const ch: string[][] = Array(boardConfig.sectionHeight) const ch: string[][] = Array(boardConfig.sectionHeight).fill([]).map(() =>
.fill([]) Array(boardConfig.sectionWidth).fill(boardConfig.defaultCh)
.map(() => Array(boardConfig.sectionWidth).fill(boardConfig.defaultCh)); );
const color: string[][] = Array(boardConfig.sectionHeight) const color: string[][] = Array(boardConfig.sectionHeight).fill([]).map(() =>
.fill([]) Array(boardConfig.sectionWidth).fill(boardConfig.defaultColor)
.map(() => Array(boardConfig.sectionWidth).fill(boardConfig.defaultColor)); );
const bgColor: string[][] = Array(boardConfig.sectionHeight) const bgColor: string[][] = Array(boardConfig.sectionHeight).fill([]).map(
.fill([]) () => Array(boardConfig.sectionWidth).fill(boardConfig.defaultBgColor),
.map(() => );
Array(boardConfig.sectionWidth).fill(boardConfig.defaultBgColor), const width: number[][] = Array(boardConfig.sectionHeight).fill([]).map(() =>
Array(boardConfig.sectionWidth).fill(boardConfig.defaultWidth)
); );
const width: number[][] = Array(boardConfig.sectionHeight)
.fill([])
.map(() => Array(boardConfig.sectionWidth).fill(boardConfig.defaultWidth));
return { offsetX, offsetY, ch, color, bgColor, width }; return { offsetX, offsetY, ch, color, bgColor, width };
} }
@ -38,23 +36,9 @@ export function applyChange(change: BoardChange, section: SectionData) {
const xInSection = change.x - section.offsetX; const xInSection = change.x - section.offsetX;
const yInSection = change.y - section.offsetY; const yInSection = change.y - section.offsetY;
const row0 = section.ch[0]; const validX = xInSection >= 0 && xInSection < section.ch[0].length;
const validX =
xInSection >= 0 && row0 !== undefined && xInSection < row0.length;
const validY = yInSection >= 0 && yInSection < section.ch.length; const validY = yInSection >= 0 && yInSection < section.ch.length;
if (!validX || !validY) {
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"); throw new Error("Change does not belong to this section");
} }
@ -62,13 +46,13 @@ export function applyChange(change: BoardChange, section: SectionData) {
const chWidth = getCharacterWidth(change.ch); const chWidth = getCharacterWidth(change.ch);
const xCharacterOffset = xInSection % chWidth; const xCharacterOffset = xInSection % chWidth;
const offsetAdjustedXInSection = xInSection - xCharacterOffset; const offsetAdjustedXInSection = xInSection - xCharacterOffset;
chRow[offsetAdjustedXInSection] = change.ch; section.ch[yInSection][offsetAdjustedXInSection] = change.ch;
widthRow[offsetAdjustedXInSection] = chWidth; section.width[yInSection][offsetAdjustedXInSection] = chWidth;
} }
if (change.color) { if (change.color) {
colorRow[xInSection] = change.color; section.color[yInSection][xInSection] = change.color;
} }
if (change.bg_color) { if (change.bg_color) {
bgColorRow[xInSection] = change.bg_color; section.bgColor[yInSection][xInSection] = change.bg_color;
} }
} }

View file

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

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;

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,9 +1,14 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
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 "../src/logic/board.ts";
import type { BoardData } from "../src/types/board.ts";
import { checkFullBoard } from "./checkFullBoard.ts";
import { locateSection } from "../src/logic/board.ts";
import { applyChangeOnBoard } from "../src/logic/board.ts";
describe("board", () => { describe("board", () => {
let board: BoardData | undefined; let board: BoardData | undefined;
@ -45,23 +50,23 @@ describe("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"); expect(board!.sections[0][0].ch[0][0]).toBe("A");
expect(board!.sections[0]![1]!.ch[0]![0]).toBe("B"); expect(board!.sections[0][1].ch[0][0]).toBe("B");
expect(board!.sections[1]![0]!.ch[0]![0]).toBe("C"); expect(board!.sections[1][0].ch[0][0]).toBe("C");
expect(board!.sections[1]![1]!.ch[0]).toEqual(["D", "E", " ", " "]); expect(board!.sections[1][1].ch[0]).toEqual(["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("你"); expect(board!.sections[0][0].ch[1][0]).toBe("你");
expect(board!.sections[0]![1]!.ch[2]![0]).toBe("好"); expect(board!.sections[0][1].ch[2][0]).toBe("好");
expect(board!.sections[1]![0]!.ch[1]![0]).toBe("嗎"); expect(board!.sections[1][0].ch[1][0]).toBe("嗎");
expect(board!.sections[1]![1]!.ch[1]).toEqual(["嘛", " ", " ", " "]); expect(board!.sections[1][1].ch[1]).toEqual(["嘛", " ", " ", " "]);
applyChangeOnBoard({ x: 5, y: 4, ch: "啊" }, board!); applyChangeOnBoard({ x: 5, y: 4, ch: "啊" }, board!);
expect(board!.sections[1]![1]!.ch[1]).toEqual(["啊", " ", " ", " "]); expect(board!.sections[1][1].ch[1]).toEqual(["啊", " ", " ", " "]);
}); });
it("getSectionOnBoard: existing section", () => { it("getSectionOnBoard: existing section", () => {
@ -71,8 +76,8 @@ describe("board", () => {
readOnly: true, readOnly: true,
}); });
expect(section.ch[0]).toEqual(["D", "E", " ", " "]); expect(section.ch[0]).toEqual(["D", "E", " ", " "]);
expect(section.color[0]![0]).toBe("F"); expect(section.color[0][0]).toBe("F");
expect(section.bgColor[0]![0]).toBe("0"); expect(section.bgColor[0][0]).toBe("0");
expect(section.width[0]).toEqual([1, 1, 1, 1]); expect(section.width[0]).toEqual([1, 1, 1, 1]);
}); });
@ -82,10 +87,10 @@ describe("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(" "); expect(section.ch[0][0]).toBe(" ");
expect(section.color[0]![0]).toBe("F"); expect(section.color[0][0]).toBe("F");
expect(section.bgColor[0]![0]).toBe("0"); expect(section.bgColor[0][0]).toBe("0");
expect(section.width[0]![0]).toBe(1); expect(section.width[0][0]).toBe(1);
}); });
it("getSectionOnBoard: non-existing section", () => { it("getSectionOnBoard: non-existing section", () => {
@ -94,16 +99,23 @@ describe("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(" "); expect(section.ch[0][0]).toBe(" ");
expect(section.color[0]![0]).toBe("F"); expect(section.color[0][0]).toBe("F");
expect(section.bgColor[0]![0]).toBe("0"); expect(section.bgColor[0][0]).toBe("0");
expect(section.width[0]![0]).toBe(1); expect(section.width[0][0]).toBe(1);
});
it("renderFullBoard", () => {
expect(board).toBeDefined();
const rendered = renderFullBoard(board!);
checkFullBoard(rendered);
}); });
it("on-demand creation: only changed sections are saved", () => { it("on-demand creation: only changed sections are saved", () => {
expect(board).toBeDefined(); expect(board).toBeDefined();
expect(board!.sections[2]).toBeUndefined(); expect(board!.sections[2]).toBeUndefined();
expect(board!.sections[0]![2]).toBeUndefined(); expect(board!.sections[0][2]).toBeUndefined();
}); });
}); });

View file

@ -1,6 +1,6 @@
import { it, expect } from "vitest"; import { it, expect } from "vitest";
import { getCharacterWidth } from "../src/mod"; import { getCharacterWidth } from "../src/mod.ts";
it("getCharacterWidth ASCII", () => { it("getCharacterWidth ASCII", () => {
expect(getCharacterWidth("a")).toBe(1); expect(getCharacterWidth("a")).toBe(1);

View file

@ -1,42 +1,30 @@
import { getCharacterWidth } from "../src/logic/character"; import { getCharacterWidth } from "../src/logic/character.ts";
import type { BoardRender } from "../src/types/render"; import type { FullBoard } from "../src/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,7 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { applyChange, createSection } from "../src/logic/section"; import { applyChange, createSection } from "../src/logic/section.ts";
import type { SectionData } from "../src/types/section"; import type { SectionData } from "../src/types/section.ts";
describe("section", () => { describe("section", () => {
let section: SectionData | undefined; let section: SectionData | undefined;

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,
},
}