From 7766112ed588a58cef31431465fb808ccc4f2f8e Mon Sep 17 00:00:00 2001 From: ahaoboy <504595380@qq.com> Date: Tue, 6 Aug 2024 21:04:25 +0800 Subject: [PATCH 1/4] add solid --- flex/src/dom.ts | 32 +++--- package.json | 21 +--- pnpm-workspace.yaml | 3 +- react/src/examples/cmd.tsx | 2 +- react/src/examples/echo.tsx | 2 +- react/src/examples/input.tsx | 2 +- react/src/examples/ls.tsx | 6 +- react/src/examples/move-box.tsx | 2 +- react/src/examples/readline.tsx | 2 +- react/src/examples/snake.tsx | 2 +- react/src/ui/box.tsx | 3 +- solid/build.ts | 19 ++++ solid/esbuild-plugin-solid.ts | 121 ++++++++++++++++++++ solid/package.json | 44 ++++++++ solid/src/examples/base.tsx | 5 + solid/src/examples/cmd.tsx | 42 +++++++ solid/src/examples/counter.tsx | 24 ++++ solid/src/examples/echo.tsx | 12 ++ solid/src/examples/fill-box.tsx | 42 +++++++ solid/src/examples/fill.tsx | 35 ++++++ solid/src/examples/flex.tsx | 19 ++++ solid/src/examples/index.tsx | 38 +++++++ solid/src/examples/input.tsx | 20 ++++ solid/src/examples/life.tsx | 97 ++++++++++++++++ solid/src/examples/ls.tsx | 96 ++++++++++++++++ solid/src/examples/move-box.tsx | 47 ++++++++ solid/src/examples/move.tsx | 25 +++++ solid/src/examples/readline.tsx | 21 ++++ solid/src/examples/snake.tsx | 192 ++++++++++++++++++++++++++++++++ solid/src/hook/index.ts | 1 + solid/src/hook/input.ts | 55 +++++++++ solid/src/index.ts | 3 + solid/src/render/canvas.ts | 44 ++++++++ solid/src/render/flex.ts | 125 +++++++++++++++++++++ solid/src/render/index.ts | 2 + solid/src/render/reconciler.tsx | 142 +++++++++++++++++++++++ solid/src/ui/box.tsx | 11 ++ solid/src/ui/index.ts | 1 + solid/tsconfig.build.json | 8 ++ solid/tsconfig.json | 7 ++ tsconfig.base.json | 1 + 41 files changed, 1335 insertions(+), 41 deletions(-) create mode 100644 solid/build.ts create mode 100644 solid/esbuild-plugin-solid.ts create mode 100644 solid/package.json create mode 100644 solid/src/examples/base.tsx create mode 100644 solid/src/examples/cmd.tsx create mode 100644 solid/src/examples/counter.tsx create mode 100644 solid/src/examples/echo.tsx create mode 100644 solid/src/examples/fill-box.tsx create mode 100644 solid/src/examples/fill.tsx create mode 100644 solid/src/examples/flex.tsx create mode 100644 solid/src/examples/index.tsx create mode 100644 solid/src/examples/input.tsx create mode 100644 solid/src/examples/life.tsx create mode 100644 solid/src/examples/ls.tsx create mode 100644 solid/src/examples/move-box.tsx create mode 100644 solid/src/examples/move.tsx create mode 100644 solid/src/examples/readline.tsx create mode 100644 solid/src/examples/snake.tsx create mode 100644 solid/src/hook/index.ts create mode 100644 solid/src/hook/input.ts create mode 100644 solid/src/index.ts create mode 100644 solid/src/render/canvas.ts create mode 100644 solid/src/render/flex.ts create mode 100644 solid/src/render/index.ts create mode 100644 solid/src/render/reconciler.tsx create mode 100644 solid/src/ui/box.tsx create mode 100644 solid/src/ui/index.ts create mode 100644 solid/tsconfig.build.json create mode 100644 solid/tsconfig.json diff --git a/flex/src/dom.ts b/flex/src/dom.ts index d251518..b830f6e 100644 --- a/flex/src/dom.ts +++ b/flex/src/dom.ts @@ -172,29 +172,27 @@ export function appendChildNode( } export function insertBeforeNode( + parent: D, node: D, - newChildNode: D, - beforeChildNode: D, -): void { - if (newChildNode.parentNode) { - removeChildNode(newChildNode.parentNode, newChildNode) + anchor: D, +) { + if (node.parentNode) { + removeChildNode(node.parentNode, node) } - newChildNode.parentNode = node + node.parentNode = parent - const index = node.childNodes.indexOf(beforeChildNode) + const index = parent.childNodes.indexOf(anchor) if (index >= 0) { - node.childNodes.splice(index, 0, newChildNode) + parent.childNodes.splice(index, 0, node) return } - node.childNodes.push(newChildNode) + parent.childNodes.push(node) + return parent.childNodes } -export function removeChildNode( - node: D, - removeNode: D, -): void { +export function removeChildNode(node: D, removeNode: D) { removeNode.parentNode = undefined const index = node.childNodes.indexOf(removeNode) @@ -211,3 +209,11 @@ export function setAttribute( // @ts-ignore node.attributes[key] = value } + +export function getNextSibling(node: D) { + if (!node || !node.parentNode) return + const childNodes = node.parentNode.childNodes + const i = childNodes.indexOf(node) + if (i < 0 || i >= childNodes.length) return + return childNodes[i + 1] +} diff --git a/package.json b/package.json index 9579ed3..82f3245 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,7 @@ "name": "r-tui", "version": "0.0.0", "private": true, - "files": [ - "dist", - "readme.md", - "package.json" - ], + "files": ["dist", "readme.md", "package.json"], "description": "react terminal UI", "scripts": { "case-police": "case-police \"**/*.ts\" fix", @@ -23,12 +19,7 @@ "release": "pnpm publish -r --access public", "release-alpha": "pnpm publish -r --access public --tag alpha" }, - "keywords": [ - "TS", - "React", - "r-tui", - "terminal" - ], + "keywords": ["TS", "React", "r-tui", "terminal"], "author": "ahaoboy", "license": "MIT", "devDependencies": { @@ -46,10 +37,10 @@ "case-police": "^0.6.1", "core-js": "^3.37.1", "dotenv": "^16.4.5", - "esbuild": "^0.19.12", "tsx": "^4.16.0", "typescript": "^5.5.2", - "vitest": "^1.6.0" - }, - "packageManager": "pnpm@8.15.8+sha256.691fe176eea9a8a80df20e4976f3dfb44a04841ceb885638fe2a26174f81e65e" + "vitest": "^1.6.0", + "esbuild": "^0.23.0", + "esbuild-plugin-solid": "^0.6.0" + } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 906ece0..6b602db 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,4 +3,5 @@ packages: - 'canvas' - 'share' - 'terminal' - - 'flex' \ No newline at end of file + - 'flex' + - 'solid' \ No newline at end of file diff --git a/react/src/examples/cmd.tsx b/react/src/examples/cmd.tsx index 0b6f858..49bf8b7 100644 --- a/react/src/examples/cmd.tsx +++ b/react/src/examples/cmd.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react" import { Box } from "../" -import { useReadLine } from "../hook/input" +import { useReadLine } from "../hook" import { spawn } from "node:child_process" export default function App() { diff --git a/react/src/examples/echo.tsx b/react/src/examples/echo.tsx index a279fc6..aeb65fc 100644 --- a/react/src/examples/echo.tsx +++ b/react/src/examples/echo.tsx @@ -1,6 +1,6 @@ import React from "react" import { Box } from "../" -import { useReadLine } from "../hook/input" +import { useReadLine } from "../hook" export default function App() { const data = useReadLine() diff --git a/react/src/examples/input.tsx b/react/src/examples/input.tsx index 4f48c94..7c4d8ff 100644 --- a/react/src/examples/input.tsx +++ b/react/src/examples/input.tsx @@ -1,6 +1,6 @@ import React from "react" import { Box } from "../" -import { useInput } from "../hook/input" +import { useInput } from "../hook" export default function App() { const key = useInput() diff --git a/react/src/examples/ls.tsx b/react/src/examples/ls.tsx index ea37f75..d95af19 100644 --- a/react/src/examples/ls.tsx +++ b/react/src/examples/ls.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from "react" -import { Box, render } from "../" +import { Box } from "../" import child_process from "node:child_process" -import { Down, Enter, Tab, Up, offInput, onInput } from "../hook/input" +import { Down, Enter, Tab, Up, offInput, onInput } from "../hook" type Info = { name: string @@ -96,5 +96,3 @@ export default function App() { ) } - -render() diff --git a/react/src/examples/move-box.tsx b/react/src/examples/move-box.tsx index 869038f..dc291c4 100644 --- a/react/src/examples/move-box.tsx +++ b/react/src/examples/move-box.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react" import { Box } from "../" -import { Down, Left, Right, Up, onInput } from "../hook/input" +import { Down, Left, Right, Up, onInput } from "../hook" export default function App() { const [x, setX] = useState(0) diff --git a/react/src/examples/readline.tsx b/react/src/examples/readline.tsx index c07727c..395a34a 100644 --- a/react/src/examples/readline.tsx +++ b/react/src/examples/readline.tsx @@ -1,6 +1,6 @@ import React from "react" import { Box } from "../" -import { useReadLine } from "../hook/input" +import { useReadLine } from "../hook" export default function App() { const data = useReadLine() diff --git a/react/src/examples/snake.tsx b/react/src/examples/snake.tsx index 8bfc3e5..9093528 100644 --- a/react/src/examples/snake.tsx +++ b/react/src/examples/snake.tsx @@ -1,7 +1,7 @@ import { choice, Color } from "@r-tui/share" import React, { useEffect, useRef, useState } from "react" import { Box } from "../ui" -import { onInput } from "../hook/input" +import { onInput } from "../hook" import { getTerminalShape } from "@r-tui/terminal" const initSnakeLen = 10 diff --git a/react/src/ui/box.tsx b/react/src/ui/box.tsx index 627c980..ea2c951 100644 --- a/react/src/ui/box.tsx +++ b/react/src/ui/box.tsx @@ -1,7 +1,6 @@ import React from "react" import { forwardRef } from "react" -import type { TDom, TDomProps } from "../render/flex" -import type { BaseDomProps } from "@r-tui/flex" +import type { TDom } from "../render/flex" export const Box = forwardRef< TDom, diff --git a/solid/build.ts b/solid/build.ts new file mode 100644 index 0000000..bf9e1ed --- /dev/null +++ b/solid/build.ts @@ -0,0 +1,19 @@ +import { build } from "esbuild" +import { solidPlugin } from "./esbuild-plugin-solid" + +build({ + entryPoints: ["./src/examples/index.tsx"], + bundle: true, + outdir: "bundle", + // TODO: solid not support node platform? + // platform:"node", + external: ["fs", "node:child_process"], + plugins: [ + solidPlugin({ + solid: { + moduleName: "@r-tui/solid", + generate: "universal", + }, + }), + ], +}).catch(() => process.exit(1)) diff --git a/solid/esbuild-plugin-solid.ts b/solid/esbuild-plugin-solid.ts new file mode 100644 index 0000000..a9910c0 --- /dev/null +++ b/solid/esbuild-plugin-solid.ts @@ -0,0 +1,121 @@ +import { Plugin } from "esbuild" +import { parse } from "node:path" +import { readFile } from "node:fs/promises" +// @ts-ignore +import { transformAsync, TransformOptions } from "@babel/core" +// @ts-ignore +import solid from "babel-preset-solid" +// @ts-ignore +import ts from "@babel/preset-typescript" + +// These options are partly taken from vite-plugin-solid: + +/** Configuration options for esbuild-plugin-solid */ +export interface Options { + /** The options to use for @babel/preset-typescript @default {} */ + typescript?: object + /** + * Pass any additional babel transform options. They will be merged with + * the transformations required by Solid. + * + * @default {} + */ + babel?: + | TransformOptions + | ((source: string, id: string, ssr: boolean) => TransformOptions) + | ((source: string, id: string, ssr: boolean) => Promise) + /** + * Pass any additional [babel-plugin-jsx-dom-expressions](https://github.com/ryansolid/dom-expressions/tree/main/packages/babel-plugin-jsx-dom-expressions#plugin-options). + * They will be merged with the defaults sets by [babel-preset-solid](https://github.com/solidjs/solid/blob/main/packages/babel-preset-solid/index.js#L8-L25). + * + * @default {} + */ + solid?: { + /** + * The name of the runtime module to import the methods from. + * + * @default "solid-js/web" + */ + moduleName?: string + + /** + * The output mode of the compiler. + * Can be: + * - "dom" is standard output + * - "ssr" is for server side rendering of strings. + * - "universal" is for using custom renderers from solid-js/universal + * + * @default "dom" + */ + generate?: "ssr" | "dom" | "universal" + + /** + * Indicate whether the output should contain hydratable markers. + * + * @default false + */ + hydratable?: boolean + + /** + * Boolean to indicate whether to enable automatic event delegation on camelCase. + * + * @default true + */ + delegateEvents?: boolean + + /** + * Boolean indicates whether smart conditional detection should be used. + * This optimizes simple boolean expressions and ternaries in JSX. + * + * @default true + */ + wrapConditionals?: boolean + + /** + * Boolean indicates whether to set current render context on Custom Elements and slots. + * Useful for seemless Context API with Web Components. + * + * @default true + */ + contextToCustomElements?: boolean + + /** + * Array of Component exports from module, that aren't included by default with the library. + * This plugin will automatically import them if it comes across them in the JSX. + * + * @default ["For","Show","Switch","Match","Suspense","SuspenseList","Portal","Index","Dynamic","ErrorBoundary"] + */ + builtIns?: string[] + } +} + +export function solidPlugin(options?: Options): Plugin { + return { + name: "esbuild:solid", + + setup(build) { + build.onLoad({ filter: /\.(t|j)sx$/ }, async (args) => { + const source = await readFile(args.path, { encoding: "utf-8" }) + + const { name, ext } = parse(args.path) + const filename = name + ext + + const result = await transformAsync(source, { + presets: [ + [solid, options?.solid ?? {}], + [ts, options?.typescript ?? {}], + ], + filename, + sourceMaps: "inline", + ...(options?.babel ?? {}), + }) + + if (result?.code === undefined || result.code === null) { + throw new Error("No result was provided from Babel") + } + + return { contents: result.code, loader: "js" } + }) + }, + } +} diff --git a/solid/package.json b/solid/package.json new file mode 100644 index 0000000..7c3c020 --- /dev/null +++ b/solid/package.json @@ -0,0 +1,44 @@ +{ + "name": "@r-tui/solid", + "version": "0.1.0", + "description": "@r-tui/solid", + "main": "dist/index.js", + "types": "dist/index.d.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "module": "./dist/index.js" + }, + "./examples": { + "types": "./dist/examples/index.d.ts", + "import": "./dist/examples/index.js", + "module": "./dist/examples/index.js" + } + }, + "scripts": { + "bundle": "tsx ./build.ts", + "build": "tsc -p ./tsconfig.build.json", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@r-tui/canvas": "workspace:*", + "@r-tui/flex": "workspace:*", + "@r-tui/share": "workspace:*", + "@r-tui/terminal": "workspace:*", + "e-color": "^0.1.3", + "lodash-es": "^4.17.21", + "@babel/core": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@r-tui/solid": "workspace:*", + "babel-preset-solid": "^1.8.19", + "solid-js": "^1.8.19" + }, + "devDependencies": {}, + "peerDependencies": { + "solid-js": "^1.8.19" + } +} diff --git a/solid/src/examples/base.tsx b/solid/src/examples/base.tsx new file mode 100644 index 0000000..53a3ca6 --- /dev/null +++ b/solid/src/examples/base.tsx @@ -0,0 +1,5 @@ +import { Box } from "@r-tui/solid" + +export default function App() { + return +} diff --git a/solid/src/examples/cmd.tsx b/solid/src/examples/cmd.tsx new file mode 100644 index 0000000..b481756 --- /dev/null +++ b/solid/src/examples/cmd.tsx @@ -0,0 +1,42 @@ +import { Box, effect } from "@r-tui/solid" +import { useReadLine } from "../hook" +import { spawn } from "node:child_process" +import { createSignal } from "solid-js" + +export default function App() { + const data = useReadLine() + const [output, setOutput] = createSignal("") + + effect(() => { + if (!data()) { + return + } + const list = data()?.split(" ") || [] + if (!list.length) { + return + } + const [exe, ...args] = list + const cmd = spawn(exe, args) + cmd.stdout.on("data", (s) => { + setOutput(s.toString().trim()) + }) + }) + + return ( + + {!!data()?.length && ( + + )} + {!!output()?.length && ( + + )} + + + ) +} diff --git a/solid/src/examples/counter.tsx b/solid/src/examples/counter.tsx new file mode 100644 index 0000000..c18ef57 --- /dev/null +++ b/solid/src/examples/counter.tsx @@ -0,0 +1,24 @@ +import { Box } from "@r-tui/solid" +import { createSignal } from "solid-js" + +export default function App() { + const [count, setCount] = createSignal(0) + setInterval(() => { + setCount((c) => ++c) + }, 1000) + + return ( + + + + + + ) +} diff --git a/solid/src/examples/echo.tsx b/solid/src/examples/echo.tsx new file mode 100644 index 0000000..c9f5d77 --- /dev/null +++ b/solid/src/examples/echo.tsx @@ -0,0 +1,12 @@ +import { Box } from "@r-tui/solid" +import { useReadLine } from "../hook" + +export default function App() { + const data = useReadLine() + return ( + + {!!data()?.length && } + + + ) +} diff --git a/solid/src/examples/fill-box.tsx b/solid/src/examples/fill-box.tsx new file mode 100644 index 0000000..df4c538 --- /dev/null +++ b/solid/src/examples/fill-box.tsx @@ -0,0 +1,42 @@ +import { Box } from "@r-tui/solid" +import { getTerminalShape } from "@r-tui/terminal" +import { createSignal } from "solid-js" + +export default function FillBox() { + const { width, height } = getTerminalShape() + const [count, setCount] = createSignal(0) + setInterval(() => { + setCount((c) => c + 1) + }, 1000) + + return ( + + {Array(width * height) + .fill(0) + .map((_, k) => { + const x = k % width + const y = ((k - x) / width) | 0 + const text = (x + y) & 1 ? " " : "█" + return ( + + ) + })} + + ) +} diff --git a/solid/src/examples/fill.tsx b/solid/src/examples/fill.tsx new file mode 100644 index 0000000..3d22337 --- /dev/null +++ b/solid/src/examples/fill.tsx @@ -0,0 +1,35 @@ +import { Box } from "@r-tui/solid" +import { getTerminalShape } from "@r-tui/terminal" + +export default function App() { + const { width, height } = getTerminalShape() + return ( + + {Array(width * height) + .fill(0) + .map((_, k) => { + const x = k % width + const y = ((k - x) / width) | 0 + return ( + + ) + })} + + ) +} diff --git a/solid/src/examples/flex.tsx b/solid/src/examples/flex.tsx new file mode 100644 index 0000000..867af35 --- /dev/null +++ b/solid/src/examples/flex.tsx @@ -0,0 +1,19 @@ +import React from "react" +import { Box } from "@r-tui/solid" + +export default function App() { + return ( + + + + + + ) +} diff --git a/solid/src/examples/index.tsx b/solid/src/examples/index.tsx new file mode 100644 index 0000000..14be70d --- /dev/null +++ b/solid/src/examples/index.tsx @@ -0,0 +1,38 @@ +import Base from "./base" +import Counter from "./counter" +import Echo from "./echo" +import Cmd from "./cmd" +import FillBox from "./fill-box" +import Fill from "./fill" +import Flex from "./flex" +import Input from "./input" +import Life from "./life" +import Readline from "./readline" +import Move from "./move" +import MoveBox from "./move-box" +import Snake from "./snake" +import Ls from "./ls" + +export const Example = { + Base, + Counter, + Echo, + Cmd, + FillBox, + Fill, + Flex, + Input, + Life, + Readline, + Move, + MoveBox, + Snake, + Ls, +} + +import { render, Box, TDom, createTDom, effect } from "@r-tui/solid" +render(() => , { + write: (s) => { + console.log(s.trimEnd()) + }, +}) diff --git a/solid/src/examples/input.tsx b/solid/src/examples/input.tsx new file mode 100644 index 0000000..48f4b3a --- /dev/null +++ b/solid/src/examples/input.tsx @@ -0,0 +1,20 @@ +import { Box } from "@r-tui/solid" +import { useInput } from "../hook" + +export default function App() { + const key = useInput() + return ( + + + + + + ) +} diff --git a/solid/src/examples/life.tsx b/solid/src/examples/life.tsx new file mode 100644 index 0000000..e4e8d6e --- /dev/null +++ b/solid/src/examples/life.tsx @@ -0,0 +1,97 @@ +import { getTerminalShape } from "@r-tui/terminal" +import { Box } from "@r-tui/solid" +import { createSignal } from "solid-js" + +const dir = [ + [-1, -1], + [-1, 0], + [-1, 1], + [0, -1], + [0, 1], + [1, -1], + [1, 0], + [1, 1], +] + +function getCells( + width: number, + height: number, + fill: () => boolean, +): boolean[][] { + const v = new Array(height) + .fill(0) + .map(() => new Array(width).fill(0).map(fill)) + return v +} + +export default function Life() { + const { width, height } = getTerminalShape() + const [cells, setCells] = createSignal( + getCells(width, height, () => Math.random() > 0.9), + ) + + const render = () => { + const newCells: boolean[][] = JSON.parse(JSON.stringify(cells())) + + for (let x = 0; x < width; x++) { + for (let y = 0; y < height; y++) { + let n = 0 + const v = cells()[y][x] + + for (const [dx, dy] of dir) { + const nx = x + dx + const ny = y + dy + + if (nx < 0 || ny < 0 || nx >= width || ny >= height) { + continue + } + + if (cells()[ny][nx]) { + n++ + } + } + + if (v) { + if (n < 2) { + newCells[y][x] = false + } else if (n <= 3) { + newCells[y][x] = true + } else if (n > 3) { + newCells[y][x] = false + } else { + newCells[y][x] = false + } + } else { + if (n === 3) { + newCells[y][x] = true + } else { + newCells[y][x] = false + } + } + } + } + + setCells(newCells) + } + setInterval(render, 1000) + + return ( + + {new Array(width * height).fill(0).map((_, k) => { + const x = k % width + const y = (k - x) / width + const char = cells()[y][x] ? "█" : "" + return ( + + ) + })} + + ) +} diff --git a/solid/src/examples/ls.tsx b/solid/src/examples/ls.tsx new file mode 100644 index 0000000..44849ea --- /dev/null +++ b/solid/src/examples/ls.tsx @@ -0,0 +1,96 @@ +import { Box } from "@r-tui/solid" +import child_process from "node:child_process" +import { Down, Enter, Tab, Up, offInput, onInput } from "../hook" +import { createSignal } from "solid-js" + +type Info = { + name: string + dir: boolean +} +function getDirInfo(path = "."): Info[] { + const s = child_process.execFileSync("ls", ["-p", path]).toString().trim() + const list = s.split("\n").map((name) => { + const dir = name.endsWith("/") + return { + name: name.replace("/", ""), + dir, + } + }) + return list +} + +export default function App() { + const [root, setRoot] = createSignal(["."]) + const currentPath = root().join("/") + const [select, setSelect] = createSignal(0) + const [dirInfo, setDirInfo] = createSignal([]) + const [fileInfo, setFileInfo] = createSignal([]) + const line = "-".repeat( + [...dirInfo(), ...fileInfo()] + .map((i) => i.name.length) + .reduce((a, b) => Math.max(a, b), 0), + ) + function init(root: string[]) { + setRoot(root) + const currentPath = root.join("/") + const info = getDirInfo(currentPath) + setDirInfo([{ name: "..", dir: true }, ...info.filter((i) => i.dir)]) + setFileInfo(info.filter((i) => !i.dir)) + } + const update = (key: string) => { + if (!dirInfo().length) { + return + } + switch (key) { + case "w": + case Up: { + setSelect((select() - 1 + dirInfo().length) % dirInfo().length) + break + } + case "s": + case Tab: + case Down: { + setSelect((select() + 1) % dirInfo().length) + break + } + case " ": + case Enter: { + const name = dirInfo()[select()].name + if (name === "..") { + if (root.length > 1) { + root().pop() + } + } else { + root().push(dirInfo()[select()].name) + } + init([...root()]) + setSelect(0) + break + } + } + } + init(root()) + onInput(update) + + return ( + + + + {dirInfo().map((i, k) => ( + + ))} + {fileInfo().map((i) => ( + + ))} + + ) +} diff --git a/solid/src/examples/move-box.tsx b/solid/src/examples/move-box.tsx new file mode 100644 index 0000000..f114777 --- /dev/null +++ b/solid/src/examples/move-box.tsx @@ -0,0 +1,47 @@ +import { Box } from "@r-tui/solid" +import { Down, Left, Right, Up, onInput } from "../hook" +import { createSignal } from "solid-js" + +export default function App() { + const [x, setX] = createSignal(0) + const [y, setY] = createSignal(0) + + onInput((key) => { + if (!key) { + return + } + switch (key) { + case "a": + case Left: { + setX((x) => x - 1) + break + } + case Right: + case "d": { + setX((x) => x + 1) + break + } + case Up: + case "w": { + setY((y) => y - 1) + break + } + case Down: + case "s": { + setY((y) => y + 1) + break + } + } + }) + + return ( + + + + ) +} diff --git a/solid/src/examples/move.tsx b/solid/src/examples/move.tsx new file mode 100644 index 0000000..ca5fb3c --- /dev/null +++ b/solid/src/examples/move.tsx @@ -0,0 +1,25 @@ +import { Box } from "@r-tui/solid" +import { createSignal } from "solid-js" + +export default function App() { + const [count, setCount] = createSignal(0) + setInterval(() => { + setCount((c) => ++c) + }, 100) + + return ( + + + + ) +} diff --git a/solid/src/examples/readline.tsx b/solid/src/examples/readline.tsx new file mode 100644 index 0000000..b515dc6 --- /dev/null +++ b/solid/src/examples/readline.tsx @@ -0,0 +1,21 @@ +import React from "react" +import { Box } from "@r-tui/solid" +import { useReadLine } from "../hook" + +export default function App() { + const data = useReadLine() + return ( + + + + + + ) +} diff --git a/solid/src/examples/snake.tsx b/solid/src/examples/snake.tsx new file mode 100644 index 0000000..e20a8ba --- /dev/null +++ b/solid/src/examples/snake.tsx @@ -0,0 +1,192 @@ +import { choice, Color } from "@r-tui/share" +import { Box } from "@r-tui/solid" +import { onInput } from "../hook" +import { getTerminalShape } from "@r-tui/terminal" +import { createSignal } from "solid-js" + +const initSnakeLen = 10 +const speed = 100 + +const snakeColor = "yellow" +const snakeHeadColor = "red" +const foodColor = "green" + +const keyMap = [ + ["w", 0], + ["a", 1], + ["s", 2], + ["d", 3], + ["UP", 0], + ["LEFT", 1], + ["DOWN", 2], + ["RIGHT", 3], +] as const + +const directionMap = [ + [0, -1], + [-1, 0], + [0, 1], + [1, 0], +] as const + +type SnakeBody = { x: number; y: number; type: "head" | "body" }[] +class Snake { + body: SnakeBody = [] + // 0 top, 1 left, 2 down, 3 right + direction = 0 + grow = 0 + + addHead(x: number, y: number) { + this.body.push({ x, y, type: "head" }) + } + + addBody(x: number, y: number) { + this.body.push({ x, y, type: "body" }) + } + + move(): boolean { + const newBody: SnakeBody = [] + + const [dx, dy] = directionMap[this.direction] + + const newHead = { + x: this.body[0]!.x + dx, + y: this.body[0].y + dy, + type: "head", + } as const + + newBody.push(newHead) + + for (const { x, y } of this.body.slice(0, -1)) { + if (x === newHead.x && y === newHead.y) { + return false + } + + newBody.push({ + x, + y, + type: "body", + }) + } + + const tail = this.body[this.body.length - 1] + if (this.grow > 0) { + this.grow-- + newBody.push({ + x: tail.x, + y: tail.y, + type: "body", + }) + + if (tail.x === newHead.x && tail.y === newHead.y) { + return false + } + } + + this.body = newBody + return true + } +} + +const snake = new Snake() + +export default function SnakeGame() { + const { width: w, height: h } = getTerminalShape() + const [body, setBody] = createSignal(snake.body) + const [food, setFood] = createSignal({ x: -1, y: -1 }) + const cellSize = 1 + const yCount = (h / cellSize) | 0 + const xCount = (w / cellSize) | 0 + const offsetX = (w - xCount * cellSize) / 2 + + const createFood = () => { + const list: { x: number; y: number }[] = [] + for (let x = 0; x < xCount; x++) { + for (let y = 0; y < yCount; y++) { + if (body().findIndex((i) => i.x === x && i.y === y) >= 0) { + continue + } + list.push({ x, y }) + } + } + setFood(choice(list)!) + } + + const update = () => { + const safe = snake.move() + + if (snake.body[0].x === food().x && snake.body[0].y === food().y) { + snake.grow++ + createFood() + } + + if (safe) { + setBody([...snake.body]) + } else { + // console.log("you lose") + } + } + + snake.body = [] + snake.direction = 3 + snake.grow = 0 + snake.addHead(initSnakeLen - 1, 0) + + for (let i = initSnakeLen - 2; i >= 0; i--) { + snake.addBody(i, 0) + } + + onInput((key) => { + if (!key) return + + const d = keyMap.find((i) => i[0] === key) + + if (d) { + snake.direction = d[1] + } + }) + + const handle = setInterval(update, speed) + createFood() + setBody(snake.body) + + return ( + + {Array(xCount * yCount) + .fill(0) + .map((_, k) => { + const x = k % xCount + const y = (k / xCount) | 0 + let color: Color = "default" + if (body().length) { + if (x === body()[0].x && y === body()[0].y) { + color = snakeHeadColor + } else if (body().find((i) => i.x === x && i.y === y)) { + color = snakeColor + } else if (x === food().x && y === food().y) { + color = foodColor + } + } + + return ( + + ) + })} + + ) +} diff --git a/solid/src/hook/index.ts b/solid/src/hook/index.ts new file mode 100644 index 0000000..be91c8e --- /dev/null +++ b/solid/src/hook/index.ts @@ -0,0 +1 @@ +export * from "./input" diff --git a/solid/src/hook/input.ts b/solid/src/hook/input.ts new file mode 100644 index 0000000..c44835b --- /dev/null +++ b/solid/src/hook/input.ts @@ -0,0 +1,55 @@ +import { createSignal } from "solid-js" + +let init = false +const inputHandleSet = new Set<(s: string) => void>() + +export const Enter = "\x0d" +export const CtrlC = "\x03" +export const Left = "\u001b[D" +export const Up = "\u001b[A" +export const Right = "\u001b[C" +export const Down = "\u001b[B" +export const Tab = "\t" + +function initMode() { + if (init) return + init = true + process.stdin.setRawMode(true) + function handleReadable() { + let s: string | null + // biome-ignore lint/suspicious/noAssignInExpressions: + while ((s = process.stdin.read()) !== null) { + const str = s.toString() + if (str === CtrlC) { + process.exit() + } + for (const fn of inputHandleSet) { + fn(str) + } + } + } + process.stdin.addListener("readable", handleReadable) +} + +export function onInput(handle: (s: string) => void) { + initMode() + inputHandleSet.add(handle) +} +export function offInput(handle: (s: string) => void) { + initMode() + inputHandleSet.delete(handle) +} +export function useInput() { + initMode() + const [key, setKey] = createSignal() + onInput(setKey) + return key +} + +export function useReadLine() { + const [data, setData] = createSignal() + process.stdin.addListener("data", (s) => { + setData(s.toString().trim()) + }) + return data +} diff --git a/solid/src/index.ts b/solid/src/index.ts new file mode 100644 index 0000000..8e4d01b --- /dev/null +++ b/solid/src/index.ts @@ -0,0 +1,3 @@ +export * from "./hook" +export * from "./ui" +export * from "./render" diff --git a/solid/src/render/canvas.ts b/solid/src/render/canvas.ts new file mode 100644 index 0000000..d78f499 --- /dev/null +++ b/solid/src/render/canvas.ts @@ -0,0 +1,44 @@ +import { type Canvas, Pixel } from "@r-tui/canvas" +import type { TDom } from "../render/flex" + +export function drawNode(canvas: Canvas, node: TDom, x: number, y: number) { + let px = x + let py = y + const w = canvas.shape.width + const h = canvas.shape.height + + for (const c of node.attributes.text || "") { + if (px < 0 || py < 0 || px >= w || py >= h) { + continue + } + + if (c === "\n") { + py++ + px = 0 + continue + } + + // TODO: support \t + if (c === "\t") { + px += 2 + continue + } + + const p = new Pixel( + c, + px, + py, + node.attributes.zIndex, + node.attributes.color, + node.attributes.backgroundColor, + // node.attributes.bold + ) + canvas.pixels[py][px].push(p) + if (px + 1 === w) { + px = 0 + py++ + } else { + px++ + } + } +} diff --git a/solid/src/render/flex.ts b/solid/src/render/flex.ts new file mode 100644 index 0000000..958d93f --- /dev/null +++ b/solid/src/render/flex.ts @@ -0,0 +1,125 @@ +import { Canvas } from "@r-tui/canvas" +import { type BaseDom, Flex, LayoutNode, BaseMouseEvent } from "@r-tui/flex" +import { Color, getStringShape, type Shape } from "@r-tui/share" +import { drawNode } from "./canvas" +import throttle from "lodash-es/throttle" +import { getTerminalShape } from "@r-tui/terminal" +import { defaultFPS } from "./reconciler" + +export const BoxName = "@r-tui/box" +export const RootName = "@r-tui/root" +export const TextName = "@r-tui/text" + +export interface TDomAttrs { + color?: Color + backgroundColor?: Color +} + +export interface TDomProps { + nodeName: string +} + +export interface TDom extends BaseDom {} + +export type RenderConfig = { + enableMouseMoveEvent: boolean + fps: number + trim: boolean + shape: Shape + write: (s: string) => void +} + +export function createTDom(nodeName = BoxName): TDom { + return { + attributes: {}, + layoutNode: new LayoutNode(), + parentNode: undefined, + childNodes: [], + props: { nodeName }, + } +} +export class TFlex extends Flex { + fps = 0 + trim = false + canvas: Canvas + write: (s: string) => void + shape: Shape + constructor(config: Partial = {}) { + super() + const { + fps = defaultFPS, + trim = false, + shape = getTerminalShape(), + write = process.stdout.write, + } = config + this.fps = fps + this.trim = trim + this.shape = shape + this.canvas = new Canvas(shape) + this.write = write + } + customCreateMouseEvent( + node: BaseDom | undefined, + x: number, + y: number, + hover: boolean, + event: {}, + ): BaseMouseEvent { + throw new Error("Method not implemented.") + } + customIsWheelDown(e: BaseMouseEvent): boolean { + throw new Error("Method not implemented.") + } + customIsWheelUp(e: BaseMouseEvent): boolean { + throw new Error("Method not implemented.") + } + customIsMousePress(e: BaseMouseEvent): boolean { + throw new Error("Method not implemented.") + } + customIsMouseDown(e: BaseMouseEvent): boolean { + throw new Error("Method not implemented.") + } + customIsMouseUp(e: BaseMouseEvent): boolean { + throw new Error("Method not implemented.") + } + renderToConsole: () => void = throttle( + () => { + console.clear() + this.canvas.clear() + this.rerender() + const s = this.canvas.toAnsi(this.trim) + this.write(s) + }, + 1000 / defaultFPS, + { + trailing: true, + leading: true, + }, + ) + customIsRootNode(node: TDom): boolean { + return node.props?.nodeName === RootName + } + customCreateRootNode(): TDom { + return createTDom(RootName) + } + customRenderNode(node: TDom): void { + const { + props: { nodeName } = {}, + } = node + if (nodeName === RootName) { + return + } + const { x, y } = node.layoutNode + drawNode(this.canvas, node, x | 0, y | 0) + } + customMeasureNode(node: TDom): Shape { + const text = node.attributes.text || "" + return getStringShape(text) + } + customComputeZIndex( + node: TDom, + zIndex: number, + currentRenderCount: number, + deep: number, + ): void {} +} diff --git a/solid/src/render/index.ts b/solid/src/render/index.ts new file mode 100644 index 0000000..dbd52af --- /dev/null +++ b/solid/src/render/index.ts @@ -0,0 +1,2 @@ +export * from "./reconciler" +export * from "./flex" diff --git a/solid/src/render/reconciler.tsx b/solid/src/render/reconciler.tsx new file mode 100644 index 0000000..4dc1da1 --- /dev/null +++ b/solid/src/render/reconciler.tsx @@ -0,0 +1,142 @@ +import { + appendChildNode, + getNextSibling, + insertBeforeNode, + removeChildNode, +} from "@r-tui/flex" +import { RenderConfig, TFlex, TextName, createTDom } from "./flex" +import { createRenderer } from "solid-js/universal" +import { TDom } from "./flex" +import fs from "node:fs" +import { Shape } from "@r-tui/share" +import { JSXElement } from "solid-js" + +const log = (...args: any[]) => { + // console.log(`[RENDERER] `, ...args) +} + +// const flex = new TFlex({ +// write: s => { +// // console.log(s.slice(100)) +// fs.writeFileSync("./a.log", s) +// process.stdout.write(s) +// } +// }) + +let flex: TFlex + +const { + render: _render, + effect, + memo, + createComponent, + createElement, + createTextNode, + insertNode, + insert, + spread, + setProp, + mergeProps, +} = createRenderer({ + createElement(nodeName: string): any { + log("creating element", nodeName) + return createTDom(nodeName) + }, + createTextNode(value: string): any { + log("createTextNode") + throw new Error("not support text node") + }, + replaceText(textNode: TDom, value: string) { + log("replaceText", value) + textNode.attributes.text = value + }, + setProperty(node: TDom, name: string, value: any) { + log("setProperty", node.attributes.id, name, value) + // @ts-ignore + node.attributes[name] = value + flex.renderToConsole() + }, + insertNode(parent: TDom, node: TDom, anchor: TDom) { + log( + "insertNode", + parent.attributes.id, + node.attributes.id, + node.childNodes[0]?.attributes?.id, + ) + insertBeforeNode(parent, node, anchor) + }, + isTextNode(node: TDom) { + log("isTextNode") + return node.props.nodeName === TextName + }, + removeNode(parent: TDom, node: TDom) { + log("removeNode", node) + removeChildNode(parent, node) + }, + getParentNode(node: TDom) { + log("getParentNode", node) + return node.parentNode + }, + getFirstChild(node: TDom) { + log("getFirstChild", node) + return node.childNodes[0] + }, + getNextSibling(node: TDom) { + log("getNextSibling", node) + return getNextSibling(node) as TDom + }, +}) + +// Forward Solid control flow +export { + For, + Show, + Suspense, + SuspenseList, + Switch, + Match, + Index, + ErrorBoundary, +} from "solid-js" + +export const defaultFPS = 30 + +function render(code: () => any, config: Partial = {}) { + flex = new TFlex(config) + const { attributes, layoutNode } = flex.rootNode + const { width, height } = flex.canvas.shape + attributes.id = "@rtui-root" + attributes.width = width + attributes.height = height + attributes.position = "relative" + attributes.color = undefined + attributes.backgroundColor = undefined + attributes.display = "flex" + attributes.padding = 0 + attributes.borderSize = 0 + attributes.x = 0 + attributes.y = 0 + attributes.zIndex = 0 + layoutNode.x = 0 + layoutNode.y = 0 + layoutNode.width = width + layoutNode.height = height + layoutNode.padding = 0 + layoutNode.border = 0 + + _render(code, flex.rootNode) +} + +export { + render, + effect, + memo, + createComponent, + createElement, + createTextNode, + insertNode, + insert, + spread, + setProp, + mergeProps, +} diff --git a/solid/src/ui/box.tsx b/solid/src/ui/box.tsx new file mode 100644 index 0000000..9f5c564 --- /dev/null +++ b/solid/src/ui/box.tsx @@ -0,0 +1,11 @@ +import type { TDom } from "../render/flex" +import { type Component, type JSX, JSXElement } from "solid-js" + +export const Box = ( + props: Partial & { + children?: any + }, +) => { + // @ts-ignore + return props.display !== "none" && +} diff --git a/solid/src/ui/index.ts b/solid/src/ui/index.ts new file mode 100644 index 0000000..d63ec58 --- /dev/null +++ b/solid/src/ui/index.ts @@ -0,0 +1 @@ +export * from "./box" diff --git a/solid/tsconfig.build.json b/solid/tsconfig.build.json new file mode 100644 index 0000000..bcf344d --- /dev/null +++ b/solid/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "jsx": "preserve", + "jsxImportSource": "solid-js" + } +} diff --git a/solid/tsconfig.json b/solid/tsconfig.json new file mode 100644 index 0000000..0c4f602 --- /dev/null +++ b/solid/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js" + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 4a7f873..ff9db52 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "outDir": "${configDir}/dist", "target": "ESNext", "useDefineForClassFields": true, "lib": ["ESNext", "ES2022", "es2021", "DOM"], From 305086903ce1e2ed59ef8c8b67902dd16ac847bd Mon Sep 17 00:00:00 2001 From: ahaoboy <504595380@qq.com> Date: Tue, 6 Aug 2024 22:39:40 +0800 Subject: [PATCH 2/4] bump dep --- solid/package.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/solid/package.json b/solid/package.json index 7c3c020..8456312 100644 --- a/solid/package.json +++ b/solid/package.json @@ -1,6 +1,6 @@ { "name": "@r-tui/solid", - "version": "0.1.0", + "version": "0.1.1", "description": "@r-tui/solid", "main": "dist/index.js", "types": "dist/index.d.js", @@ -31,13 +31,14 @@ "@r-tui/terminal": "workspace:*", "e-color": "^0.1.3", "lodash-es": "^4.17.21", - "@babel/core": "^7.24.7", - "@babel/preset-typescript": "^7.24.7", - "@r-tui/solid": "workspace:*", + "@r-tui/solid": "workspace:*" + }, + "devDependencies": { + "solid-js": "^1.8.19", "babel-preset-solid": "^1.8.19", - "solid-js": "^1.8.19" + "@babel/core": "^7.24.7", + "@babel/preset-typescript": "^7.24.7" }, - "devDependencies": {}, "peerDependencies": { "solid-js": "^1.8.19" } From 08fd6426a997289141d37362c6b68039c24c6634 Mon Sep 17 00:00:00 2001 From: ahaoboy <504595380@qq.com> Date: Tue, 6 Aug 2024 22:47:20 +0800 Subject: [PATCH 3/4] bump dep --- flex/package.json | 2 +- solid/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flex/package.json b/flex/package.json index d13bf18..c033ea6 100644 --- a/flex/package.json +++ b/flex/package.json @@ -1,6 +1,6 @@ { "name": "@r-tui/flex", - "version": "0.1.6", + "version": "0.1.7", "description": "", "main": "dist/index.js", "types": "dist/index.d.js", diff --git a/solid/package.json b/solid/package.json index 8456312..d5bf4f3 100644 --- a/solid/package.json +++ b/solid/package.json @@ -1,6 +1,6 @@ { "name": "@r-tui/solid", - "version": "0.1.1", + "version": "0.1.2", "description": "@r-tui/solid", "main": "dist/index.js", "types": "dist/index.d.js", From 1aec959c7d436d8547b42d735fb7f349814ab0a8 Mon Sep 17 00:00:00 2001 From: ahaoboy <504595380@qq.com> Date: Tue, 6 Aug 2024 23:09:58 +0800 Subject: [PATCH 4/4] remove react from solidjs --- solid/src/examples/flex.tsx | 1 - solid/src/examples/index.tsx | 2 +- solid/src/examples/readline.tsx | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/solid/src/examples/flex.tsx b/solid/src/examples/flex.tsx index 867af35..d5f8835 100644 --- a/solid/src/examples/flex.tsx +++ b/solid/src/examples/flex.tsx @@ -1,4 +1,3 @@ -import React from "react" import { Box } from "@r-tui/solid" export default function App() { diff --git a/solid/src/examples/index.tsx b/solid/src/examples/index.tsx index 14be70d..c51a347 100644 --- a/solid/src/examples/index.tsx +++ b/solid/src/examples/index.tsx @@ -31,7 +31,7 @@ export const Example = { } import { render, Box, TDom, createTDom, effect } from "@r-tui/solid" -render(() => , { +render(() => , { write: (s) => { console.log(s.trimEnd()) }, diff --git a/solid/src/examples/readline.tsx b/solid/src/examples/readline.tsx index b515dc6..996b672 100644 --- a/solid/src/examples/readline.tsx +++ b/solid/src/examples/readline.tsx @@ -1,4 +1,3 @@ -import React from "react" import { Box } from "@r-tui/solid" import { useReadLine } from "../hook"