diff --git a/.zed/settings.json b/.zed/settings.json index 017a870..9425503 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -4,5 +4,10 @@ // see the documentation: https://zed.dev/docs/configuring-zed#folder-specific-settings { "formatter": "prettier", - "format_on_save": "on" + "format_on_save": "on", + "languages": { + "TypeScript": { + "language_servers": ["vtsls", "!deno"] + } + } } diff --git a/build.config.ts b/build.config.ts index fe36e73..0e77cd2 100644 --- a/build.config.ts +++ b/build.config.ts @@ -2,4 +2,4 @@ import { defineBuildConfig } from "obuild/config"; export default defineBuildConfig({ entries: ["src/mod.ts"], -}); +}) as ReturnType; diff --git a/package.json b/package.json index 08c9d71..d8cece0 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "@textplace/core", - "version": "0.5.0", + "version": "0.6.0", "description": "The core logic of TextPlace.", "license": "MIT", + "type": "module", "repository": { "type": "git", "url": "https://github.com/TextPlace/CoreTextPlace" @@ -12,7 +13,8 @@ }, "scripts": { "test": "vitest", - "build": "obuild" + "build": "obuild", + "typecheck": "tsc --noEmit" }, "files": [ "dist" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62094ec..070deb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,16 +13,16 @@ importers: version: '@jsr/std__cli@1.0.24' devDependencies: obuild: - specifier: ^0.4.8 + specifier: ^0.4.3 version: 0.4.8(typescript@5.9.3) prettier: - specifier: ^3.7.4 + specifier: ^3.7.2 version: 3.7.4 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: - specifier: ^4.0.15 + specifier: ^4.0.14 version: 4.0.15(jiti@2.6.1) packages: diff --git a/src/logic/board.ts b/src/logic/board.ts index 0436cdc..9d199c0 100644 --- a/src/logic/board.ts +++ b/src/logic/board.ts @@ -1,12 +1,7 @@ -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"; +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: [] }; @@ -39,15 +34,17 @@ export function getSectionOnBoard( 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]; + 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; } @@ -56,59 +53,3 @@ export function applyChangeOnBoard(change: BoardChange, board: BoardData) { const section = getSectionOnBoard(sPos, board); applyChange(change, section); } - -export function renderFullBoard(data: BoardData): FullBoard { - const totalLineCount = data.config.sectionHeight * data.config.ySections; - const lineLength = data.config.sectionWidth * data.config.xSections; - - const chLines: string[][] = Array(totalLineCount); - const colorLines: string[][] = Array(totalLineCount); - const bgColorLines: string[][] = Array(totalLineCount); - const widthLines: number[][] = Array(totalLineCount); - - for (let y = 0; y < totalLineCount; y++) { - const chLine: string[] = []; - const colorLine: string[] = []; - const bgColorLine: string[] = []; - const widthLine: number[] = []; - - let charsToSkip = 0; - - for (let x = 0; x < lineLength; x++) { - if (charsToSkip > 0) { - charsToSkip--; - continue; - } - - const sPos = locateSection({ x, y }, data.config); - const section = getSectionOnBoard(sPos, data, { readOnly: true }); - const xInSection = x % data.config.sectionWidth; - const yInSection = y % data.config.sectionHeight; - - const cCh = section.ch[yInSection][xInSection]; - const cCo = section.color[yInSection][xInSection]; - const cBg = section.bgColor[yInSection][xInSection]; - const cWd = section.width[yInSection][xInSection]; - - chLine.push(cCh); - colorLine.push(cCo); - bgColorLine.push(cBg); - widthLine.push(cWd); - charsToSkip += cWd - 1; - } - - chLines[y] = chLine; - colorLines[y] = colorLine; - bgColorLines[y] = bgColorLine; - widthLines[y] = widthLine; - } - - return { - w: lineLength, - h: totalLineCount, - ch: chLines.flat(), - color: colorLines.flat(), - bg_color: bgColorLines.flat(), - width: widthLines.flat(), - }; -} diff --git a/src/logic/render.ts b/src/logic/render.ts new file mode 100644 index 0000000..2eb72a4 --- /dev/null +++ b/src/logic/render.ts @@ -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, + }; +} diff --git a/src/logic/section.ts b/src/logic/section.ts index c3f976c..54a6d8b 100644 --- a/src/logic/section.ts +++ b/src/logic/section.ts @@ -1,7 +1,7 @@ -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"; +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, @@ -16,18 +16,20 @@ export function createSection( 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) - ); + 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 }; } @@ -36,9 +38,23 @@ 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 row0 = section.ch[0]; + const validX = + xInSection >= 0 && row0 !== undefined && xInSection < row0.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"); } @@ -46,13 +62,13 @@ export function applyChange(change: BoardChange, section: SectionData) { const chWidth = getCharacterWidth(change.ch); const xCharacterOffset = xInSection % chWidth; const offsetAdjustedXInSection = xInSection - xCharacterOffset; - section.ch[yInSection][offsetAdjustedXInSection] = change.ch; - section.width[yInSection][offsetAdjustedXInSection] = chWidth; + chRow[offsetAdjustedXInSection] = change.ch; + widthRow[offsetAdjustedXInSection] = chWidth; } if (change.color) { - section.color[yInSection][xInSection] = change.color; + colorRow[xInSection] = change.color; } if (change.bg_color) { - section.bgColor[yInSection][xInSection] = change.bg_color; + bgColorRow[xInSection] = change.bg_color; } } diff --git a/src/mod.ts b/src/mod.ts index 7b01c8a..5d0260e 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,7 +1,7 @@ -export * from "./types/board.ts"; -export * from "./types/section.ts"; -export * from "./types/change.ts"; +export * from "./types/board"; +export * from "./types/section"; +export * from "./types/change"; -export * from "./logic/board.ts"; -export * from "./logic/section.ts"; -export * from "./logic/character.ts"; +export * from "./logic/board"; +export * from "./logic/section"; +export * from "./logic/character"; diff --git a/src/types/board.ts b/src/types/board.ts index 4fa2ef7..9d553f0 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -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; diff --git a/src/types/render.ts b/src/types/render.ts new file mode 100644 index 0000000..ef7ec80 --- /dev/null +++ b/src/types/render.ts @@ -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[]; +} diff --git a/tests/board.test.ts b/tests/board.test.ts index cacbc2f..1169f8f 100644 --- a/tests/board.test.ts +++ b/tests/board.test.ts @@ -1,14 +1,9 @@ import { describe, it, expect } from "vitest"; -import { - createBoard, - getSectionOnBoard, - 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"; +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"; describe("board", () => { let board: BoardData | undefined; @@ -50,23 +45,23 @@ describe("board", () => { applyChangeOnBoard({ x: 4, y: 3, ch: "D" }, board!); applyChangeOnBoard({ x: 5, y: 3, ch: "E" }, board!); - 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", " ", " "]); + 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!); - 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(["嘛", " ", " ", " "]); + 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(["啊", " ", " ", " "]); + expect(board!.sections[1]![1]!.ch[1]).toEqual(["啊", " ", " ", " "]); }); it("getSectionOnBoard: existing section", () => { @@ -76,8 +71,8 @@ describe("board", () => { readOnly: true, }); expect(section.ch[0]).toEqual(["D", "E", " ", " "]); - expect(section.color[0][0]).toBe("F"); - expect(section.bgColor[0][0]).toBe("0"); + expect(section.color[0]![0]).toBe("F"); + expect(section.bgColor[0]![0]).toBe("0"); expect(section.width[0]).toEqual([1, 1, 1, 1]); }); @@ -87,10 +82,10 @@ describe("board", () => { const section = getSectionOnBoard({ sx: 1, sy: 2 }, board!, { readOnly: true, }); - 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); + 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); }); it("getSectionOnBoard: non-existing section", () => { @@ -99,23 +94,16 @@ describe("board", () => { const section = getSectionOnBoard({ sx: 2, sy: 1 }, board!, { readOnly: true, }); - 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); - }); - - it("renderFullBoard", () => { - expect(board).toBeDefined(); - - const rendered = renderFullBoard(board!); - checkFullBoard(rendered); + 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); }); it("on-demand creation: only changed sections are saved", () => { expect(board).toBeDefined(); expect(board!.sections[2]).toBeUndefined(); - expect(board!.sections[0][2]).toBeUndefined(); + expect(board!.sections[0]![2]).toBeUndefined(); }); }); diff --git a/tests/character.test.ts b/tests/character.test.ts index 1a8c493..05b675a 100644 --- a/tests/character.test.ts +++ b/tests/character.test.ts @@ -1,6 +1,6 @@ import { it, expect } from "vitest"; -import { getCharacterWidth } from "../src/mod.ts"; +import { getCharacterWidth } from "../src/mod"; it("getCharacterWidth ASCII", () => { expect(getCharacterWidth("a")).toBe(1); diff --git a/tests/checkFullBoard.ts b/tests/checkBoardRender.ts similarity index 61% rename from tests/checkFullBoard.ts rename to tests/checkBoardRender.ts index 954eceb..1a17ed2 100644 --- a/tests/checkFullBoard.ts +++ b/tests/checkBoardRender.ts @@ -1,30 +1,42 @@ -import { getCharacterWidth } from "../src/logic/character.ts"; -import type { FullBoard } from "../src/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( @@ -68,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 = ""; @@ -77,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"); } diff --git a/tests/render.test.ts b/tests/render.test.ts new file mode 100644 index 0000000..4174094 --- /dev/null +++ b/tests/render.test.ts @@ -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 }); + }); + }); +}); diff --git a/tests/section.test.ts b/tests/section.test.ts index 3240c0d..a9a5abe 100644 --- a/tests/section.test.ts +++ b/tests/section.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; -import { applyChange, createSection } from "../src/logic/section.ts"; -import type { SectionData } from "../src/types/section.ts"; +import { applyChange, createSection } from "../src/logic/section"; +import type { SectionData } from "../src/types/section"; describe("section", () => { let section: SectionData | undefined; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c83df2e --- /dev/null +++ b/tsconfig.json @@ -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, + }, +}