├── .github ├── contributing.md ├── funding.yml ├── settings.yml └── workflows │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── netlify.toml ├── package.json ├── packages ├── cli │ ├── CHANGELOG.md │ ├── LICENSE │ ├── LICENSE.md │ ├── connect.mjs │ ├── package.json │ ├── src │ │ ├── cli.ts │ │ ├── commands │ │ │ ├── build │ │ │ │ └── index.ts │ │ │ └── dev │ │ │ │ ├── index.ts │ │ │ │ └── websocket.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── vtui.mjs ├── core │ ├── CHANGELOG.md │ ├── LICENSE │ ├── auto-imports.d.ts │ ├── package.json │ ├── src │ │ ├── app │ │ │ ├── createApp.ts │ │ │ └── types.ts │ │ ├── components │ │ │ ├── App.ts │ │ │ ├── Box.ts │ │ │ ├── Input.vue │ │ │ ├── Link.ts │ │ │ ├── Newline.ts │ │ │ ├── ProgressBar.ts │ │ │ ├── Text.ts │ │ │ ├── TextTransform.ts │ │ │ └── index.ts │ │ ├── composables │ │ │ ├── input.ts │ │ │ ├── keyboard.ts │ │ │ ├── mouse.ts │ │ │ ├── screen.ts │ │ │ ├── utils.ts │ │ │ └── writeStreams.ts │ │ ├── deps │ │ │ └── signal-exit │ │ │ │ ├── index.ts │ │ │ │ └── signals.ts │ │ ├── errors │ │ │ └── TuiError.ts │ │ ├── focus │ │ │ ├── FocusManager.spec.ts │ │ │ ├── FocusManager.ts │ │ │ ├── Focusable.ts │ │ │ └── types.ts │ │ ├── globals.d.ts │ │ ├── hmr │ │ │ ├── client.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── messages.ts │ │ │ └── server.ts │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── injectionSymbols.ts │ │ ├── input │ │ │ ├── debug.ts │ │ │ ├── handling.ts │ │ │ ├── inputSequences.ts │ │ │ ├── keyEvents.ts │ │ │ └── types.ts │ │ ├── mocks │ │ │ └── stdmock.ts │ │ ├── renderer │ │ │ ├── LogUpdate.ts │ │ │ ├── Output.ts │ │ │ ├── dom.ts │ │ │ ├── index.ts │ │ │ ├── nodeOpts.ts │ │ │ ├── render.ts │ │ │ ├── renderBorders.ts │ │ │ ├── renderNodeToOutput.ts │ │ │ ├── styles.ts │ │ │ ├── text.ts │ │ │ └── textColor.ts │ │ ├── shims-vue.d.ts │ │ ├── style-syntax │ │ │ ├── alias.ts │ │ │ ├── index.ts │ │ │ └── transform.ts │ │ └── utils │ │ │ ├── fileLog.ts │ │ │ ├── indentHTML.ts │ │ │ └── index.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── vite.config.ts ├── create-vue-termui │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── index.js │ ├── package.json │ └── template-ts │ │ ├── .vscode │ │ └── extensions.json │ │ ├── README.md │ │ ├── _gitignore │ │ ├── auto-imports.d.ts │ │ ├── components.d.ts │ │ ├── package.json │ │ ├── src │ │ ├── App.vue │ │ └── main.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts ├── docs │ ├── .editorconfig │ ├── .gitignore │ ├── .prettierrc │ ├── .vitepress │ │ ├── config.ts │ │ └── theme │ │ │ ├── components │ │ │ ├── AsideSponsors.vue │ │ │ ├── Banner.vue │ │ │ ├── HomeSponsors.vue │ │ │ ├── SvgImage.vue │ │ │ └── TextLogo.vue │ │ │ ├── composables │ │ │ └── sponsor.ts │ │ │ ├── index.ts │ │ │ └── styles │ │ │ └── vars.css │ ├── env.d.ts │ ├── package.json │ ├── src │ │ ├── api │ │ │ └── index.md │ │ ├── guide │ │ │ ├── components │ │ │ │ ├── tui-box.md │ │ │ │ ├── tui-input.md │ │ │ │ ├── tui-link.md │ │ │ │ ├── tui-newline.md │ │ │ │ ├── tui-progress-bar.md │ │ │ │ └── tui-text.md │ │ │ ├── essentials │ │ │ │ ├── builtin-components.md │ │ │ │ └── todo.md │ │ │ ├── introduction.md │ │ │ └── quick-start.md │ │ ├── index.md │ │ └── public │ │ │ ├── logo-big.png │ │ │ ├── logo-big.svg │ │ │ ├── logo.png │ │ │ ├── logo.svg │ │ │ ├── quick-start-demo.png │ │ │ └── social.png │ └── tsconfig.json ├── domino │ ├── .gitignore │ ├── .vscode │ │ └── extensions.json │ ├── README.md │ ├── auto-imports.d.ts │ ├── package.json │ ├── src │ │ ├── App.vue │ │ ├── components │ │ │ ├── DominoTile.vue │ │ │ └── HandDominoTile.vue │ │ ├── engine │ │ │ ├── DominoGame.spec.ts │ │ │ ├── DominoGame.ts │ │ │ ├── DominoTile.spec.ts │ │ │ ├── DominoTile.ts │ │ │ ├── DominoTileBoard.spec.ts │ │ │ ├── DominoTileBoard.ts │ │ │ ├── DominoTilePile.spec.ts │ │ │ ├── DominoTilePile.ts │ │ │ ├── Emitter.ts │ │ │ ├── Player.ts │ │ │ ├── index.ts │ │ │ ├── my-program-icon.png │ │ │ ├── tui-test.mjs │ │ │ ├── tui │ │ │ │ └── game.ts │ │ │ └── utils │ │ │ │ └── shuffle.ts │ │ ├── main.ts │ │ └── views │ │ │ └── Game.vue │ ├── tsconfig.json │ └── vite.config.ts ├── playground │ ├── auto-imports.d.ts │ ├── components.d.ts │ ├── package.json │ ├── src │ │ ├── App.vue │ │ ├── Borders.vue │ │ ├── CenteredDemo.vue │ │ ├── Counter.vue │ │ ├── Focusables.vue │ │ ├── Fragments.vue │ │ ├── Input.vue │ │ ├── InputDemo.vue │ │ ├── ProgressBarDemo.vue │ │ ├── ShortcutsDemo.vue │ │ ├── VueTermUILogo.vue │ │ ├── bugs │ │ │ └── Focusables.vue │ │ ├── components │ │ │ └── GlobalEvents.ts │ │ ├── env.d.ts │ │ └── main.ts │ └── vite.config.cjs ├── vite-plugin-vue-termui │ ├── CHANGELOG.md │ ├── LICENSE │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── tsup.config.ts └── xterm-playground │ ├── .gitignore │ ├── README.md │ ├── auto-imports.d.ts │ ├── index.html │ ├── package.json │ ├── public │ └── favicon.ico │ ├── src │ ├── App.vue │ ├── assets │ │ └── logo.png │ ├── components │ │ └── HelloWorld.vue │ ├── env.d.ts │ ├── main.ts │ └── tui │ │ └── TuiApp.vue │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── scripts ├── docs-check.sh └── release.mjs ├── tsconfig.json └── tsconfig.node.json /.github/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome and will be fully credited! 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/vue-terminal/vue-termui). 6 | 7 | ## Pull Requests 8 | 9 | Here are some guidelines to make the process smoother: 10 | 11 | - **Add a test** - New features and bugfixes need tests. If you find it difficult to test, please tell us in the pull request and we will try to help you! 12 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 13 | - **Run `pnpm run test` locally** - This will allow you to go faster 14 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 15 | - **Set a coherent title** - Make sure your commit(s) message means something. Note commits will be squashed. 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | Check the [project guidelines](#project-guidelines) to find help moving around the codebase. 19 | 20 | ## Creating issues 21 | 22 | ### Bug reports 23 | 24 | When creating an issue, try to provide a minimal reproduction or a failing test case within the repository. 25 | 26 | ### Feature requests 27 | 28 | Lay out the reasoning behind it and propose an API for it. Ideally, you should have a practical example to prove the utility of the feature you're requesting. 29 | 30 | ## Project Guidelines 31 | 32 | After pulling the project code and installing deps with `pnpm i`. run `pnpm run stub` at the root of the project. You can `cd packages/playground` and run `pnpm run dev` to test the playground. 33 | 34 | This project uses pnpm workspaces and contains different packages: 35 | 36 | ### packages/core 37 | 38 | This folder contains the `vue-termui` package. It's contains the Vue Custom Renderer, all the logic to handle terminal escape codes and input, and client HMR code. 39 | 40 | ### packages/vite-plugin-vue-termui 41 | 42 | This folder contains the `vite-plugin-vue-termui` package. It's contains the Vite plugin that is required to build a Vue TermUI project and includes other Vite plugins like the Vue one and automatic imports. 43 | 44 | ### packages/create-vue-termui 45 | 46 | This folder contains the `create-vue-termui` package. It's the scaffolding tool to create a Vue TermUI project. 47 | 48 | ### packages/cli 49 | 50 | This folder contains the `@vue-termui/cli` package. It's the CLI used to develop and bundle Vue TermUI applications. It contains the HMR logic used on the server. 51 | 52 | Note: **Any other folder is probably a test and might be outdated.** 53 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [posva, webfansplz] 2 | custom: https://www.paypal.me/posva 3 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | labels: 2 | - name: 🐞 bug 3 | color: ee0701 4 | description: Something isn't working 5 | - name: contribution welcome 6 | color: 0e8a16 7 | - name: discussion 8 | color: 4935ad 9 | - name: :sparkles: enhancement 10 | color: a2eeef 11 | description: New Feature or request 12 | - name: good first issue 13 | color: 7057ff 14 | - name: help wanted 15 | color: 008672 16 | - name: wontfix 17 | color: ffffff 18 | - name: WIP 19 | color: ffffff 20 | - name: need repro 21 | color: c9581c 22 | - name: feature request 23 | color: fbca04 24 | - name: 📚 docs 25 | color: 0052cc 26 | description: Related to documentation changes 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'packages/docs/**' 9 | - 'packages/create-vue-termui/**' 10 | - 'packages/*playground*/**' 11 | 12 | pull_request: 13 | branches: 14 | - main 15 | paths-ignore: 16 | - 'packages/docs/**' 17 | - 'packages/create-vue-termui/**' 18 | - 'packages/*playground*/**' 19 | 20 | jobs: 21 | lint: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - name: Install pnpm 27 | uses: pnpm/action-setup@v2.2.1 28 | 29 | - name: Set node 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: 16.x 33 | cache: pnpm 34 | 35 | - name: Install 36 | run: pnpm i 37 | 38 | - name: Lint 39 | run: pnpm run lint 40 | 41 | build: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v3 45 | 46 | - name: Install pnpm 47 | uses: pnpm/action-setup@v2.2.1 48 | 49 | - name: Set node 50 | uses: actions/setup-node@v3 51 | with: 52 | node-version: 16.x 53 | cache: pnpm 54 | 55 | - name: Install 56 | run: pnpm i 57 | 58 | - name: Build 59 | run: pnpm run build 60 | 61 | - name: Build types 62 | run: pnpm run types 63 | 64 | test: 65 | runs-on: ${{ matrix.os }} 66 | 67 | timeout-minutes: 10 68 | 69 | strategy: 70 | matrix: 71 | node_version: [14.x, 16.x] 72 | os: [ubuntu-latest] # , macos-latest] 73 | fail-fast: false 74 | 75 | steps: 76 | - uses: actions/checkout@v3 77 | 78 | - name: Install pnpm 79 | uses: pnpm/action-setup@v2.2.1 80 | 81 | - name: Set node version to ${{ matrix.node_version }} 82 | uses: actions/setup-node@v3 83 | with: 84 | node-version: ${{ matrix.node_version }} 85 | cache: pnpm 86 | 87 | - name: Install 88 | run: pnpm i 89 | 90 | - name: Stub packages 91 | run: pnpm run stub 92 | 93 | - name: Test 94 | run: pnpm run test 95 | 96 | - uses: codecov/codecov-action@v2 97 | with: 98 | files: ./packages/core/coverage/lcov.info 99 | fail_ci_if_error: true 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .idea 17 | .DS_Store 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | tsconfig.tsbuildinfo 24 | @vite:client.ts 25 | coverage 26 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | __build__ 2 | dist 3 | coverage 4 | auto-imports.d.ts 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'es5', 4 | singleQuote: true, 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "type": "pwa-node", 7 | "request": "launch", 8 | "name": "Debug Current Test File", 9 | "autoAttachChildProcesses": true, 10 | "skipFiles": ["/**", "**/node_modules/**"], 11 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 12 | "args": ["run", "${relativeFile}"], 13 | "smartStep": true, 14 | "console": "integratedTerminal" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "prettier.enable": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "typescript.tsdk": "node_modules/typescript/lib" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present Eduardo San Martin Morote 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Vue TermUI logo 4 | 5 |

6 | 7 |
8 | 9 |

10 | npm package 11 | build status 12 | code coverage 13 |

14 | 15 |
16 | 17 | # Vue TermUI 18 | 19 | > Terminal User Interfaces powered by Vue 20 | 21 | Build TUIs (Terminal User Interfaces) with ease and benefit from the best Developer Experience out there. 22 | 23 | ### [🧑‍💻 Join the Discord Server!](https://discord.gg/HPVS2AbgXP) 24 | 25 | ## Features 26 | 27 | - Dev Server with **instant** HMR (Hot Module Replacement) 28 | - `.vue` files support out of the box 29 | - Flexbox-like layouts 30 | - Keyboard handling 31 | - Mouse handling 32 | - Focus management 33 | - Simple bundling 34 | 35 | Get started now with a template: 36 | 37 | ```bash 38 | # npm 6.x 39 | npm create vue-termui@latest my-vue-tui-app 40 | 41 | # npm 7+, extra double-dash is needed: 42 | npm create vue-termui@latest my-vue-tui-app 43 | 44 | # yarn 45 | yarn create vue-termui my-vue-tui-app 46 | 47 | # pnpm 48 | pnpm create vue-termui my-vue-tui-app 49 | ``` 50 | 51 | ## Help me keep working on this project 💚 52 | 53 | - [Become a Sponsor on GitHub](https://github.com/sponsors/posva) 54 | - [One-time donation via PayPal](https://paypal.me/posva) 55 | 56 | ## Contributors 57 | 58 | 59 | 60 | 61 | 62 | ## License 63 | 64 | [MIT](http://opensource.org/licenses/MIT) 65 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NODE_VERSION = "16" 3 | 4 | [build] 5 | command = "pnpm run -w docs:build" 6 | ignore = "./scripts/docs-check.sh" 7 | publish = "packages/docs/.vitepress/dist" 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "version": "0.0.0", 4 | "private": true, 5 | "packageManager": "pnpm@7.12.2", 6 | "repository": { 7 | "url": "https://github.com/vue-terminal/vue-termui.git", 8 | "type": "git" 9 | }, 10 | "scripts": { 11 | "release": "node scripts/release.mjs", 12 | "stub": "pnpm run -r --filter=./packages/{core,vite-plugin-vue-termui,cli} stub", 13 | "build": "pnpm run -r --filter=./packages/{core,vite-plugin-vue-termui,cli} build", 14 | "play:dev": "pnpm run --filter=./packages/playground dev", 15 | "lint": "prettier --check packages README.md", 16 | "lint:fix": "prettier --write packages README.md", 17 | "types": "pnpm run -r types", 18 | "test": "pnpm run --parallel -r test --coverage", 19 | "docs": "pnpm run -r dev --filter=./packages/docs", 20 | "docs:build": "pnpm -r --filter=./packages/docs run build" 21 | }, 22 | "engines": { 23 | "node": ">=16.0.0" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^18.11.18", 27 | "@vitejs/plugin-vue": "^4.0.0", 28 | "@vitest/coverage-c8": "^0.26.2", 29 | "@vue/compiler-sfc": "^3.2.45", 30 | "@vue/runtime-core": "^3.2.45", 31 | "c8": "^7.11.2", 32 | "chalk": "^5.2.0", 33 | "conventional-changelog-cli": "^2.2.2", 34 | "enquirer": "^2.3.6", 35 | "execa": "^6.1.0", 36 | "globby": "^13.1.3", 37 | "minimist": "^1.2.7", 38 | "p-series": "^3.0.0", 39 | "prettier": "^2.8.1", 40 | "semver": "^7.3.8", 41 | "tsup": "^6.5.0", 42 | "typescript": "^4.9.4", 43 | "unbuild": "^1.0.2", 44 | "unplugin-auto-import": "^0.12.1", 45 | "unplugin-vue-components": "^0.22.12", 46 | "vite": "^4.0.3", 47 | "vitest": "^0.26.2", 48 | "vue": "^3.2.45", 49 | "vue-tsc": "^1.0.18" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/cli/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present Eduardo San Martin Morote 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/cli/LICENSE.md: -------------------------------------------------------------------------------- 1 | # Vue TermUI core license 2 | 3 | Vue TermUI is released under the MIT license: 4 | 5 | The MIT License (MIT) 6 | 7 | Copyright (c) 2022-present Eduardo San Martin Morote 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | 27 | # Licenses of bundled dependencies 28 | 29 | The published Vue Term UI artifact additionally contains code with the following licenses: 30 | MIT 31 | 32 | # Bundled dependencies: 33 | 34 | ## cac 35 | 36 | License: MIT 37 | By: egoist 38 | Repository: egoist/cac 39 | 40 | > The MIT License (MIT) 41 | > 42 | > Copyright (c) EGOIST <0x142857@gmail.com> (https://github.com/egoist) 43 | > 44 | > Permission is hereby granted, free of charge, to any person obtaining a copy 45 | > of this software and associated documentation files (the "Software"), to deal 46 | > in the Software without restriction, including without limitation the rights 47 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 48 | > copies of the Software, and to permit persons to whom the Software is 49 | > furnished to do so, subject to the following conditions: 50 | > 51 | > The above copyright notice and this permission notice shall be included in 52 | > all copies or substantial portions of the Software. 53 | > 54 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 55 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 56 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 57 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 58 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 59 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 60 | > THE SOFTWARE. 61 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vue-termui/cli", 3 | "private": false, 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "type": "module", 8 | "version": "0.0.17", 9 | "scripts": { 10 | "stub": "unbuild --stub", 11 | "build": "tsup", 12 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s --commit-path . -l @vue-termui/cli -r 1" 13 | }, 14 | "exports": { 15 | ".": { 16 | "import": "./dist/index.mjs", 17 | "require": "./dist/index.cjs", 18 | "types": "./dist/index.d.ts" 19 | }, 20 | "./*": "./*" 21 | }, 22 | "main": "./dist/index.cjs", 23 | "module": "./dist/index.mjs", 24 | "types": "./dist/index.d.ts", 25 | "bin": { 26 | "vtui": "./vtui.mjs" 27 | }, 28 | "files": [ 29 | "dist/**/*.js", 30 | "dist/**/*.mjs", 31 | "dist/**/*.cjs", 32 | "dist/**/*.d.ts", 33 | "vtui.mjs" 34 | ], 35 | "keywords": [ 36 | "vue", 37 | "term", 38 | "ui", 39 | "cli", 40 | "vue-termui-cli", 41 | "vue-termui" 42 | ], 43 | "funding": "https://github.com/vue-terminal/vue-termui?sponsor=1", 44 | "license": "MIT", 45 | "author": "Eduardo San Martin Morote (https://esm.dev)", 46 | "repository": { 47 | "type": "git", 48 | "url": "https://github.com/vue-terminal/vue-termui.git", 49 | "directory": "packages/cli" 50 | }, 51 | "engines": { 52 | "node": ">=14.0.0" 53 | }, 54 | "bugs": { 55 | "url": "https://github.com/vue-terminal/vue-termui/issues" 56 | }, 57 | "homepage": "https://github.com/vue-terminal/vue-termui#readme", 58 | "dependencies": { 59 | "picocolors": "^1.0.0", 60 | "vite-node": "^0.26.2", 61 | "ws": "^8.11.0" 62 | }, 63 | "peerDependencies": { 64 | "vite": "^3.1.3", 65 | "vite-plugin-vue-termui": ">=0.0.11", 66 | "vue-termui": ">=0.0.14" 67 | }, 68 | "devDependencies": { 69 | "@types/ws": "^8.5.3", 70 | "cac": "^6.7.12", 71 | "fast-glob": "^3.2.12", 72 | "pathe": "^1.0.0", 73 | "rimraf": "^3.0.2", 74 | "vite-plugin-inspect": "^0.7.11", 75 | "vite-plugin-vue-termui": "workspace:*", 76 | "vue-termui": "workspace:*" 77 | }, 78 | "unbuild": { 79 | "entries": [ 80 | "src/cli", 81 | "src/index" 82 | ], 83 | "rollup": { 84 | "emitCJS": true 85 | }, 86 | "clean": true 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/cli/src/cli.ts: -------------------------------------------------------------------------------- 1 | import cac from 'cac' 2 | import c from 'picocolors' 3 | import { runDevServer } from './commands/dev' 4 | import { buildCommand } from './commands/build' 5 | import { version } from '../package.json' 6 | 7 | const cli = cac('vtui') 8 | 9 | cli 10 | .version(version) 11 | .option('-c, --config ', 'path to vite config file') 12 | .help() 13 | 14 | cli.command('[entryFile]', 'Same as build command').action(buildCommand) 15 | 16 | cli 17 | .command( 18 | 'build [entryFile]', 19 | 'Build the application for production. "entryFile" defaults to src/main.ts.' 20 | ) 21 | .usage('build [src/main.ts]') 22 | .action(buildCommand) 23 | 24 | cli 25 | .command( 26 | 'dev [entryFile]', 27 | 'Runs a development server with HMR. "entryFile" defaults to src/main.ts.' 28 | ) 29 | .usage('dev [src/main.ts]') 30 | .action(runDevServer) 31 | 32 | cli.on('command:*', () => { 33 | console.log() 34 | console.error( 35 | c.inverse(c.red(' ERROR ')) + c.white(' Unknown command: %s'), 36 | cli.args.join(' ') 37 | ) 38 | console.log() 39 | cli.outputHelp() 40 | process.exit(1) 41 | }) 42 | 43 | // TODO: maybe https://github.com/cacjs/cac#error-handling 44 | cli.parse() 45 | -------------------------------------------------------------------------------- /packages/cli/src/commands/build/index.ts: -------------------------------------------------------------------------------- 1 | import { build } from 'vite' 2 | 3 | export async function buildCommand(entryFile: string = 'src/main.ts') { 4 | await build({ 5 | define: { 6 | 'process.env.NODE_ENV': JSON.stringify('production'), 7 | }, 8 | build: { 9 | rollupOptions: { 10 | input: entryFile, 11 | }, 12 | }, 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /packages/cli/src/commands/dev/websocket.ts: -------------------------------------------------------------------------------- 1 | import { ViteDevServer } from 'vite' 2 | import { WebSocketServer } from 'ws' 3 | 4 | export async function startHMRServer( 5 | server: ViteDevServer, 6 | port: number | undefined | null 7 | ) { 8 | let pendingWSS: WebSocketServer | null = null 9 | // if a port is required, avoid trying other ports 10 | const isStrictPort = port != null 11 | port = port || 3000 12 | 13 | while (!pendingWSS) { 14 | pendingWSS = await createHMRWSS(port++, isStrictPort) 15 | } 16 | 17 | const wss = pendingWSS 18 | 19 | wss.on('connection', (ws) => { 20 | ws.on('message', (buf) => { 21 | const data = buf.toString() 22 | console.error('Received', data) 23 | }) 24 | 25 | ws.on('error', (error) => { 26 | console.error('they error', error) 27 | // TODO: crash, restart, panel of options? 28 | }) 29 | 30 | ws.on('close', (status, buf) => { 31 | if (isConnectionClosedNormally(status)) { 32 | wss.close() 33 | server.close() 34 | } else { 35 | // TODO: restart or crash 36 | console.error('Unexpectedly closed from client:') 37 | console.error('status:', status) 38 | console.error('data:', buf.toString()) 39 | } 40 | }) 41 | }) 42 | 43 | return { wss, port: port - 1 } 44 | } 45 | 46 | function createHMRWSS(port: number, strictPort: boolean) { 47 | return new Promise((resolve, reject) => { 48 | // create the socket server to communicate between the running process and the dev server 49 | const wss = new WebSocketServer({ port }) 50 | 51 | function onError(error: Error & { code?: string }) { 52 | wss.off('error', onError) 53 | if (error.code === 'EADDRINUSE') { 54 | if (strictPort) { 55 | reject(new Error(`Port ${port} is already in use`)) 56 | } else { 57 | console.info(`Port ${port} is in use, trying another one...`) 58 | resolve(null) 59 | } 60 | wss.close() 61 | } else { 62 | wss.close() 63 | reject(error) 64 | } 65 | } 66 | wss.on('error', onError) 67 | 68 | function onListening() { 69 | console.log(`Started dev Server on port ${port}`) 70 | wss.off('listening', onListening) 71 | resolve(wss) 72 | } 73 | wss.on('listening', onListening) 74 | }) 75 | } 76 | 77 | /** 78 | * WebSocket connection codes from https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code 79 | */ 80 | export enum CloseEventCode { 81 | normal = 1000, 82 | goingAway = 1001, 83 | protocolError = 1002, 84 | unsupported = 1003, 85 | // classic ctrl-c 86 | noStatus = 1005, 87 | abnormal = 1005, 88 | tooLarge = 1009, 89 | internal = 1011, 90 | restart = 1012, 91 | 92 | // 3000-3999 for libraries, frameworks, NOT applications 93 | // 4000-4999 for applications 94 | } 95 | 96 | function isConnectionClosedNormally(status: number) { 97 | return status === CloseEventCode.noStatus || status === CloseEventCode.normal 98 | } 99 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | export { runDevServer } from './commands/dev' 2 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "esnext", 6 | "sourceMap": true, 7 | "types": ["vite/client"], 8 | "lib": ["esnext"] 9 | }, 10 | "include": [ 11 | "vite.config.ts", 12 | "bin/**/*.ts", 13 | "bin/*.ts", 14 | "demo/**/*.vue", 15 | "demo/**/*.ts", 16 | "demo/*.vue", 17 | "demo/*.d.ts", 18 | "src/**/*.ts", 19 | "src/**/*.d.ts", 20 | "src/**/*.vue", 21 | "./auto-imports.d.ts", 22 | "./components.d.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /packages/cli/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | target: 'node14', 6 | format: ['esm', 'cjs'], 7 | dts: true, 8 | esbuildOptions(options) { 9 | if (options.format === 'esm') options.outExtension = { '.js': '.mjs' } 10 | }, 11 | entry: [ 12 | // commands because why not 13 | 'src/index.ts', 14 | // cli app 15 | 'src/cli.ts', 16 | ], 17 | external: [ 18 | 'vite-node/server', 19 | 'vite-node/client', 20 | 'vite-node/utils', 21 | 'path', // huh? 22 | ], 23 | }) 24 | -------------------------------------------------------------------------------- /packages/cli/vtui.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import('./dist/cli.mjs') 3 | -------------------------------------------------------------------------------- /packages/core/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present Eduardo San Martin Morote 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/core/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | export {} 3 | declare global { 4 | const afterAll: typeof import('vitest')['afterAll'] 5 | const afterEach: typeof import('vitest')['afterEach'] 6 | const assert: typeof import('vitest')['assert'] 7 | const beforeAll: typeof import('vitest')['beforeAll'] 8 | const beforeEach: typeof import('vitest')['beforeEach'] 9 | const chai: typeof import('vitest')['chai'] 10 | const describe: typeof import('vitest')['describe'] 11 | const expect: typeof import('vitest')['expect'] 12 | const it: typeof import('vitest')['it'] 13 | const suite: typeof import('vitest')['suite'] 14 | const test: typeof import('vitest')['test'] 15 | const vi: typeof import('vitest')['vi'] 16 | const vitest: typeof import('vitest')['vitest'] 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-termui", 3 | "version": "0.0.19", 4 | "private": false, 5 | "type": "module", 6 | "types": "./dist/src", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/vue-termui.mjs", 10 | "types": "./dist/src" 11 | } 12 | }, 13 | "files": [ 14 | "dist/*.js", 15 | "dist/vue-termui.mjs", 16 | "dist/src/**/*.d.ts" 17 | ], 18 | "keywords": [ 19 | "vue", 20 | "term", 21 | "ui", 22 | "user", 23 | "interface", 24 | "cli", 25 | "app", 26 | "vtui", 27 | "terminal", 28 | "termui", 29 | "tui" 30 | ], 31 | "funding": "https://github.com/vue-terminal/vue-termui?sponsor=1", 32 | "license": "MIT", 33 | "author": "Eduardo San Martin Morote (https://esm.dev)", 34 | "scripts": { 35 | "test": "vitest run", 36 | "test:dev": "vitest", 37 | "types": "tsc --emitDeclarationOnly -p ./tsconfig.build.json", 38 | "lint": "echo TODO:", 39 | "build": "vite build", 40 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s --commit-path . -l vue-termui -r 1" 41 | }, 42 | "dependencies": { 43 | "ansi-escapes": "^6.0.0", 44 | "chalk": "^5.2.0", 45 | "cli-boxes": "^3.0.0", 46 | "cli-cursor": "^4.0.0", 47 | "cli-truncate": "^3.1.0", 48 | "indent-string": "^5.0.0", 49 | "slice-ansi": "^5.0.0", 50 | "string-width": "^5.1.0", 51 | "terminal-link": "^3.0.0", 52 | "type-fest": "^3.5.0", 53 | "widest-line": "^4.0.1", 54 | "wrap-ansi": "^8.0.1", 55 | "ws": "^8.11.0", 56 | "yoga-layout-prebuilt": "^1.10.0" 57 | }, 58 | "peerDependencies": { 59 | "@vue/runtime-core": "^3.2.33" 60 | }, 61 | "devDependencies": { 62 | "@types/slice-ansi": "^5.0.0", 63 | "@types/wrap-ansi": "^8.0.1", 64 | "@types/ws": "^8.5.3" 65 | }, 66 | "engines": { 67 | "node": ">=14.0.0" 68 | }, 69 | "repository": { 70 | "type": "git", 71 | "url": "https://github.com/vue-terminal/vue-termui.git", 72 | "directory": "packages/core" 73 | }, 74 | "bugs": { 75 | "url": "https://github.com/vue-terminal/vue-termui/issues" 76 | }, 77 | "homepage": "https://github.com/vue-terminal/vue-termui#readme" 78 | } 79 | -------------------------------------------------------------------------------- /packages/core/src/app/types.ts: -------------------------------------------------------------------------------- 1 | import { App } from '@vue/runtime-core' 2 | 3 | type TODO = any 4 | 5 | export interface TuiAppMountOptions { 6 | /** 7 | * Render the application once and exit. 8 | * 9 | * @defaultValue `false` 10 | */ 11 | renderOnce: boolean 12 | 13 | /** 14 | * Exits on ctrl-c. 15 | * 16 | * @defaultValue `true` 17 | */ 18 | exitOnCtrlC: boolean 19 | } 20 | 21 | export interface TuiApp extends Omit, 'mount'> { 22 | mount(options?: Partial): TuiApp 23 | 24 | waitUntilExit(): Promise 25 | } 26 | 27 | export interface TuiAppOptions { 28 | /** 29 | * Output stream where app will be rendered. 30 | * 31 | * @default process.stdout 32 | */ 33 | stdout?: NodeJS.WriteStream 34 | /** 35 | * Input stream where app will listen for input. 36 | * 37 | * @default process.stdin 38 | */ 39 | stdin?: NodeJS.ReadStream 40 | /** 41 | * Error stream. 42 | * @default process.stderr 43 | */ 44 | stderr?: NodeJS.WriteStream 45 | 46 | /** 47 | * Switches the current screen buffer when mounting the app and restores it when exiting. This is useful for 48 | * fullscreen applications and applications relying on mouse coordinates.Using this will always display the app at the 49 | * top left corner. 50 | */ 51 | swapScreens?: boolean 52 | } 53 | 54 | export type RootProps = Record 55 | -------------------------------------------------------------------------------- /packages/core/src/components/App.ts: -------------------------------------------------------------------------------- 1 | import { 2 | h, 3 | provide, 4 | defineComponent, 5 | onMounted, 6 | onUnmounted, 7 | onUpdated, 8 | onErrorCaptured, 9 | inject, 10 | DefineComponent, 11 | PropType, 12 | } from '@vue/runtime-core' 13 | import ansiEscapes from 'ansi-escapes' 14 | import { renderRoot } from '../renderer/render' 15 | import { 16 | renderOnceSymbol, 17 | scheduleUpdateSymbol, 18 | useLog, 19 | useRootNode, 20 | } from '../injectionSymbols' 21 | import { stdoutSymbol } from '../composables/writeStreams' 22 | // TODO: useSettings() 23 | 24 | export const TuiApp = defineComponent({ 25 | name: 'TuiRoot', 26 | props: { 27 | root: { 28 | type: Object as PropType, 29 | required: true, 30 | }, 31 | 32 | // we need this as a prop instead of useStdout because we need to define the write function here based on the last final render output 33 | stdout: { 34 | type: Object as PropType, 35 | required: true, 36 | }, 37 | swapScreens: Boolean, 38 | }, 39 | setup(props, { attrs }) { 40 | const log = useLog() 41 | 42 | const { stdout } = props 43 | 44 | const writeToStdout: NodeJS.WriteStream['write'] = (...args) => { 45 | log.clear() 46 | // @ts-expect-error: args fails for some reason 47 | const ret = stdout.write.apply(stdout, args) 48 | log(lastOutput) 49 | return ret 50 | } 51 | 52 | provide(stdoutSymbol, { stdout, write: writeToStdout }) 53 | 54 | const rootNode = useRootNode() 55 | 56 | let lastOutput: string = '' 57 | 58 | function renderTuiApp() { 59 | // console.log('need update', i?.root.vnode.el) 60 | const { output, outputHeight, staticOutput } = renderRoot( 61 | rootNode, 62 | stdout.columns || 80 63 | ) 64 | 65 | // If output isn't empty, it means new children have been added to it 66 | const hasStaticOutput = staticOutput && staticOutput !== '\n' 67 | 68 | // console.log('update', { hasStaticOutput }) 69 | 70 | if (outputHeight >= stdout.rows || props.swapScreens) { 71 | stdout.write( 72 | ansiEscapes.cursorTo(0, 0) + 73 | ansiEscapes.eraseDown + 74 | /* fullStaticOutput + */ output 75 | ) 76 | 77 | lastOutput = output 78 | 79 | return 80 | } 81 | 82 | if (!hasStaticOutput && output !== lastOutput) { 83 | log(output) 84 | } 85 | 86 | lastOutput = output 87 | } 88 | 89 | let interval: NodeJS.Timer 90 | let needsUpdate = false 91 | const renderOnce = inject(renderOnceSymbol, false) 92 | onMounted(() => { 93 | if (!renderOnce) { 94 | interval = setInterval(() => { 95 | if (needsUpdate) { 96 | renderTuiApp() 97 | needsUpdate = false 98 | } 99 | }, 32) 100 | stdout.on('resize', scheduleUpdate) 101 | } 102 | renderTuiApp() 103 | }) 104 | 105 | onUnmounted(() => { 106 | clearInterval(interval) 107 | stdout.off('resize', scheduleUpdate) 108 | }) 109 | 110 | function scheduleUpdate() { 111 | needsUpdate = true 112 | } 113 | provide(scheduleUpdateSymbol, scheduleUpdate) 114 | 115 | onUpdated(scheduleUpdate) 116 | 117 | onErrorCaptured((error, target) => { 118 | debugger 119 | console.error('Captured Error') 120 | console.error(error) 121 | console.log(target) 122 | }) 123 | 124 | return () => h(props.root, attrs) 125 | }, 126 | }) 127 | -------------------------------------------------------------------------------- /packages/core/src/components/Box.ts: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent, computed } from '@vue/runtime-core' 2 | import { Styles } from '../renderer/styles' 3 | import { transformClassToStyleProps } from '../style-syntax' 4 | 5 | export interface TuiBoxProps extends Omit { 6 | /** 7 | * Margin on all sides. Equivalent to setting `marginTop`, `marginBottom`, `marginLeft` and `marginRight`. 8 | * 9 | * @default 0 10 | */ 11 | margin?: number 12 | 13 | /** 14 | * Horizontal margin. Equivalent to setting `marginLeft` and `marginRight`. 15 | * 16 | * @default 0 17 | */ 18 | marginX?: number 19 | 20 | /** 21 | * Vertical margin. Equivalent to setting `marginTop` and `marginBottom`. 22 | * 23 | * @default 0 24 | */ 25 | marginY?: number 26 | 27 | /** 28 | * Padding on all sides. Equivalent to setting `paddingTop`, `paddingBottom`, `paddingLeft` and `paddingRight`. 29 | * 30 | * @default 0 31 | */ 32 | padding?: number 33 | 34 | /** 35 | * Horizontal padding. Equivalent to setting `paddingLeft` and `paddingRight`. 36 | * 37 | * @default 0 38 | */ 39 | paddingX?: number 40 | 41 | /** 42 | * Vertical padding. Equivalent to setting `paddingTop` and `paddingBottom`. 43 | * 44 | * @default 0 45 | */ 46 | paddingY?: number 47 | 48 | /** 49 | * Optional title to display. 50 | */ 51 | title?: string 52 | 53 | /** 54 | * Style shortcuts. 55 | */ 56 | class?: string 57 | } 58 | 59 | export const TuiBox: FunctionalComponent = (props, { slots }) => { 60 | const propsWithClasses = computed(() => 61 | props.class 62 | ? { 63 | ...props, 64 | ...transformClassToStyleProps(props.class), 65 | } 66 | : props 67 | ) 68 | 69 | const propsValue = propsWithClasses.value 70 | 71 | return h( 72 | 'tui:box', 73 | { 74 | style: { 75 | ...propsValue, 76 | display: propsValue.display ?? 'flex', 77 | flexDirection: propsValue.flexDirection ?? 'row', 78 | flexGrow: propsValue.flexGrow ?? 0, 79 | flexShrink: propsValue.flexShrink ?? 1, 80 | 81 | marginLeft: 82 | propsValue.marginLeft ?? propsValue.marginX ?? propsValue.margin ?? 0, 83 | marginRight: 84 | propsValue.marginRight ?? 85 | propsValue.marginX ?? 86 | propsValue.margin ?? 87 | 0, 88 | marginTop: 89 | propsValue.marginTop ?? propsValue.marginY ?? propsValue.margin ?? 0, 90 | marginBottom: 91 | propsValue.marginBottom ?? 92 | propsValue.marginY ?? 93 | propsValue.margin ?? 94 | 0, 95 | 96 | paddingLeft: 97 | propsValue.paddingLeft ?? 98 | propsValue.paddingX ?? 99 | propsValue.padding ?? 100 | 0, 101 | paddingRight: 102 | propsValue.paddingRight ?? 103 | propsValue.paddingX ?? 104 | propsValue.padding ?? 105 | 0, 106 | paddingTop: 107 | propsValue.paddingTop ?? 108 | propsValue.paddingY ?? 109 | propsValue.padding ?? 110 | 0, 111 | paddingBottom: 112 | propsValue.paddingBottom ?? 113 | propsValue.paddingY ?? 114 | propsValue.padding ?? 115 | 0, 116 | }, 117 | }, 118 | { default: slots.default } 119 | ) 120 | } 121 | 122 | TuiBox.displayName = 'TuiBox' 123 | TuiBox.props = [ 124 | 'title', 125 | 'class', 126 | 'position', 127 | 'top', 128 | 'right', 129 | 'bottom', 130 | 'left', 131 | 'margin', 132 | 'marginX', 133 | 'marginY', 134 | 'marginTop', 135 | 'marginBottom', 136 | 'marginLeft', 137 | 'marginRight', 138 | 'padding', 139 | 'paddingX', 140 | 'paddingY', 141 | 'paddingTop', 142 | 'paddingBottom', 143 | 'paddingLeft', 144 | 'paddingRight', 145 | 'flexGrow', 146 | 'flexShrink', 147 | 'flexDirection', 148 | 'flexBasis', 149 | 'alignItems', 150 | 'alignSelf', 151 | 'justifyContent', 152 | 'width', 153 | 'height', 154 | 'minWidth', 155 | 'minHeight', 156 | 'maxWidth', 157 | 'maxHeight', 158 | 'display', 159 | 'borderStyle', 160 | 'borderColor', 161 | ] 162 | -------------------------------------------------------------------------------- /packages/core/src/components/Link.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, toRef } from '@vue/runtime-core' 2 | import { TuiTextTransform } from './TextTransform' 3 | import terminalLink from 'terminal-link' 4 | import { useFocus } from '../focus/Focusable' 5 | 6 | // export const TuiLink: FunctionalComponent<{ 7 | // href: string 8 | // fallback?: boolean 9 | // }> = (props, { slots }) => { 10 | // return h( 11 | // TuiTextTransform, 12 | // { 13 | // transform: (text) => 14 | // terminalLink(text, props.href, { fallback: props.fallback ?? true }), 15 | // }, 16 | // { default: slots.default } 17 | // ) 18 | // } 19 | // TuiLink.displayName = 'TuiLink' 20 | 21 | export const TuiLink = defineComponent({ 22 | props: { 23 | href: { 24 | type: String, 25 | required: true, 26 | }, 27 | fallback: Boolean, 28 | disabled: Boolean, 29 | }, 30 | setup(props, { slots }) { 31 | const { active } = useFocus({ 32 | active: true, 33 | disabled: toRef(props, 'disabled'), 34 | }) 35 | 36 | return () => 37 | h( 38 | TuiTextTransform, 39 | { 40 | inverse: active.value, 41 | dimmed: props.disabled, 42 | ...props, 43 | transform: props.disabled 44 | ? undefined 45 | : (text) => 46 | terminalLink(text, props.href, { 47 | fallback: props.fallback ?? true, 48 | }), 49 | }, 50 | { default: slots.default } 51 | ) 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /packages/core/src/components/Newline.ts: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent } from '@vue/runtime-core' 2 | 3 | export const TuiNewline: FunctionalComponent<{ n?: number }> = (props) => 4 | h('tui:text', '\n'.repeat(props.n ?? 1)) 5 | 6 | TuiNewline.displayName = 'TuiNewline' 7 | -------------------------------------------------------------------------------- /packages/core/src/components/ProgressBar.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineComponent, 3 | h, 4 | inject, 5 | onMounted, 6 | onUpdated, 7 | onUnmounted, 8 | computed, 9 | } from '@vue/runtime-core' 10 | import type { PropType } from '@vue/runtime-core' 11 | import { colorize } from '../renderer/textColor' 12 | import type { ForegroundColorProp } from '../renderer/textColor' 13 | import { scheduleUpdateSymbol } from '../injectionSymbols' 14 | 15 | const FIGURES = { 16 | basic: '█', 17 | shade: '▓', 18 | } as const 19 | 20 | type FigureType = keyof typeof FIGURES 21 | 22 | export const TuiProgressBar = defineComponent({ 23 | name: 'TuiProgressBar', 24 | 25 | props: { 26 | color: { 27 | required: false, 28 | default: 'blue', 29 | type: String as PropType, 30 | }, 31 | bgColor: { 32 | required: false, 33 | default: 'white', 34 | type: String as PropType, 35 | }, 36 | width: { 37 | required: false, 38 | default: 25, 39 | type: Number, 40 | }, 41 | value: { 42 | required: true, 43 | type: Number, 44 | }, 45 | type: { 46 | required: false, 47 | type: String as PropType, 48 | default: 'basic', 49 | }, 50 | }, 51 | 52 | setup(props) { 53 | const scheduleUpdate = inject(scheduleUpdateSymbol)! 54 | 55 | onMounted(scheduleUpdate) 56 | 57 | onUpdated(scheduleUpdate) 58 | 59 | onUnmounted(scheduleUpdate) 60 | 61 | const content = computed(() => { 62 | const type = FIGURES[props.type] 63 | const w = Math.floor(props.value * (props.width / 100)) 64 | const bg = colorize(type, props.bgColor, 'foreground') 65 | const fg = colorize(type, props.color, 'foreground') 66 | return fg.repeat(w) + bg.repeat(props.width - w) 67 | }) 68 | 69 | return () => { 70 | return h('tui:text', content.value) 71 | } 72 | }, 73 | }) 74 | -------------------------------------------------------------------------------- /packages/core/src/components/Text.ts: -------------------------------------------------------------------------------- 1 | import chalk, { ForegroundColorName } from 'chalk' 2 | import { transformClassToStyleProps } from '../style-syntax' 3 | import { 4 | PropType, 5 | h, 6 | inject, 7 | defineComponent, 8 | onMounted, 9 | onUpdated, 10 | onUnmounted, 11 | computed, 12 | } from '@vue/runtime-core' 13 | import type { LiteralUnion } from '../utils' 14 | import type { Styles } from '../renderer/styles' 15 | import { scheduleUpdateSymbol } from '../injectionSymbols' 16 | import { colorize } from '../renderer/textColor' 17 | 18 | export const defaultStyle: Styles = { 19 | flexGrow: 0, 20 | flexShrink: 1, 21 | flexDirection: 'row', 22 | } 23 | 24 | export interface TuiTextProps { 25 | color?: LiteralUnion 26 | bgColor?: LiteralUnion 27 | dimmed?: boolean 28 | bold?: boolean 29 | italic?: boolean 30 | underline?: boolean 31 | strikethrough?: boolean 32 | inverse?: boolean 33 | wrap?: Styles['textWrap'] 34 | } 35 | 36 | function transform(props: TuiTextProps, text: string): string { 37 | if (props.dimmed) { 38 | text = chalk.dim(text) 39 | } 40 | if (props.color) { 41 | text = colorize(text, props.color, 'foreground') 42 | } 43 | 44 | if (props.bgColor) { 45 | text = colorize(text, props.bgColor, 'background') 46 | } 47 | 48 | if (props.bold) { 49 | text = chalk.bold(text) 50 | } 51 | 52 | if (props.italic) { 53 | text = chalk.italic(text) 54 | } 55 | 56 | if (props.underline) { 57 | text = chalk.underline(text) 58 | } 59 | 60 | if (props.strikethrough) { 61 | text = chalk.strikethrough(text) 62 | } 63 | 64 | if (props.inverse) { 65 | text = chalk.inverse(text) 66 | } 67 | 68 | return text 69 | } 70 | 71 | export const TuiText = defineComponent({ 72 | name: 'TuiText', 73 | 74 | props: { 75 | color: String as PropType>, 76 | bgColor: String as PropType>, 77 | dimmed: Boolean, 78 | bold: Boolean, 79 | italic: Boolean, 80 | underline: Boolean, 81 | strikethrough: Boolean, 82 | inverse: Boolean, 83 | wrap: String as PropType, 84 | class: String, 85 | }, 86 | 87 | setup(props, { slots }) { 88 | const propsWithClasses = computed(() => 89 | props.class 90 | ? { 91 | ...props, 92 | ...transformClassToStyleProps(props.class), 93 | } 94 | : props 95 | ) 96 | 97 | const scheduleUpdate = inject(scheduleUpdateSymbol)! 98 | 99 | onMounted(scheduleUpdate) 100 | 101 | onUpdated(scheduleUpdate) 102 | 103 | onUnmounted(scheduleUpdate) 104 | 105 | return () => { 106 | const propsWithClassesValue = propsWithClasses.value 107 | return h( 108 | 'tui:text', 109 | { 110 | style: { ...defaultStyle, textWrap: propsWithClassesValue.wrap }, 111 | internal_transform: (text: string) => 112 | transform(propsWithClassesValue, text), 113 | }, 114 | slots.default?.() 115 | ) 116 | } 117 | }, 118 | }) 119 | -------------------------------------------------------------------------------- /packages/core/src/components/TextTransform.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { h, inject, FunctionalComponent } from '@vue/runtime-core' 3 | import { scheduleUpdateSymbol } from '../injectionSymbols' 4 | import type { OutputTransformer } from '../renderer/Output' 5 | import { defaultStyle, TuiTextProps } from './Text' 6 | import { colorize } from '../renderer/textColor' 7 | 8 | /** 9 | * A Text Transforms allows modifying the text before it is written to the stdout while accounting for line breaks and 10 | * text wrapping. 11 | */ 12 | export const TuiTextTransform: FunctionalComponent< 13 | { 14 | transform?: OutputTransformer 15 | } & TuiTextProps 16 | > = (props, { slots }) => { 17 | const scheduleUpdate = inject(scheduleUpdateSymbol)! 18 | scheduleUpdate() 19 | // onUpdated(() => { 20 | // scheduleUpdate() 21 | // }) 22 | 23 | function transform(text: string): string { 24 | if (props.dimmed) { 25 | text = chalk.dim(text) 26 | } 27 | if (props.color) { 28 | text = colorize(text, props.color, 'foreground') 29 | } 30 | 31 | if (props.bgColor) { 32 | text = colorize(text, props.bgColor, 'background') 33 | } 34 | 35 | if (props.bold) { 36 | text = chalk.bold(text) 37 | } 38 | 39 | if (props.italic) { 40 | text = chalk.italic(text) 41 | } 42 | 43 | if (props.underline) { 44 | text = chalk.underline(text) 45 | } 46 | 47 | if (props.strikethrough) { 48 | text = chalk.strikethrough(text) 49 | } 50 | 51 | if (props.inverse) { 52 | text = chalk.inverse(text) 53 | } 54 | 55 | return text 56 | } 57 | return h( 58 | 'tui:text', 59 | { 60 | style: { ...defaultStyle, textWrap: props.wrap }, 61 | internal_transform: props.transform 62 | ? (text: string) => transform(props.transform!(text)) 63 | : transform, 64 | }, 65 | slots.default?.() 66 | ) 67 | } 68 | 69 | TuiTextTransform.displayName = 'TuiTextTransform' 70 | TuiTextTransform.props = [ 71 | 'color', 72 | 'bgColor', 73 | 'dimmend', 74 | 'bold', 75 | 'italic', 76 | 'underline', 77 | 'strikethrough', 78 | 'inverse', 79 | 'wrap', 80 | 'transform', 81 | ] 82 | -------------------------------------------------------------------------------- /packages/core/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { TuiText } from './Text' 2 | export { TuiTextTransform } from './TextTransform' 3 | export { TuiNewline } from './Newline' 4 | export { TuiApp } from './App' 5 | export { TuiBox } from './Box' 6 | export { TuiProgressBar } from './ProgressBar' 7 | 8 | export { TuiLink } from './Link' 9 | export { default as TuiInput } from './Input.vue' 10 | -------------------------------------------------------------------------------- /packages/core/src/composables/input.ts: -------------------------------------------------------------------------------- 1 | import { inject, onMounted, onUnmounted } from '@vue/runtime-core' 2 | import { checkCurrentInstance, noop } from '../utils' 3 | import { InputEventSetSymbol } from '../input/handling' 4 | import { InputDataEventHandler } from '../input/types' 5 | import { RemoveListener } from './keyboard' 6 | 7 | export function onInputData(handler: InputDataEventHandler): RemoveListener { 8 | if (!checkCurrentInstance('onInput')) return noop 9 | 10 | const inputEventSet = inject(InputEventSetSymbol)! 11 | 12 | onMounted(() => { 13 | inputEventSet.add(handler) 14 | }) 15 | const removeListener = () => { 16 | inputEventSet.delete(handler) 17 | } 18 | onUnmounted(removeListener) 19 | 20 | return removeListener 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/src/composables/keyboard.ts: -------------------------------------------------------------------------------- 1 | import { inject, onMounted, onUnmounted } from '@vue/runtime-core' 2 | import { KeyEventMapSymbol } from '../input/handling' 3 | import { 4 | KeyDataEventHandler, 5 | KeyDataEventHandlerFn, 6 | KeyDataEventKey, 7 | KeyDataEventRawHandlerFn, 8 | } from '../input/types' 9 | import { checkCurrentInstance, noop } from '../utils' 10 | 11 | export type RemoveListener = () => void 12 | 13 | export function onKeyData(handler: KeyDataEventRawHandlerFn): RemoveListener 14 | export function onKeyData( 15 | key: KeyDataEventKey | KeyDataEventKey[], 16 | handler: KeyDataEventHandlerFn 17 | ): RemoveListener 18 | export function onKeyData( 19 | keyOrHandler: KeyDataEventKey | KeyDataEventKey[] | KeyDataEventHandler, 20 | handler?: KeyDataEventHandler 21 | ): RemoveListener { 22 | if (!checkCurrentInstance('onInput')) return noop 23 | 24 | const keyEventMap = inject(KeyEventMapSymbol)! 25 | 26 | let keys: string[] 27 | 28 | if (typeof keyOrHandler === 'function') { 29 | keys = ['@any'] 30 | handler = keyOrHandler 31 | } else { 32 | keys = Array.isArray(keyOrHandler) ? keyOrHandler : [keyOrHandler] 33 | } 34 | 35 | for (const key of keys) { 36 | if (!keyEventMap.has(key)) { 37 | keyEventMap.set(key, new Set()) 38 | } 39 | } 40 | const listenersList = keys.map((key) => keyEventMap.get(key)!) 41 | 42 | onMounted(() => { 43 | listenersList.forEach((list) => list.add(handler!)) 44 | }) 45 | const removeListener = () => { 46 | listenersList.forEach((list) => list.delete(handler!)) 47 | } 48 | onUnmounted(removeListener) 49 | return removeListener 50 | } 51 | -------------------------------------------------------------------------------- /packages/core/src/composables/mouse.ts: -------------------------------------------------------------------------------- 1 | import { inject, onMounted, onUnmounted } from '@vue/runtime-core' 2 | import { checkCurrentInstance, noop } from '../utils' 3 | import { MouseEventMapSymbol } from '../input/handling' 4 | import { MouseEventType, MouseDataEventHandler } from '../input/types' 5 | import { RemoveListener } from './keyboard' 6 | 7 | export function onMouseData( 8 | type: MouseEventType, 9 | handler: MouseDataEventHandler 10 | ): RemoveListener 11 | export function onMouseData(handler: MouseDataEventHandler): RemoveListener 12 | export function onMouseData( 13 | typeOrHandler: MouseEventType | MouseDataEventHandler, 14 | handler?: MouseDataEventHandler 15 | ): RemoveListener { 16 | if (!checkCurrentInstance('onInput')) return noop 17 | 18 | const mouseEventMap = inject(MouseEventMapSymbol)! 19 | 20 | const type: MouseEventType = 21 | typeof typeOrHandler !== 'function' ? typeOrHandler : MouseEventType.any 22 | 23 | handler = handler || (typeOrHandler as MouseDataEventHandler) 24 | 25 | if (!mouseEventMap.has(type)) { 26 | mouseEventMap.set(type, new Set()) 27 | } 28 | 29 | const listener = mouseEventMap.get(type)! 30 | 31 | onMounted(() => { 32 | listener.add(handler!) 33 | }) 34 | const removeListener = () => { 35 | listener.delete(handler!) 36 | } 37 | onUnmounted(removeListener) 38 | return removeListener 39 | } 40 | -------------------------------------------------------------------------------- /packages/core/src/composables/screen.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted, ref, watch } from '@vue/runtime-core' 2 | import { MaybeRef } from '~/utils' 3 | import { useStdout } from './writeStreams' 4 | 5 | /** 6 | * Listen to resize events on the terminal. Automatically removes the event handler when unmounting. 7 | * 8 | * @param handler - callback 9 | * @returns a function to remove the listener 10 | */ 11 | export function onResize(handler: () => void) { 12 | const { stdout } = useStdout() 13 | 14 | onMounted(() => { 15 | stdout.on('resize', handler) 16 | }) 17 | function remove() { 18 | stdout.off('resize', handler) 19 | } 20 | 21 | onUnmounted(remove) 22 | 23 | return remove 24 | } 25 | 26 | export function useStdoutDimensions() { 27 | const { stdout } = useStdout() 28 | const width = ref(stdout.columns) 29 | const height = ref(stdout.rows) 30 | 31 | onResize(() => { 32 | width.value = stdout.columns 33 | height.value = stdout.rows 34 | }) 35 | 36 | return [width, height] 37 | } 38 | 39 | export function useTitle(initialTitle: MaybeRef = '') { 40 | const { stdout } = useStdout() 41 | const title = ref(initialTitle) 42 | 43 | function changeTitle(text: string) { 44 | setTitle(stdout, text) 45 | } 46 | 47 | watch(title, changeTitle, { immediate: !!title.value }) 48 | 49 | onUnmounted(() => { 50 | // resets the title on exit 51 | changeTitle('') 52 | }) 53 | } 54 | 55 | function setTitle(stdout: NodeJS.WriteStream, title: string) { 56 | stdout.write(`\x1b]0;${title}\x07`) 57 | } 58 | -------------------------------------------------------------------------------- /packages/core/src/composables/utils.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted } from '@vue/runtime-core' 2 | 3 | export function useInterval(fn: () => void, interval?: number) { 4 | let handle: ReturnType 5 | 6 | function restart() { 7 | stop() 8 | handle = setInterval(fn, interval) 9 | } 10 | function stop() { 11 | clearInterval(handle) 12 | } 13 | 14 | onMounted(restart) 15 | onUnmounted(stop) 16 | 17 | return { restart, stop } 18 | } 19 | 20 | export function useTimeout(fn: () => void, delay?: number) { 21 | let handle: ReturnType 22 | onMounted(() => { 23 | handle = setTimeout(fn, delay) 24 | }) 25 | onUnmounted(() => { 26 | clearTimeout(handle) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /packages/core/src/composables/writeStreams.ts: -------------------------------------------------------------------------------- 1 | import { inject, InjectionKey } from '@vue/runtime-core' 2 | 3 | export interface UseStdoutReturn { 4 | stdout: NodeJS.WriteStream 5 | write: NodeJS.WriteStream['write'] 6 | } 7 | export const stdoutSymbol = Symbol( 8 | 'vue-termui:stdout' 9 | ) as InjectionKey 10 | 11 | export function useStdout() { 12 | return inject(stdoutSymbol)! 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/src/deps/signal-exit/signals.ts: -------------------------------------------------------------------------------- 1 | // This is not the set of all possible signals. 2 | // 3 | // It IS, however, the set of all signals that trigger 4 | // an exit on either Linux or BSD systems. Linux is a 5 | // superset of the signal names supported on BSD, and 6 | // the unknown signals just fail to register, so we can 7 | // catch that easily enough. 8 | // 9 | // Don't bother with SIGKILL. It's uncatchable, which 10 | // means that we can't fire any callbacks anyway. 11 | // 12 | // If a user does happen to register a handler on a non- 13 | // fatal signal like SIGWINCH or something, and then 14 | // exit, it'll end up firing `process.emit('exit')`, so 15 | // the handler will be fired anyway. 16 | // 17 | // SIGBUS, SIGFPE, SIGSEGV and SIGILL, when not raised 18 | // artificially, inherently leave the process in a 19 | // state from which it is not safe to try and enter JS 20 | // listeners. 21 | 22 | export type Signal = 23 | | 'SIGABRT' 24 | | 'SIGALRM' 25 | | 'SIGHUP' 26 | | 'SIGINT' 27 | | 'SIGTERM' 28 | | string 29 | 30 | const signals: Signal[] = ['SIGABRT', 'SIGALRM', 'SIGHUP', 'SIGINT', 'SIGTERM'] 31 | 32 | if (typeof process !== 'undefined' && process.platform !== 'win32') { 33 | signals.push( 34 | 'SIGVTALRM', 35 | 'SIGXCPU', 36 | 'SIGXFSZ', 37 | 'SIGUSR2', 38 | 'SIGTRAP', 39 | 'SIGSYS', 40 | 'SIGQUIT', 41 | 'SIGIOT' 42 | // should detect profiler and enable/disable accordingly. 43 | // see #21 44 | // 'SIGPROF' 45 | ) 46 | } 47 | 48 | if (typeof process !== 'undefined' && process.platform === 'linux') { 49 | signals.push('SIGIO', 'SIGPOLL', 'SIGPWR', 'SIGSTKFLT', 'SIGUNUSED') 50 | } 51 | 52 | export default signals 53 | -------------------------------------------------------------------------------- /packages/core/src/errors/TuiError.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from '../deps/signal-exit' 2 | 3 | export class TuiError extends Error { 4 | code: number | null 5 | signal: Signal | null 6 | 7 | constructor(code: number | null, signal: Signal | null) { 8 | super(`Program Interrupted with "${signal}" ${code ? `(${code})` : ''}`) 9 | this.code = code 10 | this.signal = signal 11 | } 12 | 13 | static fromError(error: Error) { 14 | return new TuiError(null, `(${error.name}): ${error.message}`) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/core/src/focus/FocusManager.spec.ts: -------------------------------------------------------------------------------- 1 | import { createFocusManager } from './FocusManager' 2 | import { DOMElement, DOMNode, TextNode } from '../renderer/dom' 3 | import { 4 | ComponentInternalInstance, 5 | VNode, 6 | ref, 7 | ComputedRef, 8 | } from '@vue/runtime-core' 9 | import { Focusable } from './types' 10 | 11 | /** 12 | * create internal instance 13 | * @param el 14 | * @returns 15 | */ 16 | function focusable( 17 | _el: DOMNode | string, 18 | options: Partial | false = {} 19 | ): ComponentInternalInstance { 20 | const el = typeof _el === 'string' ? new TextNode(_el) : _el 21 | const vnode = { el } as VNode 22 | // @ts-expect-error: we only need the el for focus 23 | const instance = { vnode } as ComponentInternalInstance 24 | 25 | el.focusable = 26 | options === false 27 | ? null 28 | : { 29 | active: ref(false) as ComputedRef, 30 | disabled: ref(false), 31 | id: Symbol(), 32 | _i: instance, 33 | ...options, 34 | } 35 | 36 | return instance 37 | } 38 | 39 | focusable(new TextNode('a')) 40 | focusable('a') 41 | 42 | interface DOMTree extends Array {} 43 | 44 | function createFocusableTree( 45 | tree: DOMTree, 46 | parent: DOMElement = new DOMElement('tui:root') 47 | ) { 48 | tree.forEach((node) => { 49 | if (Array.isArray(node)) { 50 | parent.insertNode(createFocusableTree(node, new DOMElement('tui:box'))) 51 | } else { 52 | const ci = focusable(node) 53 | parent.insertNode(ci.vnode.el as DOMNode) 54 | } 55 | }) 56 | 57 | return parent 58 | } 59 | 60 | describe('FocusManager', () => { 61 | it('creates a focus manager', () => { 62 | const root = createFocusableTree([]) 63 | const fm = createFocusManager(root) 64 | 65 | expect(fm.activeElement.value).toBeNull() 66 | expect(fm.focusNext()).toBeFalsy() 67 | expect(fm.activeElement.value).toBeNull() 68 | expect(fm.focusPrevious()).toBeFalsy() 69 | expect(fm.activeElement.value).toBeNull() 70 | }) 71 | 72 | it('loops through one single item with next', () => { 73 | const root = createFocusableTree(['1']) 74 | const fm = createFocusManager(root) 75 | 76 | expect(fm.activeElement.value).toBeNull() 77 | expect(fm.focusNext()).toBeFalsy() 78 | expect(fm.activeElement.value).toBeNull() 79 | expect(fm.focusPrevious()).toBeFalsy() 80 | expect(fm.activeElement.value).toBeNull() 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /packages/core/src/focus/Focusable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | onUnmounted, 4 | ref, 5 | watch, 6 | isRef, 7 | getCurrentInstance, 8 | unref, 9 | onMounted, 10 | } from '@vue/runtime-core' 11 | import { checkCurrentInstance, MaybeRef } from '../utils' 12 | import { Focusable, FocusId } from './types' 13 | import { useFocusManager } from './FocusManager' 14 | 15 | export interface FocusableOptions { 16 | /** 17 | * Is initially active. Defaults to `false`. Use `FocusManager.focus()` to focus a specific element. 18 | */ 19 | active?: boolean 20 | 21 | /** 22 | * Is initially disabled. Defaults to `false` and can be changed. 23 | */ 24 | disabled?: MaybeRef 25 | 26 | /** 27 | * Unique `id` for the focusable element. Can be a `string` or a `symbol`. If none is provided, a `symbol` will be 28 | * created. 29 | */ 30 | id?: MaybeRef 31 | } 32 | 33 | export function useFocus({ 34 | active: startsActive, 35 | disabled: startsDisabled, 36 | id: idSource, 37 | }: FocusableOptions = {}): Focusable { 38 | if (!checkCurrentInstance('useFocus')) { 39 | throw new Error('Cannot create a focusable without an instance') 40 | } 41 | 42 | const instance = getCurrentInstance()! 43 | 44 | const { activeElement, _add, _remove, focus, _changeFocusableId } = 45 | useFocusManager() 46 | 47 | const id: MaybeRef = idSource ? ref(idSource) : Symbol() 48 | 49 | const active = computed(() => activeElement.value === focusable) 50 | 51 | const disabled = ref( 52 | isRef(startsDisabled) ? startsDisabled : !!startsDisabled 53 | ) 54 | watch(disabled, (disabled) => { 55 | if (disabled && active.value) { 56 | focus(null) 57 | } 58 | }) 59 | 60 | const focusable: Focusable = { 61 | disabled, 62 | id, 63 | active, 64 | _i: instance, 65 | } 66 | 67 | onMounted(() => { 68 | // handle the creation and removal of the focusable 69 | _add(focusable) 70 | // if the id can be changed, we need to adapt the internal map 71 | if (isRef(id)) { 72 | watch(id, _changeFocusableId) 73 | } 74 | 75 | // ensures active starts with the right value 76 | if (startsActive) { 77 | focus(unref(id)) 78 | } 79 | }) 80 | onUnmounted(() => { 81 | _remove(focusable) 82 | // this is okay because the focusable is being destroyed 83 | // @ts-expect-error: avoid cyclic references 84 | focusable._i = null 85 | }) 86 | 87 | return focusable 88 | } 89 | -------------------------------------------------------------------------------- /packages/core/src/focus/types.ts: -------------------------------------------------------------------------------- 1 | import { ComponentInternalInstance, ComputedRef, Ref } from '@vue/runtime-core' 2 | import { MaybeRef } from '../utils' 3 | 4 | export interface Focusable { 5 | active: ComputedRef 6 | disabled: Ref 7 | id: MaybeRef 8 | 9 | /** 10 | * Instance attached to the focusable 11 | * @internal 12 | */ 13 | _i: ComponentInternalInstance 14 | } 15 | 16 | /** 17 | * Types for the `id` of a Focusable element. 18 | */ 19 | export type FocusId = string | symbol 20 | -------------------------------------------------------------------------------- /packages/core/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare var __DEV__: boolean 2 | -------------------------------------------------------------------------------- /packages/core/src/hmr/client.ts: -------------------------------------------------------------------------------- 1 | // this should be used by the app 2 | import { TuiApp } from '../app/types' 3 | import { WebSocket } from 'ws' 4 | import { TuiError } from '../errors/TuiError' 5 | import { defineMessage, parseServerMessage } from './messages' 6 | import { WSS_PORT } from './common' 7 | 8 | export function setupHMRSocket( 9 | app: TuiApp, 10 | exitApp: (error?: TuiError) => void 11 | ) { 12 | const ws = new WebSocket(`ws://localhost:${WSS_PORT}`) 13 | 14 | ws.on('message', (buf) => { 15 | const data = parseServerMessage(buf) 16 | if (!data) return 17 | 18 | const { type, payload } = data 19 | 20 | switch (type) { 21 | default: 22 | console.error('Unknown message from server', type, payload) 23 | } 24 | }) 25 | 26 | app 27 | .waitUntilExit() 28 | .catch((error: Error) => { 29 | ws.send(defineMessage({ type: 'crash', payload: error })) 30 | }) 31 | .then(() => { 32 | ws.close() 33 | }) 34 | 35 | ws.on('close', () => { 36 | // the server closed the connection to likely restart the app 37 | exitApp() 38 | }) 39 | 40 | // TODO: is this necessary? 41 | ws.on('error', (error) => { 42 | console.error('It looks like the dev server crashed... Stopping the app...') 43 | exitApp(TuiError.fromError(error)) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /packages/core/src/hmr/common.ts: -------------------------------------------------------------------------------- 1 | export const WSS_PORT = Number(process.env.PORT) || 3000 2 | -------------------------------------------------------------------------------- /packages/core/src/hmr/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common' 2 | export * from './client' 3 | export * from './messages' 4 | -------------------------------------------------------------------------------- /packages/core/src/hmr/messages.ts: -------------------------------------------------------------------------------- 1 | import type { RawData } from 'ws' 2 | 3 | function parseMessage(buffer: RawData): TuiWSMessage | null { 4 | let data: TuiWSMessage | null | undefined 5 | try { 6 | data = JSON.parse(buffer.toString()) 7 | } catch (error) { 8 | console.error('Malformed message from server:', buffer.toString()) 9 | return null 10 | } 11 | 12 | if (!data?.type || !data?.payload) { 13 | console.error('Ignoring message from server:', data) 14 | return null 15 | } 16 | 17 | return data 18 | } 19 | 20 | // Convenience types for parsing messages 21 | 22 | export function parseClientMessage(buffer: RawData): TuiWSMessageClient | null { 23 | return parseMessage(buffer) as TuiWSMessageClient 24 | } 25 | 26 | export function parseServerMessage(buffer: RawData): TuiWSMessageServer | null { 27 | return parseMessage(buffer) as TuiWSMessageServer 28 | } 29 | 30 | export interface TuiWSMessage_Base { 31 | type: string 32 | payload: any 33 | } 34 | 35 | export interface TuiWsMessage_Crash { 36 | type: 'crash' 37 | payload: Error 38 | } 39 | 40 | export interface TuiWsMessage_Restart { 41 | type: 'restart' 42 | payload: null 43 | } 44 | 45 | /** 46 | * A message coming from the server. 47 | */ 48 | export type TuiWSMessageClient = TuiWsMessage_Crash 49 | 50 | /** 51 | * A message coming from the client. 52 | */ 53 | export type TuiWSMessageServer = TuiWsMessage_Restart 54 | 55 | /** 56 | * A message coming from the server or the client. Used for types mostly 57 | */ 58 | export type TuiWSMessage = TuiWSMessageServer | TuiWSMessageClient 59 | 60 | /** 61 | * Define a message to be sent over a WebSocket connection. 62 | * 63 | * @param message - message object 64 | * @returns a JSON version of the message ready to send 65 | */ 66 | export function defineMessage(message: T): string { 67 | return JSON.stringify(message) 68 | } 69 | -------------------------------------------------------------------------------- /packages/core/src/hmr/server.ts: -------------------------------------------------------------------------------- 1 | // this should be used by the dev server 2 | -------------------------------------------------------------------------------- /packages/core/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createApp, 3 | defineComponent, 4 | h, 5 | TuiBox, 6 | TuiText, 7 | ref, 8 | onKeyData, 9 | nextTick, 10 | } from './index' 11 | import { createStdin, createStdout } from './mocks/stdmock' 12 | 13 | // TODO: nextTick should schedule a render or force it and await for the render 14 | const delay = (t: number) => new Promise((r) => setTimeout(r, t)) 15 | 16 | describe('Full App', () => { 17 | const App = defineComponent({ 18 | setup() { 19 | const n = ref(0) 20 | 21 | onKeyData('A', () => n.value++) 22 | 23 | return () => 24 | h( 25 | TuiBox, 26 | {}, 27 | { 28 | default: () => 29 | h(TuiText, {}, { default: () => 'Hello ' + n.value }), 30 | } 31 | ) 32 | }, 33 | }) 34 | 35 | it('works', async () => { 36 | const stdout = createStdout() 37 | const stderr = createStdout() 38 | const stdin = createStdin() 39 | 40 | const app = createApp(App, { 41 | stdout, 42 | stdin, 43 | stderr, 44 | }) 45 | 46 | app.mount() 47 | 48 | expect(stdout.write).toHaveBeenLastCalledWith( 49 | expect.stringContaining('Hello 0') 50 | ) 51 | 52 | stdin.emit('data', 'A') 53 | await nextTick() 54 | // TODO: we probably need a custom nextTick that triggers after a render 55 | await delay(40) 56 | 57 | expect(stdout.write).toHaveBeenLastCalledWith( 58 | expect.stringContaining('Hello 1') 59 | ) 60 | app.unmount() 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { TuiError } from './errors/TuiError' 2 | 3 | // all but TuiApp 4 | export { 5 | TuiBox, 6 | TuiText, 7 | TuiNewline, 8 | TuiLink, 9 | TuiTextTransform, 10 | TuiInput, 11 | TuiProgressBar, 12 | } from './components' 13 | 14 | export { render } from './renderer' 15 | export { createApp, exitApp } from './app/createApp' 16 | export type { TuiApp } from './app/types' 17 | 18 | export { 19 | useLog, 20 | logSymbol, 21 | useRootNode, 22 | rootNodeSymbol, 23 | } from './injectionSymbols' 24 | 25 | export * from './components' 26 | 27 | // re-export Vue core APIs 28 | export * from '@vue/runtime-core' 29 | 30 | export { onKeyData } from './composables/keyboard' 31 | export { onMouseData } from './composables/mouse' 32 | export { onInputData } from './composables/input' 33 | export { useInterval, useTimeout } from './composables/utils' 34 | export { onResize, useStdoutDimensions, useTitle } from './composables/screen' 35 | export { useStdout } from './composables/writeStreams' 36 | export type { UseStdoutReturn } from './composables/writeStreams' 37 | 38 | export { useDebugLog } from './utils' 39 | 40 | export { useFocus } from './focus/Focusable' 41 | export { useFocusManager } from './focus/FocusManager' 42 | export type { FocusableOptions } from './focus/Focusable' 43 | export type { FocusId, Focusable } from './focus/types' 44 | export type { FocusManager, FocusManagerOptions } from './focus/FocusManager' 45 | 46 | export { inputDataToString } from './input/debug' 47 | export { 48 | MouseEventType, 49 | isInputDataEvent, 50 | isKeyDataEvent, 51 | isMouseDataEvent, 52 | } from './input/types' 53 | export type { 54 | InputDataEventHandler, 55 | KeyDataEventHandler, 56 | KeyDataEventKeyCode, 57 | KeyDataEventRawHandlerFn, 58 | KeyDataEventHandlerFn, 59 | KeyDataEvent, 60 | KeyDataEventRaw, 61 | MouseDataEvent, 62 | MouseDataEventHandler, 63 | MouseEventButton, 64 | _InputDataEventModifiers, 65 | } from './input/types' 66 | 67 | export { defineMessage, parseClientMessage, parseServerMessage } from './hmr' 68 | export type { 69 | TuiWSMessageClient, 70 | TuiWSMessageServer, 71 | TuiWSMessage, 72 | TuiWsMessage_Crash, 73 | TuiWsMessage_Restart, 74 | } from './hmr' 75 | -------------------------------------------------------------------------------- /packages/core/src/injectionSymbols.ts: -------------------------------------------------------------------------------- 1 | import { InjectionKey, inject } from '@vue/runtime-core' 2 | import { DOMElement } from './renderer/dom' 3 | import { LogUpdate } from './renderer/LogUpdate' 4 | 5 | export const logSymbol = Symbol('vue-termui:log') as InjectionKey 6 | 7 | export function useLog() { 8 | // TODO: add globally to avoid inject problem 9 | return inject(logSymbol)! 10 | } 11 | 12 | export const rootNodeSymbol = Symbol( 13 | 'vue-termui:root-node' 14 | ) as InjectionKey 15 | export function useRootNode() { 16 | return inject(rootNodeSymbol)! 17 | } 18 | 19 | export const scheduleUpdateSymbol = Symbol( 20 | 'vue-termui:scheduleUpdate' 21 | ) as InjectionKey<() => void> 22 | 23 | export const renderOnceSymbol = Symbol( 24 | 'vue-termui:render-once' 25 | ) as InjectionKey 26 | -------------------------------------------------------------------------------- /packages/core/src/input/debug.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Debugs an escape code 3 | * @param c - char 4 | * @returns a display friendly ansi string 5 | */ 6 | function displayableChar(c: string) { 7 | const i = c.charCodeAt(0) 8 | if ( 9 | // Ansi readable characters 10 | i >= 0x20 && 11 | i <= 0x84 && 12 | i !== 0x7f && 13 | i !== 0x83 14 | ) { 15 | return c 16 | } 17 | 18 | if (i <= 0xff) { 19 | return `\\x${i.toString(16).padStart(2, '0')}` 20 | } else { 21 | return `\\u${i.toString(16).padStart(4, '0')}` 22 | } 23 | } 24 | 25 | /** 26 | * Converts a raw input with escapes codes into a printable representation of the string to debug. 27 | * 28 | * @param input - raw data from an `InputEvent` 29 | * @returns a printable representation of the data 30 | */ 31 | export function inputDataToString(input: string) { 32 | return input.split('').map(displayableChar).join('') 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/src/input/handling.ts: -------------------------------------------------------------------------------- 1 | import { InjectionKey, App } from '@vue/runtime-core' 2 | import { SPECIAL_INPUT_KEY_TABLE, parseInputSequence } from './inputSequences' 3 | import { 4 | KeyDataEventHandler, 5 | MouseEventType, 6 | MouseDataEventHandler, 7 | isMouseDataEvent, 8 | isKeyDataEvent, 9 | KeyDataEventHandlerFn, 10 | KeyDataEventRawHandlerFn, 11 | InputDataEventHandler, 12 | } from './types' 13 | 14 | export interface InputHandlerOptions { 15 | setRawMode: (enabled: boolean) => void 16 | } 17 | 18 | export const KeyEventMapSymbol = Symbol() as InjectionKey< 19 | Map> 20 | > 21 | 22 | export const MouseEventMapSymbol = Symbol() as InjectionKey< 23 | Map> 24 | > 25 | 26 | export const InputEventSetSymbol = Symbol() as InjectionKey< 27 | Set 28 | > 29 | 30 | export function attachInputHandler( 31 | app: App, 32 | stdin: NodeJS.ReadStream, 33 | { setRawMode }: InputHandlerOptions 34 | ) { 35 | const keyEventMap = new Map>([ 36 | // create an any handler 37 | ['@any', new Set()], 38 | ]) 39 | 40 | const mouseEventMap = new Map>([ 41 | // create an any handler 42 | [MouseEventType.any, new Set()], 43 | ]) 44 | 45 | const inputEventSet = new Set() 46 | 47 | app.provide(KeyEventMapSymbol, keyEventMap) 48 | app.provide(MouseEventMapSymbol, mouseEventMap) 49 | app.provide(InputEventSetSymbol, inputEventSet) 50 | 51 | function handleOnData(data: Buffer) { 52 | const input = String(data) 53 | 54 | const specialEvent = SPECIAL_INPUT_KEY_TABLE.get(input) 55 | 56 | const events = specialEvent ? [specialEvent] : parseInputSequence(input) 57 | 58 | for (const event of events) { 59 | if (isMouseDataEvent(event)) { 60 | if (mouseEventMap.has(event._type)) { 61 | mouseEventMap.get(event._type)!.forEach((handler) => { 62 | handler(event) 63 | }) 64 | } 65 | mouseEventMap.get(MouseEventType.any)!.forEach((handler) => { 66 | // handler(event) 67 | // TODO: remove or add input to all events? 68 | handler({ 69 | ...event, 70 | // @ts-expect-error: useful for debugging 71 | input, 72 | }) 73 | }) 74 | } else if (isKeyDataEvent(event)) { 75 | if (keyEventMap.has(event.key)) { 76 | keyEventMap.get(event.key)!.forEach((handler) => { 77 | ;(handler as KeyDataEventHandlerFn)(event) 78 | }) 79 | } 80 | keyEventMap.get('@any')!.forEach((handler) => { 81 | ;(handler as KeyDataEventRawHandlerFn)({ 82 | input, 83 | ...event, 84 | }) 85 | }) 86 | } 87 | inputEventSet.forEach((handler) => { 88 | handler({ data: input, event }) 89 | }) 90 | } 91 | } 92 | 93 | stdin.on('data', handleOnData) 94 | setRawMode(true) 95 | 96 | return () => { 97 | setRawMode(false) 98 | stdin.off('data', handleOnData) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/core/src/input/keyEvents.ts: -------------------------------------------------------------------------------- 1 | import type { LiteralUnion } from '../utils' 2 | import type { 3 | KeyDataEventKeyCode, 4 | KeyDataEvent, 5 | _InputDataEventModifiers, 6 | MouseEventButton, 7 | MouseDataEvent, 8 | MouseEventType, 9 | } from './types' 10 | 11 | /** 12 | * Conveniently define a keypress. 13 | * 14 | * @param key - key to define the keypress 15 | * @param modifiers - optional modifiers 16 | * @returns 17 | */ 18 | export function defineKeypressEvent( 19 | key: LiteralUnion, 20 | modifiers?: Partial<_InputDataEventModifiers> 21 | ): KeyDataEvent { 22 | return { 23 | key, 24 | altKey: false, 25 | shiftKey: false, 26 | ctrlKey: false, 27 | metaKey: false, 28 | ...modifiers, 29 | } 30 | } 31 | 32 | /** 33 | * Conveniently define a mouse event. 34 | * 35 | * @param key - key to define the keypress 36 | * @param modifiers - optional modifiers 37 | * @returns 38 | */ 39 | export function defineMouseEvent( 40 | button: MouseEventButton, 41 | clientX: number, 42 | clientY: number, 43 | _type: MouseEventType, 44 | modifiers?: Partial<_InputDataEventModifiers> 45 | ): MouseDataEvent { 46 | return { 47 | button, 48 | _type, 49 | clientX, 50 | clientY, 51 | altKey: false, 52 | shiftKey: false, 53 | ctrlKey: false, 54 | metaKey: false, 55 | ...modifiers, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/core/src/mocks/stdmock.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events' 2 | import { SpyInstance } from 'vitest' 3 | 4 | export function createStdout() { 5 | return new StdoutMock() as unknown as WithMocks< 6 | NodeJS.WriteStream, 7 | 'write' | 'cursorTo' 8 | > 9 | } 10 | export function createStdin() { 11 | return new StdinMock() as unknown as WithMocks< 12 | NodeJS.ReadStream, 13 | 'resume' | 'pause' | 'setEncoding' | 'setRawMode' 14 | > 15 | } 16 | 17 | class StdoutMock extends EventEmitter { 18 | columns: number = 80 19 | rows: number = 24 20 | 21 | // mocked functions 22 | write = vi.fn< 23 | Parameters, 24 | ReturnType 25 | >() 26 | cursorTo = vi.fn< 27 | Parameters, 28 | ReturnType 29 | >() 30 | 31 | constructor() { 32 | super() 33 | } 34 | } 35 | 36 | class StdinMock extends EventEmitter { 37 | isTTY = true 38 | 39 | resume = vi.fn< 40 | Parameters, 41 | ReturnType 42 | >() 43 | pause = vi.fn< 44 | Parameters, 45 | ReturnType 46 | >() 47 | setEncoding = vi.fn< 48 | Parameters, 49 | ReturnType 50 | >() 51 | setRawMode = vi.fn< 52 | Parameters, 53 | ReturnType 54 | >() 55 | 56 | constructor() { 57 | super() 58 | } 59 | } 60 | 61 | type WithMocks = T & { 62 | [k in K as T[k] extends (...args: any[]) => any ? k : never]: T[k] extends ( 63 | ...args: any[] 64 | ) => any 65 | ? SpyInstance, ReturnType> & { 66 | calls: Parameters[] 67 | results: ReturnType[] 68 | } 69 | : T[k] 70 | } 71 | -------------------------------------------------------------------------------- /packages/core/src/renderer/LogUpdate.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'stream' 2 | import ansiEscapes from 'ansi-escapes' 3 | import cliCursor from 'cli-cursor' 4 | 5 | export interface LogUpdate { 6 | clear: () => void 7 | done: () => void 8 | (text: string): void 9 | } 10 | 11 | export function createLog( 12 | stream: Writable, 13 | { showCursor = false } = {} 14 | ): LogUpdate { 15 | let previousLineCount = 0 16 | let previousOutput = '' 17 | let hasHiddenCursor = false 18 | 19 | const render = (text: string) => { 20 | if (!showCursor && !hasHiddenCursor) { 21 | cliCursor.hide() 22 | hasHiddenCursor = true 23 | } 24 | 25 | const output = text + '\n' 26 | if (output === previousOutput) { 27 | return 28 | } 29 | 30 | previousOutput = output 31 | stream.write(ansiEscapes.eraseLines(previousLineCount) + output) 32 | previousLineCount = output.split('\n').length 33 | } 34 | 35 | render.clear = () => { 36 | stream.write(ansiEscapes.eraseLines(previousLineCount)) 37 | previousOutput = '' 38 | previousLineCount = 0 39 | } 40 | 41 | render.done = () => { 42 | previousOutput = '' 43 | previousLineCount = 0 44 | 45 | if (!showCursor) { 46 | cliCursor.show() 47 | hasHiddenCursor = false 48 | } 49 | } 50 | 51 | return render 52 | } 53 | -------------------------------------------------------------------------------- /packages/core/src/renderer/Output.ts: -------------------------------------------------------------------------------- 1 | import sliceAnsi from 'slice-ansi' 2 | import stringWidth from 'string-width' 3 | // import { OutputTransformer } from './render-node-to-output' 4 | export type OutputTransformer = (s: string) => string 5 | 6 | /** 7 | * "Virtual" output class 8 | * 9 | * Handles the positioning and saving of the output of each node in the tree. 10 | * Also responsible for applying transformations to each character of the output. 11 | * 12 | * Used to generate the final output of all nodes before writing it to actual output stream (e.g. stdout) 13 | */ 14 | 15 | interface Options { 16 | width: number 17 | height: number 18 | } 19 | 20 | interface Writes { 21 | x: number 22 | y: number 23 | text: string 24 | transformers: OutputTransformer[] 25 | } 26 | 27 | export class Output { 28 | width: number 29 | height: number 30 | 31 | // Initialize output array with a specific set of rows, so that margin/padding at the bottom is preserved 32 | private readonly writes: Writes[] = [] 33 | 34 | constructor(options: Options) { 35 | const { width, height } = options 36 | 37 | this.width = width 38 | this.height = height 39 | } 40 | 41 | write( 42 | x: number, 43 | y: number, 44 | text: string, 45 | options: { transformers: OutputTransformer[] } 46 | ): void { 47 | const { transformers } = options 48 | 49 | if (!text) { 50 | return 51 | } 52 | 53 | this.writes.push({ x, y, text, transformers }) 54 | } 55 | 56 | get(): { output: string; height: number } { 57 | const output: string[] = [] 58 | 59 | for (let y = 0; y < this.height; y++) { 60 | output.push(' '.repeat(this.width)) 61 | } 62 | 63 | for (const write of this.writes) { 64 | const { x, y, text, transformers } = write 65 | // TODO: can a loop with indexOf('\n', lastIndex) be faster? 66 | const lines = text.split('\n') 67 | let offsetY = 0 68 | 69 | for (let line of lines) { 70 | const currentLine = output[y + offsetY] 71 | 72 | // Line can be missing if `text` is taller than height of pre-initialized `this.output` 73 | if (!currentLine) { 74 | continue 75 | } 76 | 77 | const width = stringWidth(line) 78 | 79 | for (const transformer of transformers) { 80 | line = transformer(line) 81 | } 82 | 83 | output[y + offsetY] = 84 | sliceAnsi(currentLine, 0, x) + 85 | line + 86 | sliceAnsi(currentLine, x + width) 87 | 88 | offsetY++ 89 | } 90 | } 91 | 92 | const generatedOutput = output.map((line) => line.trimEnd()).join('\n') 93 | 94 | return { 95 | output: generatedOutput, 96 | height: output.length, 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /packages/core/src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import { createRenderer } from '@vue/runtime-core' 2 | import { DOMElement, DOMNode } from './dom' 3 | import { 4 | createComment, 5 | createElement, 6 | createText, 7 | insert, 8 | nextSibling, 9 | parentNode, 10 | patchProp, 11 | remove, 12 | setElementText, 13 | setText, 14 | } from './nodeOpts' 15 | 16 | export const { render, createApp: baseCreateApp } = createRenderer< 17 | DOMNode, 18 | DOMElement 19 | >({ 20 | insert, 21 | remove, 22 | 23 | patchProp, 24 | setElementText, 25 | setText, 26 | 27 | nextSibling, 28 | parentNode, 29 | 30 | createElement, 31 | createText, 32 | createComment, 33 | 34 | cloneNode(node) { 35 | return node.clone() 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /packages/core/src/renderer/render.ts: -------------------------------------------------------------------------------- 1 | import Yoga from 'yoga-layout-prebuilt' 2 | import { DOMElement } from './dom' 3 | import renderNodeToOutput from './renderNodeToOutput' 4 | import { Output } from './Output' 5 | 6 | interface Result { 7 | output: string 8 | outputHeight: number 9 | staticOutput: string 10 | } 11 | 12 | export function renderRoot(node: DOMElement, terminalWidth: number): Result { 13 | // console.log('renderRoot', node) 14 | node.yogaNode!.setWidth(terminalWidth) 15 | 16 | if (node.yogaNode) { 17 | node.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR) 18 | 19 | const output = new Output({ 20 | width: node.yogaNode.getComputedWidth(), 21 | height: node.yogaNode.getComputedHeight(), 22 | }) 23 | 24 | renderNodeToOutput(node, output, { skipStaticElements: true }) 25 | 26 | let staticOutput 27 | 28 | if (node.staticNode?.yogaNode) { 29 | staticOutput = new Output({ 30 | width: node.staticNode.yogaNode.getComputedWidth(), 31 | height: node.staticNode.yogaNode.getComputedHeight(), 32 | }) 33 | 34 | renderNodeToOutput(node.staticNode, staticOutput, { 35 | skipStaticElements: false, 36 | }) 37 | } 38 | 39 | const { output: generatedOutput, height: outputHeight } = output.get() 40 | 41 | return { 42 | output: generatedOutput, 43 | outputHeight, 44 | // Newline at the end is needed, because static output doesn't have one, so 45 | // interactive output will override last line of static output 46 | staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '', 47 | } 48 | } 49 | 50 | return { 51 | output: '', 52 | outputHeight: 0, 53 | staticOutput: '', 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/core/src/renderer/renderBorders.ts: -------------------------------------------------------------------------------- 1 | import cliBoxes from 'cli-boxes' 2 | import { colorize } from './textColor' 3 | import { DOMElement } from './dom' 4 | import { Output } from './Output' 5 | import stringWidth from 'string-width' 6 | 7 | export function renderBorders( 8 | x: number, 9 | y: number, 10 | node: DOMElement, // TODO: should be a tui:box 11 | output: Output 12 | ) { 13 | if (typeof node.style.borderStyle === 'string' && node.style.borderStyle) { 14 | const width = node.yogaNode!.getComputedWidth() 15 | const height = node.yogaNode!.getComputedHeight() 16 | const color = node.style.borderColor 17 | const box = cliBoxes[node.style.borderStyle] 18 | const title = node.style.title ?? '' 19 | 20 | /** 21 | * TODO: avoid calling stringWidth twice (also in output). Probably box outputting should be refactored 22 | */ 23 | const textWidth = title ? stringWidth(title) : 0 24 | 25 | const topBorder = colorize( 26 | box.topLeft + 27 | title + 28 | box.top.repeat(width - 2 - textWidth) + 29 | box.topRight, 30 | color, 31 | 'foreground' 32 | ) 33 | 34 | const rightBorder = ( 35 | colorize(box.right, color, 'foreground') + '\n' 36 | ).repeat(height - 2) 37 | 38 | const leftBorder = (colorize(box.left, color, 'foreground') + '\n').repeat( 39 | height - 2 40 | ) 41 | 42 | const bottomBorder = colorize( 43 | box.bottomLeft + box.bottom.repeat(width - 2) + box.bottomRight, 44 | color, 45 | 'foreground' 46 | ) 47 | 48 | output.write(x, y, topBorder, { transformers: [] }) 49 | output.write(x, y + 1, leftBorder, { transformers: [] }) 50 | output.write(x + width - 1, y + 1, rightBorder, { transformers: [] }) 51 | output.write(x, y + height - 1, bottomBorder, { transformers: [] }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/core/src/renderer/renderNodeToOutput.ts: -------------------------------------------------------------------------------- 1 | import Yoga from 'yoga-layout-prebuilt' 2 | import { DOMNode, getMaxWidth } from './dom' 3 | import widestLine from 'widest-line' 4 | import indentString from 'indent-string' 5 | import { wrapText, squashTextNodes } from './text' 6 | // import renderBorder from './render-border' 7 | import { Output, OutputTransformer } from './Output' 8 | import { renderBorders } from './renderBorders' 9 | 10 | // If parent container is ``, text nodes will be treated as separate nodes in 11 | // the tree and will have their own coordinates in the layout. 12 | // To ensure text nodes are aligned correctly, take X and Y of the first text node 13 | // and use it as offset for the rest of the nodes 14 | // Only first node is taken into account, because other text nodes can't have margin or padding, 15 | // so their coordinates will be relative to the first node anyway 16 | const applyPaddingToText = (node: DOMNode, text: string): string => { 17 | if ('childNodes' in node && node.childNodes[0]?.yogaNode) { 18 | const yogaNode = node.childNodes[0]?.yogaNode 19 | const offsetX = yogaNode.getComputedLeft() 20 | const offsetY = yogaNode.getComputedTop() 21 | text = '\n'.repeat(offsetY) + indentString(text, offsetX) 22 | } 23 | 24 | return text 25 | } 26 | 27 | // After nodes are laid out, render each to output object, which later gets rendered to terminal 28 | const renderNodeToOutput = ( 29 | node: DOMNode, 30 | output: Output, 31 | options: { 32 | offsetX?: number 33 | offsetY?: number 34 | transformers?: OutputTransformer[] 35 | skipStaticElements: boolean 36 | } 37 | ) => { 38 | const { 39 | offsetX = 0, 40 | offsetY = 0, 41 | transformers = [], 42 | skipStaticElements, 43 | } = options 44 | 45 | if (skipStaticElements && node.internal_static) { 46 | return 47 | } 48 | 49 | const { yogaNode } = node 50 | 51 | if (yogaNode) { 52 | if (yogaNode.getDisplay() === Yoga.DISPLAY_NONE) { 53 | return 54 | } 55 | 56 | // Left and top positions in Yoga are relative to their parent node 57 | const x = offsetX + yogaNode.getComputedLeft() 58 | const y = offsetY + yogaNode.getComputedTop() 59 | 60 | // Transformers are functions that transform final text output of each component 61 | // See Output class for logic that applies transformers 62 | let newTransformers = transformers 63 | 64 | if (typeof node.internal_transform === 'function') { 65 | newTransformers = [node.internal_transform, ...transformers] 66 | } 67 | 68 | if (node.nodeName === 'tui:text') { 69 | let text = squashTextNodes(node) 70 | 71 | if (text.length > 0) { 72 | const currentWidth = widestLine(text) 73 | const maxWidth = getMaxWidth(yogaNode) 74 | 75 | if (currentWidth > maxWidth) { 76 | const textWrap = node.style.textWrap ?? 'wrap' 77 | text = wrapText(text, maxWidth, textWrap) 78 | } 79 | 80 | text = applyPaddingToText(node, text) 81 | output.write(x, y, text, { transformers: newTransformers }) 82 | } 83 | 84 | return 85 | } 86 | 87 | if (node.nodeName === 'tui:box') { 88 | renderBorders(x, y, node, output) 89 | } 90 | 91 | if (node.nodeName === 'tui:root' || node.nodeName === 'tui:box') { 92 | for (const childNode of node.childNodes) { 93 | renderNodeToOutput(childNode, output, { 94 | offsetX: x, 95 | offsetY: y, 96 | transformers: newTransformers, 97 | skipStaticElements, 98 | }) 99 | } 100 | } 101 | } 102 | } 103 | 104 | export default renderNodeToOutput 105 | -------------------------------------------------------------------------------- /packages/core/src/renderer/textColor.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import type { ForegroundColorName } from 'chalk' 3 | type ColorType = 'foreground' | 'background' 4 | import type { LiteralUnion } from '../utils' 5 | 6 | export type ForegroundColorProp = LiteralUnion 7 | 8 | const RGB_LIKE_REGEX = /^(rgb|hsl|hsv|hwb)\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/ 9 | const ANSI_REGEX = /^(ansi|ansi256)\(\s?(\d+)\s?\)$/ 10 | 11 | const getMethod = (name: string, type: ColorType): string => { 12 | if (type === 'foreground') { 13 | return name 14 | } 15 | 16 | return 'bg' + name[0].toUpperCase() + name.slice(1) 17 | } 18 | 19 | export function colorize( 20 | str: string, 21 | color: string | undefined, 22 | type: ColorType 23 | ): string { 24 | if (!color) { 25 | return str 26 | } 27 | 28 | if (color in chalk) { 29 | const method = getMethod(color, type) 30 | return (chalk as any)[method](str) 31 | } 32 | 33 | if (color.startsWith('#')) { 34 | const method = getMethod('hex', type) 35 | return (chalk as any)[method](color)(str) 36 | } 37 | 38 | if (color.startsWith('ansi')) { 39 | const matches = ANSI_REGEX.exec(color) 40 | 41 | if (!matches) { 42 | return str 43 | } 44 | 45 | const method = getMethod(matches[1], type) 46 | const value = Number(matches[2]) 47 | 48 | return (chalk as any)[method](value)(str) 49 | } 50 | 51 | const isRgbLike = 52 | color.startsWith('rgb') || 53 | color.startsWith('hsl') || 54 | color.startsWith('hsv') || 55 | color.startsWith('hwb') 56 | 57 | if (isRgbLike) { 58 | const matches = RGB_LIKE_REGEX.exec(color) 59 | 60 | if (!matches) { 61 | return str 62 | } 63 | 64 | const method = getMethod(matches[1], type) 65 | const firstValue = Number(matches[2]) 66 | const secondValue = Number(matches[3]) 67 | const thirdValue = Number(matches[4]) 68 | 69 | return (chalk as any)[method](firstValue, secondValue, thirdValue)(str) 70 | } 71 | 72 | return str 73 | } 74 | -------------------------------------------------------------------------------- /packages/core/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { DefineComponent } from '@vue/runtime-core' 3 | const component: DefineComponent<{}, {}, any> 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /packages/core/src/style-syntax/alias.ts: -------------------------------------------------------------------------------- 1 | export const specialAliases: Record> = { 2 | // Box Component 3 | 'flex-row': { flexDirection: 'row' }, 4 | 'flex-row-reverse': { flexDirection: 'row-reverse' }, 5 | 'flex-col': { flexDirection: 'column' }, 6 | 'flex-col-reverse': { flexDirection: 'column-reverse' }, 7 | 'items-start': { alignItems: 'flex-start' }, 8 | 'items-end': { alignItems: 'flex-end' }, 9 | 'items-center': { alignItems: 'center' }, 10 | 'items-stretch': { alignItems: 'stretch' }, 11 | 'self-start': { alignSelf: 'flex-start' }, 12 | 'self-end': { alignSelf: 'flex-end' }, 13 | 'self-center': { alignSelf: 'center' }, 14 | 'self-auto': { alignSelf: 'auto' }, 15 | 'justify-start': { justifyContent: 'flex-start' }, 16 | 'justify-end': { justifyContent: 'flex-end' }, 17 | 'justify-center': { justifyContent: 'center' }, 18 | 'justify-between': { justifyContent: 'space-between' }, 19 | 'justify-around': { justifyContent: 'space-around' }, 20 | 'border-single': { borderStyle: 'single' }, 21 | 'border-double': { borderStyle: 'double' }, 22 | 'border-round': { borderStyle: 'round' }, 23 | 'border-bold': { borderStyle: 'bold' }, 24 | 'border-single-double': { borderStyle: 'singleDouble' }, 25 | 'border-double-single': { borderStyle: 'doubleSingle' }, 26 | 'border-classic': { borderStyle: 'classic' }, 27 | 'border-arrow': { borderStyle: 'arrow' }, 28 | 29 | // Text Component 30 | 'text-wrap': { wrap: 'wrap' }, 31 | 'text-end': { wrap: 'end' }, 32 | 'text-truncate': { wrap: 'truncate' }, 33 | 'text-truncate-end': { wrap: 'truncate-end' }, 34 | 'text-truncate-middle': { wrap: 'truncate-middle' }, 35 | 'text-truncate-start': { wrap: 'truncate-start' }, 36 | } 37 | 38 | export const aliases: Record = { 39 | // Box Component 40 | top: 'top', 41 | right: 'right', 42 | bottom: 'bottom', 43 | left: 'left', 44 | m: 'margin', 45 | mx: 'marginX', 46 | my: 'marginY', 47 | mt: 'marginTop', 48 | mr: 'marginRight', 49 | mb: 'marginBottom', 50 | ml: 'marginLeft', 51 | p: 'padding', 52 | px: 'paddingX', 53 | py: 'paddingY', 54 | pt: 'paddingTop', 55 | pr: 'paddingRight', 56 | pb: 'paddingBottom', 57 | pl: 'paddingLeft', 58 | grow: 'flexGrow', 59 | shrink: 'flexShrink', 60 | basis: 'flexBasis', 61 | w: 'width', 62 | 'min-w': 'minWidth', 63 | 'max-w': 'maxWidth', 64 | h: 'height', 65 | 'min-h': 'minHeight', 66 | 'max-h': 'maxHeight', 67 | border: 'borderColor', 68 | 69 | // Text Component 70 | bg: 'bgColor', 71 | text: 'color', 72 | } 73 | 74 | export function isInSpecialAliases(selector: string): boolean { 75 | return selector in specialAliases 76 | } 77 | -------------------------------------------------------------------------------- /packages/core/src/style-syntax/index.ts: -------------------------------------------------------------------------------- 1 | export { transformClassToStyleProps } from './transform' 2 | -------------------------------------------------------------------------------- /packages/core/src/style-syntax/transform.ts: -------------------------------------------------------------------------------- 1 | import { specialAliases, isInSpecialAliases, aliases } from './alias' 2 | 3 | function parseAttribute(attr: string) { 4 | let dashIndex = attr.indexOf('-') 5 | if (dashIndex < 0) { 6 | return [attr, true] as const 7 | } else { 8 | let identifier = attr.slice(0, dashIndex) 9 | if (identifier === 'min' || identifier === 'max') { 10 | dashIndex = attr.indexOf('-', dashIndex + 1) 11 | identifier = attr.slice(0, dashIndex) 12 | } 13 | 14 | return [identifier, attr.slice(dashIndex + 1)] 15 | } 16 | } 17 | 18 | function normalizeValue(value: string | boolean | number) { 19 | return !isNaN(+value) && typeof value !== 'boolean' ? +value : value 20 | } 21 | 22 | export function transformClassToStyleProps(classStr: string) { 23 | const props: Record = {} 24 | 25 | for (const token of classStr.split(' ')) { 26 | if (isInSpecialAliases(token)) { 27 | Object.assign(props, specialAliases[token]) 28 | continue 29 | } 30 | 31 | const [identifier, value] = parseAttribute(token) 32 | 33 | props[aliases[identifier] || identifier] = normalizeValue(value) 34 | } 35 | 36 | return props 37 | } 38 | -------------------------------------------------------------------------------- /packages/core/src/utils/fileLog.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentScope, onScopeDispose } from '@vue/runtime-core' 2 | import { createWriteStream } from 'node:fs' 3 | 4 | export function useDebugLog(file = `debug-${Date.now()}.log`) { 5 | const stream = createWriteStream(file, { flags: 'a' }) 6 | 7 | if (getCurrentScope()) 8 | onScopeDispose(() => { 9 | stream.end() 10 | }) 11 | 12 | function write(...args: any[]) { 13 | stream.write( 14 | `${new Date().toISOString()}: ${ 15 | 'toString' in args ? args.toString() : String(args) 16 | }\n` 17 | ) 18 | } 19 | 20 | return write 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/src/utils/indentHTML.ts: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | 5 | 6 | <#text>TODO: logs 7 | 8 | 9 | 10 | <#text> 11 | 12 | 13 | <#text>🁰 14 | 15 | <#text>  16 | 17 | 18 | 19 | <#text>🁫 20 | 21 | <#text>  22 | 23 | 24 | 25 | <#text>🁬 26 | 27 | <#text>  28 | 29 | 30 | 31 | <#text>🁨 32 | 33 | <#text>  34 | 35 | 36 | 37 | <#text>🁭 38 | 39 | <#text>  40 | 41 | 42 | 43 | <#text>🁧 44 | 45 | <#text>  46 | 47 | 48 | 49 | <#text>🁼 50 | 51 | <#text>  52 | 53 | <#text> 54 | 55 | 56 | 57 | */ 58 | 59 | const INDENT = ' ' 60 | 61 | /** 62 | * Indent unindented HTML to read better 63 | * 64 | * @param html - html to indent 65 | * @returns an indented HTML 66 | */ 67 | export function indentHTML(html: string): string { 68 | let indent = 0 69 | let isOpen = false 70 | 71 | return html.split('\n').reduce((result, line) => { 72 | // if (line.includes('virtual-text')) { 73 | // console.log({ indent, result, line }) 74 | // } 75 | // closing a tag 76 | if (line.startsWith('') && isOpen) { 80 | isOpen = false 81 | return result + line 82 | } 83 | isOpen = false 84 | return result + '\n' + INDENT.repeat(indent) + line 85 | } 86 | 87 | if (line.startsWith('<')) { 88 | const currentIndent = indent 89 | if (!line.startsWith(' 19 | -------------------------------------------------------------------------------- /packages/domino/src/components/HandDominoTile.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | 23 | 28 | -------------------------------------------------------------------------------- /packages/domino/src/engine/DominoGame.spec.ts: -------------------------------------------------------------------------------- 1 | describe('DominoGame', () => { 2 | it('must work', () => { 3 | expect(42).toBe(42) 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /packages/domino/src/engine/DominoTile.spec.ts: -------------------------------------------------------------------------------- 1 | import { DominoTile, DominoTileDirection } from './DominoTile' 2 | 3 | describe('DominoTile', () => { 4 | it('can be turned', () => { 5 | const tile = new DominoTile(0, 2) 6 | expect(tile.value).toBe('0:2') 7 | expect(tile.turn().value).toBe('2:0') 8 | expect(tile.value).toBe('2:0') 9 | }) 10 | 11 | it('can be cloned', () => { 12 | const tile = new DominoTile(0, 2) 13 | const cloned = tile.clone(DominoTileDirection.vertical) 14 | tile.turn() 15 | expect(cloned.value).not.toBe(tile.value) 16 | }) 17 | 18 | it('can get parts', () => { 19 | const tile = new DominoTile(0, 2) 20 | expect(tile.start).toBe(0) 21 | expect(tile.end).toBe(2) 22 | }) 23 | 24 | it('can be stringified', () => { 25 | const tile = new DominoTile(0, 2).clone(DominoTileDirection.horizontal) 26 | expect(`${tile}`).toBe('🀳') 27 | tile.turn() 28 | expect(`${tile}`).toBe('🀿') 29 | const clone = tile.clone(DominoTileDirection.vertical) 30 | expect(`${clone}`).toBe('🁱') 31 | clone.turn() 32 | expect(`${clone}`).toBe('🁥') 33 | }) 34 | 35 | it('can be created from string', () => { 36 | function testString(domino: string) { 37 | expect(DominoTile.fromString(domino).toString()).toBe(domino) 38 | } 39 | testString('🀳') 40 | testString('🀱') 41 | testString('🀹') 42 | testString('🀼') 43 | testString('🁋') 44 | testString('🁛') 45 | 46 | testString('🁣') 47 | testString('🁲') 48 | testString('🂓') 49 | testString('🂍') 50 | testString('🁿') 51 | testString('🁻') 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /packages/domino/src/engine/DominoTile.ts: -------------------------------------------------------------------------------- 1 | export type DominoHalfTileValueNumber = 0 | 1 | 2 | 3 | 4 | 5 | 6 2 | export type DominoHalfTileValue = 3 | | `${DominoHalfTileValueNumber}` 4 | | DominoHalfTileValueNumber 5 | export type DominoTileValue = `${DominoHalfTileValue}:${DominoHalfTileValue}` 6 | export enum DominoTileDirection { 7 | horizontal, 8 | vertical, 9 | } 10 | 11 | export class DominoTile { 12 | start: DominoHalfTileValueNumber // actually 1 - 6 13 | end: DominoHalfTileValueNumber // actually 1 - 6 14 | direction: DominoTileDirection = DominoTileDirection.vertical 15 | 16 | constructor(start: number, end: number) { 17 | if (start < 0 || start > 6 || end < 0 || end > 6) { 18 | throw new Error(`Invalid values ${start} / ${end}.`) 19 | } 20 | this.start = start as DominoHalfTileValueNumber 21 | this.end = end as DominoHalfTileValueNumber 22 | } 23 | 24 | get value() { 25 | return `${this.start}:${this.end}` 26 | } 27 | 28 | get points() { 29 | return this.start + this.end 30 | } 31 | 32 | is(tile: DominoTile): boolean 33 | is(tileStart: number, tileEnd: number): boolean 34 | is(tileOrStart: DominoTile | number, tileEnd?: number): boolean { 35 | const start = 36 | typeof tileOrStart === 'number' ? tileOrStart : tileOrStart.start 37 | const end = typeof tileOrStart === 'number' ? tileEnd : tileOrStart.end 38 | return ( 39 | (this.start === start && this.end === end) || 40 | (this.end === start && this.start === end) 41 | ) 42 | } 43 | 44 | isDouble() { 45 | return this.start === this.end 46 | } 47 | 48 | turn() { 49 | const start = this.start 50 | this.start = this.end 51 | this.end = start 52 | return this 53 | } 54 | 55 | rotate(direction: DominoTileDirection) { 56 | this.direction = direction 57 | return this 58 | } 59 | 60 | clone(direction: DominoTileDirection) { 61 | const tile = new DominoTile(this.start, this.end) 62 | tile.direction = direction 63 | return tile 64 | } 65 | 66 | toString() { 67 | return String.fromCodePoint( 68 | (this.direction === DominoTileDirection.vertical ? 0x1f063 : 0x1f031) + 69 | this.start * 7 + 70 | this.end 71 | ) 72 | } 73 | 74 | /** 75 | * Creates a DominoTile from a unicode domino string. 76 | * 77 | * @param domino A domino unicode string like 🀵 🂃 🁒 🁀 🀼 🁏 🁅 🂓 🁠 🂋 🁕 🀸 🀳 🁄 78 | */ 79 | static fromString(domino: string): DominoTile { 80 | const code = domino.codePointAt(0) 81 | if (code == null) { 82 | throw new Error(`Cannot create DominoTile from "${domino}".`) 83 | } 84 | 85 | let direction: DominoTileDirection 86 | let offset: number 87 | if (code >= DOMINO_VERTICAL_CODE && code <= DOMINO_VERTICAL_CODE_END) { 88 | direction = DominoTileDirection.vertical 89 | offset = code - DOMINO_VERTICAL_CODE 90 | } else if ( 91 | code >= DOMINO_HORIZONTAL_CODE && 92 | code <= DOMINO_HORIZONTAL_CODE_END 93 | ) { 94 | direction = DominoTileDirection.horizontal 95 | offset = code - DOMINO_HORIZONTAL_CODE 96 | } else { 97 | throw new Error(`Cannot create DominoTile from "${domino}".`) 98 | } 99 | 100 | const start = Math.floor(offset / 7) 101 | const end = Math.floor(offset % 7) 102 | const tile = new DominoTile(start as any, end as any) 103 | tile.rotate(direction) 104 | return tile 105 | } 106 | } 107 | 108 | const DOMINO_HORIZONTAL_CODE = 0x1f031 109 | const DOMINO_HORIZONTAL_CODE_END = 0x1f061 110 | const DOMINO_VERTICAL_CODE = 0x1f063 111 | const DOMINO_VERTICAL_CODE_END = 0x1f093 112 | 113 | // const DOMINO_TILE_H_0_0 = '\u{1f031}' // 🀱 114 | // const DOMINO_TILE_V_0_0 = '\u{1f063}' // 🁣 115 | -------------------------------------------------------------------------------- /packages/domino/src/engine/DominoTileBoard.spec.ts: -------------------------------------------------------------------------------- 1 | import { DominoTile } from './DominoTile' 2 | import { DominoTileBoard, DominoTileBoardPosition } from './DominoTileBoard' 3 | 4 | describe('DominoTileBoard', () => { 5 | it('empty', () => { 6 | const board = new DominoTileBoard() 7 | 8 | expect(board.canPlaceTile(new DominoTile(4, 5))).toBe( 9 | DominoTileBoardPosition.both 10 | ) 11 | expect(board.canPlaceTile(new DominoTile(5, 5))).toBe( 12 | DominoTileBoardPosition.both 13 | ) 14 | }) 15 | 16 | it('left side', () => { 17 | const tiles = '🁀 🀸 🀳 🁄'.split(' ').map(DominoTile.fromString) 18 | const board = new DominoTileBoard() 19 | board.tiles = tiles 20 | expect(board.canPlaceTile(new DominoTile(2, 0))).toBe( 21 | DominoTileBoardPosition.start 22 | ) 23 | expect(board.canPlaceTile(new DominoTile(0, 2))).toBe( 24 | DominoTileBoardPosition.start 25 | ) 26 | expect(board.canPlaceTile(new DominoTile(2, 2))).toBe( 27 | DominoTileBoardPosition.start 28 | ) 29 | }) 30 | 31 | it('right side', () => { 32 | const tiles = '🁀 🀼 🁏 🁅 🂓 🁠 🂋 🁕 🀸 🀳 🁄'.split(' ').map(DominoTile.fromString) 33 | const board = new DominoTileBoard() 34 | board.tiles = tiles 35 | expect(board.canPlaceTile(new DominoTile(4, 5))).toBe( 36 | DominoTileBoardPosition.end 37 | ) 38 | expect(board.canPlaceTile(new DominoTile(5, 4))).toBe( 39 | DominoTileBoardPosition.end 40 | ) 41 | expect(board.canPlaceTile(new DominoTile(5, 5))).toBe( 42 | DominoTileBoardPosition.end 43 | ) 44 | }) 45 | 46 | it('wraps', () => { 47 | const boardText = '🁝 🁳 🀿 🁣 🀵 🁒 🁚 🁜 🀽 🁖 🁃 🂃 🁎 🀸 🀷 🂓 🁟 🁐 🁇 🁫 🀺 🁂 🁻 🁌' 48 | const tiles = boardText.split(' ').map(DominoTile.fromString) 49 | const board = new DominoTileBoard() 50 | board.tiles = tiles 51 | // TODO: 52 | // expect(board.toString()).toBe(` 53 | // 🁝 🁳 🀿 🁣 🀵 🁒 🁚 🁜 🀽 🁖 🁃 🂃 🁎 🀸 🀷 🂓 🁟 🁐 🁇 🁫 🀺 🁂 🁻 🁌 54 | // `) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /packages/domino/src/engine/DominoTileBoard.ts: -------------------------------------------------------------------------------- 1 | import { DominoTile, DominoTileDirection } from './DominoTile' 2 | 3 | export class DominoTileBoard { 4 | tiles: DominoTile[] = [] 5 | center: number = 0 6 | width: number = Infinity 7 | 8 | toString() { 9 | return this.tiles.length ? this.tiles.join(' ') : '' 10 | } 11 | 12 | placeTile(tile: DominoTile, position?: DominoTileBoardPosition): void { 13 | tile.rotate( 14 | tile.isDouble() 15 | ? DominoTileDirection.vertical 16 | : DominoTileDirection.horizontal 17 | ) 18 | 19 | if (!this.tiles.length) { 20 | // we are placing the first tile 21 | this.tiles.push(tile) 22 | return 23 | } 24 | 25 | const possiblePosition = this.canPlaceTile(tile) 26 | if (position ? !(possiblePosition & position) : !possiblePosition) { 27 | throw new Error(`Tile ${tile} cannot be placed in that position.`) 28 | } 29 | 30 | position = position || possiblePosition 31 | 32 | const targetTile = 33 | position === DominoTileBoardPosition.end 34 | ? this.tiles[this.tiles.length - 1] 35 | : this.tiles[0] 36 | 37 | const targetValue = 38 | position === DominoTileBoardPosition.end 39 | ? targetTile.end 40 | : targetTile.start 41 | 42 | if (position === DominoTileBoardPosition.end) { 43 | if (tile.value.endsWith(`${targetValue}`)) { 44 | tile.turn() 45 | } 46 | this.tiles.push(tile) 47 | } else { 48 | if (tile.value.startsWith(`${targetValue}`)) { 49 | tile.turn() 50 | } 51 | // we are moving the center of the board 52 | this.center++ 53 | this.tiles.unshift(tile) 54 | } 55 | } 56 | 57 | canPlaceTile(tile: DominoTile): DominoTileBoardPosition { 58 | if (!this.tiles.length) { 59 | return DominoTileBoardPosition.both 60 | } 61 | 62 | const startTile = this.tiles[0] 63 | const endTile = this.tiles[this.tiles.length - 1] 64 | 65 | const canOnStart = tile.value.includes(`${startTile.start}`) 66 | const canOnEnd = tile.value.includes(`${endTile.end}`) 67 | 68 | if (canOnStart) { 69 | return canOnEnd 70 | ? DominoTileBoardPosition.both 71 | : DominoTileBoardPosition.start 72 | } else { 73 | return canOnEnd 74 | ? DominoTileBoardPosition.end 75 | : DominoTileBoardPosition.none 76 | } 77 | } 78 | } 79 | 80 | export const enum DominoTileBoardPosition { 81 | none = 0, 82 | start = 1, 83 | end = 2, 84 | both = start | end, 85 | } 86 | -------------------------------------------------------------------------------- /packages/domino/src/engine/DominoTilePile.spec.ts: -------------------------------------------------------------------------------- 1 | import { DominoTilePile } from './DominoTilePile' 2 | 3 | describe('DominoTileStack', () => { 4 | it('has all tiles', () => { 5 | const pile = new DominoTilePile() 6 | 7 | expect(pile.tiles.join('')).toBe('🁣🁤🁥🁦🁧🁨🁩🁫🁬🁭🁮🁯🁰🁳🁴🁵🁶🁷🁻🁼🁽🁾🂃🂄🂅🂋🂌🂓') 8 | expect(pile.tiles).toHaveLength(28) 9 | }) 10 | 11 | it('can pull pieces', () => { 12 | const pile = new DominoTilePile() 13 | 14 | expect(pile.pull().toString()).toBe('🁣') 15 | expect(pile.tiles).toHaveLength(27) 16 | 17 | expect(pile.pull(2).join('')).toBe('🁤🁥') 18 | expect(pile.tiles).toHaveLength(25) 19 | 20 | expect(pile.tiles.join('')).toEqual(`🁦🁧🁨🁩🁫🁬🁭🁮🁯🁰🁳🁴🁵🁶🁷🁻🁼🁽🁾🂃🂄🂅🂋🂌🂓`) 21 | }) 22 | 23 | it('can be shuffled', () => { 24 | const pile = new DominoTilePile() 25 | pile.shuffle() 26 | 27 | expect(pile.tiles.join('')).not.toBe(`🁣🁤🁥🁦🁧🁨🁩🁫🁬🁭🁮🁯🁰🁳🁴🁵🁶🁷🁻🁼🁽🁾🂃🂄🂅🂋🂌🂓`) 28 | expect(pile.tiles).toHaveLength(28) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /packages/domino/src/engine/DominoTilePile.ts: -------------------------------------------------------------------------------- 1 | import { DominoTile } from './DominoTile' 2 | import { shuffle } from './utils/shuffle' 3 | // import { shuffle } from 'lodash-es' 4 | 5 | export class DominoTilePile { 6 | tiles: DominoTile[] = [] 7 | 8 | constructor() { 9 | for (let i = 0; i <= 6; i++) { 10 | for (let j = i; j <= 6; j++) { 11 | this.tiles.push(new DominoTile(i, j)) 12 | } 13 | } 14 | } 15 | 16 | shuffle() { 17 | shuffle(this.tiles) 18 | } 19 | 20 | toString() { 21 | return this.size() 22 | ? Array.from({ length: this.size() }) 23 | .map(() => '🁢') 24 | .join('') 25 | : '' 26 | } 27 | 28 | size() { 29 | return this.tiles.length 30 | } 31 | 32 | pull(): DominoTile 33 | pull(n: number): DominoTile[] 34 | pull(n = 1): DominoTile[] | DominoTile { 35 | if (this.size() < n) { 36 | throw new Error( 37 | `Tile Stack only has ${this.size()} tiles left and cannot be drawn ${n} tiles from.` 38 | ) 39 | } 40 | 41 | return n < 2 ? this.tiles.shift()! : this.tiles.splice(0, n) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/domino/src/engine/Emitter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Emitter as _Emitter, 3 | EventHandlerList, 4 | EventHandlerMap, 5 | EventType, 6 | Handler, 7 | WildCardEventHandlerList, 8 | } from 'mitt' 9 | 10 | type GenericEventHandler> = 11 | | Handler 12 | | WildcardHandler 13 | 14 | export type RemoveEventListener = () => void 15 | 16 | type WildcardHandler> = { 17 | [K in keyof T]: (type: K, event: T[K]) => void 18 | }[keyof T] 19 | 20 | type WW> = ( 21 | type: K, 22 | event: T[K] 23 | ) => void 24 | 25 | // (type: K, event: T[K]) => void; 26 | 27 | type E = { a: { isA: boolean }; b: { isB: boolean } } 28 | 29 | function on>(handler: WW) {} 30 | on((type, event) => { 31 | if (type === 'a') { 32 | type 33 | event 34 | } 35 | }) 36 | 37 | export class EventEmitter> { 38 | /** 39 | * A Map of event names to registered handler functions. 40 | */ 41 | all: EventHandlerMap 42 | 43 | constructor(all?: EventHandlerMap) { 44 | this.all = all || new Map() 45 | } 46 | 47 | /** 48 | * Register an event handler for the given type. 49 | * @param {string|symbol} type Type of event to listen for, or `'*'` for all events 50 | * @param {Function} handler Function to call in response to given event 51 | * @memberOf mitt 52 | */ 53 | on( 54 | type: Key, 55 | handler: Handler 56 | ): RemoveEventListener 57 | on(type: '*', handler: WildcardHandler): RemoveEventListener 58 | on( 59 | type: EventType, 60 | handler: GenericEventHandler 61 | ): RemoveEventListener { 62 | if (!this.all.has(type)) { 63 | this.all.set(type, []) 64 | } 65 | const handlers: Array> = this.all.get(type)! 66 | handlers.push(handler) 67 | 68 | return () => handlers.splice(handlers.indexOf(handler) >>> 0, 1) 69 | } 70 | 71 | /** 72 | * Remove an event handler for the given type. 73 | * If `handler` is omitted, all handlers of the given type are removed. 74 | * @param {string|symbol} type Type of event to unregister `handler` from, or `'*'` 75 | * @param {Function} [handler] Handler function to remove 76 | * @memberOf mitt 77 | */ 78 | off(type: Key, handler?: Handler): void 79 | off(type: '*', handler: WildcardHandler): void 80 | off(type: EventType, handler?: GenericEventHandler): void { 81 | const handlers: Array> | undefined = 82 | this.all.get(type) 83 | if (handlers) { 84 | if (handler) { 85 | handlers.splice(handlers.indexOf(handler) >>> 0, 1) 86 | } else { 87 | this.all.set(type, []) 88 | } 89 | } 90 | } 91 | 92 | /** 93 | * Invoke all handlers for the given type. 94 | * If present, `'*'` handlers are invoked after type-matched handlers. 95 | * 96 | * Note: Manually firing '*' handlers is not supported. 97 | * 98 | * @param {string|symbol} type The event type to invoke 99 | * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler 100 | * @memberOf mitt 101 | */ 102 | emit(type: Key, event: Events[Key]): void 103 | emit( 104 | type: undefined extends Events[Key] ? Key : never 105 | ): void 106 | emit(type: EventType, evt?: any) { 107 | let handlers = this.all.get(type) 108 | if (handlers) { 109 | ;(handlers as EventHandlerList) 110 | .slice() 111 | .map((handler) => { 112 | handler(evt!) 113 | }) 114 | } 115 | 116 | handlers = this.all.get('*') 117 | if (handlers) { 118 | ;(handlers as WildCardEventHandlerList).slice().map((handler) => { 119 | handler(type, evt!) 120 | }) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /packages/domino/src/engine/Player.ts: -------------------------------------------------------------------------------- 1 | import { DominoTile, DominoTileDirection } from './DominoTile' 2 | 3 | export class Player { 4 | name: string 5 | readonly hand: DominoTile[] = [] 6 | avatar?: string 7 | 8 | constructor(name: string) { 9 | this.name = name 10 | } 11 | 12 | addToHand(tiles: DominoTile | DominoTile[]) { 13 | if (Array.isArray(tiles)) { 14 | this.hand.push(...tiles) 15 | } else { 16 | this.hand.push(tiles) 17 | } 18 | // return this 19 | } 20 | 21 | getHandPoints() { 22 | return this.hand.reduce((total, tile) => total + tile.points, 0) 23 | } 24 | 25 | hasTile(tile: DominoTile): boolean 26 | hasTile(tileStart: number, tileEnd: number): boolean 27 | hasTile(tileOrStart: DominoTile | number, tileEnd?: number): boolean { 28 | return this.hand.some((tile) => tile.is(tileOrStart as any, tileEnd as any)) 29 | } 30 | 31 | useTile(tile: DominoTile): DominoTile { 32 | const index = this.hand.findIndex((t) => t.is(tile)) 33 | if (index < 0) { 34 | throw new Error(`Player ${this.name} doesn't have ${tile}.`) 35 | } 36 | return this.hand.splice(index, 1)[0] 37 | // return this 38 | } 39 | 40 | toString() { 41 | return `${this.name}: ${this.hand 42 | .map((t) => t.clone(DominoTileDirection.vertical)) 43 | .join('')}` 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/domino/src/engine/index.ts: -------------------------------------------------------------------------------- 1 | export { Player } from './Player' 2 | export { DominoGame } from './DominoGame' 3 | export type { DominoGameEvents } from './DominoGame' 4 | export { DominoTile, DominoTileDirection } from './DominoTile' 5 | export type { DominoTileValue } from './DominoTile' 6 | export { DominoTileBoard } from './DominoTileBoard' 7 | export type { DominoTileBoardPosition } from './DominoTileBoard' 8 | export { DominoTilePile } from './DominoTilePile' 9 | -------------------------------------------------------------------------------- /packages/domino/src/engine/my-program-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vue-terminal/vue-termui/8ec18fb9adb1d18931f649511ffd1e33c83dbb10/packages/domino/src/engine/my-program-icon.png -------------------------------------------------------------------------------- /packages/domino/src/engine/tui-test.mjs: -------------------------------------------------------------------------------- 1 | import blessed from 'neo-blessed' 2 | 3 | // Create a screen object. 4 | var screen = blessed.screen({ 5 | smartCSR: true, 6 | }) 7 | 8 | screen.title = 'Hey there' 9 | 10 | // Create a box perfectly centered horizontally and vertically. 11 | var box = blessed.box({ 12 | top: 'center', 13 | left: 'center', 14 | width: '50%', 15 | height: '50%', 16 | content: 'Hello {bold}world{/bold}!', 17 | tags: true, 18 | border: { 19 | type: 'line', 20 | }, 21 | style: { 22 | fg: 'white', 23 | bg: 'magenta', 24 | border: { 25 | fg: '#f0f0f0', 26 | }, 27 | hover: { 28 | bg: 'green', 29 | }, 30 | }, 31 | }) 32 | 33 | // Append our box to the screen. 34 | screen.append(box) 35 | 36 | // Add a png icon to the box 37 | var icon = blessed.image({ 38 | parent: box, 39 | top: 0, 40 | left: 0, 41 | type: 'overlay', 42 | width: 'shrink', 43 | height: 'shrink', 44 | file: './my-program-icon.png', 45 | search: false, 46 | }) 47 | 48 | // If our box is clicked, change the content. 49 | box.on('click', function (data) { 50 | box.setContent('{center}Some different {red-fg}content{/red-fg}.{/center}') 51 | screen.render() 52 | }) 53 | 54 | // If box is focused, handle `enter`/`return` and give us some more content. 55 | box.key('enter', function (ch, key) { 56 | box.setContent( 57 | '{right}Even different {black-fg}content{/black-fg}.{/right}\n' 58 | ) 59 | box.setLine(1, 'bar') 60 | box.insertLine(1, 'foo') 61 | screen.render() 62 | }) 63 | 64 | // Quit on Escape, q, or Control-C. 65 | screen.key(['escape', 'q', 'C-c'], function (ch, key) { 66 | return process.exit(0) 67 | }) 68 | 69 | // Focus our element. 70 | box.focus() 71 | 72 | // Render the screen. 73 | screen.render() 74 | -------------------------------------------------------------------------------- /packages/domino/src/engine/tui/game.ts: -------------------------------------------------------------------------------- 1 | import { DominoGame, DominoTile } from '../' 2 | 3 | async function main() { 4 | const game = new DominoGame('Eduardo', 'Marie', 'Véronique', 'Pierre') 5 | 6 | // game.on('*', (type, event) => { 7 | // if (type === 'playerSkip') { 8 | // FIXME: event not discriminated? 9 | // console.log(`skip: ${event}`) 10 | // } 11 | // console.log(`${type}: ${event}`) 12 | // }) 13 | 14 | console.log(`${game}`) 15 | game.play(new DominoTile(6, 6)) 16 | console.log(`${game}`) 17 | 18 | while (!game.isOver()) { 19 | const possibleTiles = game.getPossibleTiles() 20 | const i = Math.floor(Math.random() * possibleTiles.length) 21 | game.play(possibleTiles[i]) 22 | console.log(`${game}`) 23 | } 24 | 25 | const winner = game.getWinner() 26 | if (winner) { 27 | console.log(`${winner.name} won ${game.getWinnerPoints()} points!`) 28 | } 29 | } 30 | 31 | main() 32 | -------------------------------------------------------------------------------- /packages/domino/src/engine/utils/shuffle.ts: -------------------------------------------------------------------------------- 1 | // for i from n−1 downto 1 do 2 | // j ← random integer such that 0 ≤ j ≤ i 3 | // exchange a[j] and a[i] 4 | 5 | export function shuffle(array: T[]): T[] { 6 | let i = array.length 7 | let temp: T 8 | while (i > 0) { 9 | // start with i = to len so it can reach the upper bound with floor 10 | const j = Math.floor(Math.random() * i) 11 | i-- // correct the swapping index 12 | temp = array[i] 13 | array[i] = array[j] 14 | array[j] = temp 15 | } 16 | 17 | return array 18 | } 19 | -------------------------------------------------------------------------------- /packages/domino/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue-termui' 2 | import App from './App.vue' 3 | 4 | createApp(App, { swapScreens: process.env.NODE_ENV === 'production' }).mount() 5 | -------------------------------------------------------------------------------- /packages/domino/src/views/Game.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 106 | -------------------------------------------------------------------------------- /packages/domino/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "useDefineForClassFields": true, 6 | "module": "esnext", 7 | "sourceMap": true, 8 | "types": ["vite/client"], 9 | "lib": ["esnext", "dom"] 10 | }, 11 | "include": [ 12 | "vite.config.ts", 13 | "src/**/*.ts", 14 | "src/**/*.d.ts", 15 | "src/**/*.tsx", 16 | "src/**/*.vue", 17 | "./auto-imports.d.ts", 18 | "./components.d.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /packages/domino/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite' 3 | // use dev version directly 4 | import VueTermui from 'vite-plugin-vue-termui' 5 | import { resolve } from 'path' 6 | 7 | export default defineConfig({ 8 | define: { 9 | __DEV__: JSON.stringify(!(process.env.NODE_ENV === 'production')), 10 | }, 11 | 12 | resolve: { 13 | alias: { 14 | // Use development version instead of dist 15 | 'vue-termui': resolve('../core/src/index.ts'), 16 | }, 17 | }, 18 | 19 | plugins: [ 20 | // 21 | VueTermui({ 22 | autoImportOptions: { 23 | include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/], 24 | imports: ['vitest'], 25 | dts: true, 26 | }, 27 | }), 28 | ], 29 | 30 | test: { 31 | include: ['src/**/*.spec.ts'], 32 | coverage: { 33 | reporter: ['text', 'html', 'lcov'], 34 | include: ['src/**/*.ts'], 35 | exclude: ['src/**/*.spec.ts', 'src/index.ts', 'src/mocks'], 36 | }, 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /packages/playground/components.d.ts: -------------------------------------------------------------------------------- 1 | // generated by unplugin-vue-components 2 | // We suggest you to commit this file into source control 3 | // Read more: https://github.com/vuejs/core/pull/3399 4 | import '@vue/runtime-core' 5 | 6 | export {} 7 | 8 | declare module '@vue/runtime-core' { 9 | export interface GlobalComponents { 10 | Div: typeof import('vue-termui')['TuiBox'] 11 | RouterLink: typeof import('vue-router')['RouterLink'] 12 | RouterView: typeof import('vue-router')['RouterView'] 13 | Span: typeof import('vue-termui')['TuiText'] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vue-termui/playground", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vtui dev", 7 | "build": "vtui build" 8 | }, 9 | "license": "MIT", 10 | "engines": { 11 | "node": ">=14.0.0" 12 | }, 13 | "devDependencies": { 14 | "@vue-termui/cli": "workspace:*", 15 | "@vue/runtime-core": "^3.2.45", 16 | "vite-plugin-vue-termui": "workspace:*", 17 | "vue-termui": "workspace:*" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/playground/src/App.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 107 | -------------------------------------------------------------------------------- /packages/playground/src/Borders.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 40 | -------------------------------------------------------------------------------- /packages/playground/src/CenteredDemo.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 77 | -------------------------------------------------------------------------------- /packages/playground/src/Counter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /packages/playground/src/Focusables.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 62 | -------------------------------------------------------------------------------- /packages/playground/src/Fragments.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | -------------------------------------------------------------------------------- /packages/playground/src/Input.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /packages/playground/src/InputDemo.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /packages/playground/src/ProgressBarDemo.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | -------------------------------------------------------------------------------- /packages/playground/src/ShortcutsDemo.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/playground/src/VueTermUILogo.vue: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /packages/playground/src/bugs/Focusables.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 45 | -------------------------------------------------------------------------------- /packages/playground/src/components/GlobalEvents.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, ref, onActivated, onDeactivated } from 'vue' 2 | import type { PropType, VNodeProps } from 'vue' 3 | import type { KeyDataEventRawHandlerFn } from 'vue-termui' 4 | import { onKeyData } from 'vue-termui' 5 | 6 | const EVENT_NAME_RE = /^on(\w+?)((?:Once|Capture|Passive)*)$/ 7 | const MODIFIERS_SEPARATOR_RE = /[OCP]/g 8 | 9 | export interface GlobalEventsProps { 10 | filter?: EventFilter 11 | } 12 | 13 | export type EventFilter = ( 14 | event: Event, 15 | listener: EventListener, 16 | name: string 17 | ) => any 18 | 19 | type Options = AddEventListenerOptions & EventListenerOptions 20 | 21 | function extractEventOptions( 22 | modifiersRaw: string | undefined 23 | ): Options | undefined | boolean { 24 | if (!modifiersRaw) return 25 | 26 | const modifiers = modifiersRaw 27 | .replace(MODIFIERS_SEPARATOR_RE, ',$&') 28 | .toLowerCase() 29 | // remove the initial comma 30 | .slice(1) 31 | .split(',') as Array<'capture' | 'passive' | 'once'> 32 | 33 | return modifiers.reduce((options, modifier) => { 34 | options[modifier] = true 35 | return options 36 | }, {} as Options) 37 | } 38 | 39 | export const GlobalEventsImpl = defineComponent({ 40 | name: 'GlobalEvents', 41 | 42 | props: { 43 | filter: { 44 | type: Function as PropType, 45 | default: () => () => true, 46 | }, 47 | }, 48 | 49 | setup(props, { attrs }) { 50 | const isActive = ref(true) 51 | onActivated(() => { 52 | isActive.value = true 53 | }) 54 | onDeactivated(() => { 55 | isActive.value = false 56 | }) 57 | 58 | const keyDataHandlers: KeyDataEventRawHandlerFn[] = [] 59 | onKeyData((event) => { 60 | keyDataHandlers.forEach((handler) => handler(event)) 61 | }) 62 | 63 | Object.keys(attrs) 64 | .filter((name) => name.startsWith('on')) 65 | .forEach((eventNameWithModifiers) => { 66 | const listener = attrs[eventNameWithModifiers] as 67 | | EventListener 68 | | EventListener[] 69 | const listeners = Array.isArray(listener) ? listener : [listener] 70 | const match = eventNameWithModifiers.match(EVENT_NAME_RE) 71 | 72 | if (!match) { 73 | if (__DEV__) { 74 | console.warn( 75 | `[vue-global-events] Unable to parse "${eventNameWithModifiers}". If this should work, you should probably open a new issue on https://github.com/shentao/vue-global-events.` 76 | ) 77 | } 78 | return 79 | } 80 | 81 | let [, eventName, modifiersRaw] = match 82 | eventName = eventName.toLowerCase() 83 | 84 | const handlers: EventListener[] = listeners.map( 85 | (listener) => (event) => { 86 | isActive.value && 87 | props.filter(event, listener, eventName) && 88 | listener(event) 89 | } 90 | ) 91 | 92 | handlers.forEach((handler) => { 93 | // TODO: filter based on eventName 94 | onKeyData(handler) 95 | }) 96 | }) 97 | 98 | return () => null 99 | }, 100 | }) 101 | 102 | // export the public type for h/tsx inference 103 | // also to avoid inline import() in generated d.ts files 104 | /** 105 | * Component of vue-lib. 106 | */ 107 | export const GlobalEvents = GlobalEventsImpl as any as { 108 | new (): { 109 | $props: VNodeProps & GlobalEventsProps 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/playground/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from '@vue/runtime-core' 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | 10 | declare var __DEV__: boolean 11 | -------------------------------------------------------------------------------- /packages/playground/src/main.ts: -------------------------------------------------------------------------------- 1 | // import devtools from '@vue/devtools' 2 | // import devtools from '@vue/devtools/node' 3 | import { createApp } from 'vue-termui' 4 | // import App from './Focusables.vue' 5 | // import App from './Fragments.vue' 6 | // import App from './CenteredDemo.vue' 7 | // import App from './App.vue' 8 | // import App from './Counter.vue' 9 | // import App from './Borders.vue' 10 | // import App from './InputDemo.vue' 11 | import App from './ShortcutsDemo.vue' 12 | 13 | createApp(App, { 14 | // swapScreens: true, 15 | }).mount({ 16 | // TODO: is this option really useful? when rendering once, any change should do a full reload one could just call 17 | // exitApp when they are done rendering on onMounted and it would would handle everything 18 | // renderOnce: true, 19 | }) 20 | -------------------------------------------------------------------------------- /packages/playground/vite.config.cjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import VueTermui from 'vite-plugin-vue-termui' 3 | import { resolve } from 'path' 4 | 5 | export default defineConfig({ 6 | define: { 7 | __DEV__: `process.env.NODE_ENV === "development"`, 8 | }, 9 | ssr: { 10 | noExternal: [ 11 | 'chalk', 12 | 'ansi-escapes', 13 | 'picocolors', 14 | 'cli-boxes', 15 | 'cli-cursor', 16 | 'cli-truncate', 17 | 'indent-string', 18 | 'strip-ansi', 19 | 'ansi-regex', 20 | 'ansi-styles', 21 | 'supports-color', 22 | 'eastasianwidth', 23 | 'emoji-regex', 24 | 'supports-hyperlinks', 25 | 'onetime', 26 | 'signal-exit', 27 | 'ws', 28 | 'slice-ansi', 29 | 'string-width', 30 | 'type-fest', 31 | 'widest-line', 32 | 'wrap-ansi', 33 | 'yoga-layout', 34 | 'yoga-layout-prebuilt', 35 | 'is-fullwidth-code-point', 36 | 'terminal-link', 37 | 'restore-cursor', 38 | ], 39 | }, 40 | resolve: { 41 | alias: { 42 | // Use development version instead of dist 43 | 'vue-termui': resolve('../core/src/index.ts'), 44 | }, 45 | }, 46 | 47 | plugins: [ 48 | VueTermui({ 49 | autoImportOptions: { 50 | include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/], 51 | imports: ['vitest'], 52 | dts: true, 53 | }, 54 | }), 55 | ], 56 | }) 57 | -------------------------------------------------------------------------------- /packages/vite-plugin-vue-termui/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.0.12](https://github.com/vue-terminal/vue-termui/compare/vite-plugin-vue-termui@0.0.11...vite-plugin-vue-termui@0.0.12) (2022-12-03) 2 | 3 | ### Bug Fixes 4 | 5 | - add missing exports for auto import ([#31](https://github.com/vue-terminal/vue-termui/issues/31)) ([a7ed72b](https://github.com/vue-terminal/vue-termui/commit/a7ed72b2a5fb12edd2ce67c881471119031bd235)) 6 | 7 | ### Features 8 | 9 | - `Input` component ([#15](https://github.com/vue-terminal/vue-termui/issues/15)) ([b720d15](https://github.com/vue-terminal/vue-termui/commit/b720d1587683c59bc0226a980ab2c08b9c24a94d)) 10 | 11 | ## [0.0.11](https://github.com/vue-terminal/vue-termui/compare/vite-plugin-vue-termui@0.0.9...vite-plugin-vue-termui@0.0.11) (2022-11-06) 12 | 13 | ### Features 14 | 15 | - `ProgressBar` component ([#14](https://github.com/vue-terminal/vue-termui/issues/14)) ([42f6383](https://github.com/vue-terminal/vue-termui/commit/42f63830df75100d95bfa3b1fa67d9680d333c67)) 16 | - allow passing auto import options ([7ab9a00](https://github.com/vue-terminal/vue-termui/commit/7ab9a001a61156264a480014ab8ccd734988b3b9)) 17 | - improve debug log ([98a4e50](https://github.com/vue-terminal/vue-termui/commit/98a4e50dc7ed1d24f1537cb44dc582cb5e07b651)) 18 | 19 | ## [0.0.10](https://github.com/vue-terminal/vue-termui/compare/vite-plugin-vue-termui@0.0.9...vite-plugin-vue-termui@0.0.10) (2022-10-21) 20 | 21 | ### Features 22 | 23 | - allow passing auto import options ([7ab9a00](https://github.com/vue-terminal/vue-termui/commit/7ab9a001a61156264a480014ab8ccd734988b3b9)) 24 | - improve debug log ([98a4e50](https://github.com/vue-terminal/vue-termui/commit/98a4e50dc7ed1d24f1537cb44dc582cb5e07b651)) 25 | 26 | ## [0.0.9](https://github.com/vue-terminal/vue-termui/compare/vite-plugin-vue-termui@0.0.8...vite-plugin-vue-termui@0.0.9) (2022-03-26) 27 | 28 | ### Code Refactoring 29 | 30 | - rewrite useStdout() ([7cfba52](https://github.com/vue-terminal/vue-termui/commit/7cfba5296a7728e2a5920ed85a41504c14f9c14c)) 31 | 32 | ### BREAKING CHANGES 33 | 34 | - now it returns an object with `stdout` and a `write` 35 | method. `stdout` is just the stdout being used by the app while `write` 36 | lets you write to the output without messing up with the current output. 37 | Useful for debugging. 38 | 39 | ## [0.0.8](https://github.com/vue-terminal/vue-termui/compare/vite-plugin-vue-termui@0.0.7...vite-plugin-vue-termui@0.0.8) (2022-03-21) 40 | 41 | ### Bug Fixes 42 | 43 | - exit app when done ([a1461db](https://github.com/vue-terminal/vue-termui/commit/a1461dbcfa6a2906e78cd5fed1bbdcc9c77d16f2)) 44 | 45 | ### Code Refactoring 46 | 47 | - rename input properties to avoid collisions with dom ([3149bea](https://github.com/vue-terminal/vue-termui/commit/3149beab70e378e20113cb84e44eff0aa16bfc68)) 48 | 49 | ### BREAKING CHANGES 50 | 51 | - all of the input events have been renamed to avoid 52 | collisions and errors with dom. All names containing Mouse are now named 53 | MouseData (to avoid collisions with MouseEvent and others) and to make 54 | things consistent, all Keypress and Keyboard are named KeyData. This is 55 | because to generate these events, we listen to the `data` event on the 56 | `stdin` and `Key` is shorter than `Keyboard`. 57 | 58 | * `*Keypress*` -> `*KeyData*` 59 | * `*Keyboard*` -> `*KeyData*` 60 | * `*Mouse*` -> `*MouseData*` 61 | 62 | ## 0.0.7 (2022-03-15) 63 | 64 | ### Bug Fixes 65 | 66 | - deps and cli file ([0473999](https://github.com/vue-terminal/vue-termui/commit/04739996ede2b9d64a507a292ba813b7bafabe98)) 67 | - **vite:** change target and input ([493c62c](https://github.com/vue-terminal/vue-termui/commit/493c62cbbd870858287b64315c3182d7963b279e)) 68 | 69 | ### Features 70 | 71 | - handle communication channel + ctrl-c on dev server ([ebb5ec7](https://github.com/vue-terminal/vue-termui/commit/ebb5ec72438dcf2f8e693ba9d16dd63672f834d5)) 72 | - **vite:** vue must be included ([64c2161](https://github.com/vue-terminal/vue-termui/commit/64c21618a99807b1b6194ce6d7f4b59e30affda7)) 73 | -------------------------------------------------------------------------------- /packages/vite-plugin-vue-termui/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present Eduardo San Martin Morote 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/vite-plugin-vue-termui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-vue-termui", 3 | "description": "Vite Plugin for Vue TermUI", 4 | "private": false, 5 | "version": "0.0.12", 6 | "sideEffects": false, 7 | "scripts": { 8 | "stub": "unbuild --stub", 9 | "build": "tsup", 10 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s --commit-path . -l vite-plugin-vue-termui -r 1", 11 | "prepublishOnly": "npm run build" 12 | }, 13 | "exports": { 14 | ".": { 15 | "import": "./dist/index.mjs", 16 | "require": "./dist/index.cjs", 17 | "types": "./dist/index.d.ts" 18 | } 19 | }, 20 | "module": "./dist/index.mjs", 21 | "main": "./dist/index.cjs", 22 | "types": "./dist/index.d.ts", 23 | "files": [ 24 | "dist/*.js", 25 | "dist/*.cjs", 26 | "dist/*.mjs", 27 | "dist/*.d.ts" 28 | ], 29 | "keywords": [ 30 | "vite", 31 | "plugin", 32 | "vue", 33 | "term", 34 | "ui", 35 | "terminal", 36 | "termui", 37 | "tui" 38 | ], 39 | "funding": "https://github.com/vue-terminal/vue-termui?sponsor=1", 40 | "license": "MIT", 41 | "author": "Eduardo San Martin Morote (https://esm.dev)", 42 | "repository": { 43 | "type": "git", 44 | "url": "https://github.com/vue-terminal/vue-termui.git", 45 | "directory": "packages/vite-plugin-vue-termui" 46 | }, 47 | "bugs": { 48 | "url": "https://github.com/vue-terminal/vue-termui/issues" 49 | }, 50 | "homepage": "https://github.com/vue-terminal/vue-termui#readme", 51 | "peerDependencies": { 52 | "@vitejs/plugin-vue": "^3.0.3", 53 | "unplugin-auto-import": "^0.11.2", 54 | "unplugin-vue-components": "^0.22.4", 55 | "vue": "^3.2.25", 56 | "vue-termui": ">=0.0.18" 57 | }, 58 | "devDependencies": { 59 | "vue-termui": "workspace:*" 60 | }, 61 | "unbuild": { 62 | "entries": [ 63 | "src/index" 64 | ], 65 | "rollup": { 66 | "emitCJS": true 67 | }, 68 | "clean": true 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/vite-plugin-vue-termui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "useDefineForClassFields": false, 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "jsx": "preserve", 10 | "declaration": true, 11 | "skipLibCheck": true, 12 | "target": "esnext", 13 | "module": "esnext", 14 | "sourceMap": true, 15 | "lib": ["esnext"] 16 | }, 17 | "include": ["src/**/*.ts", "src/**/*.d.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/vite-plugin-vue-termui/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | import { peerDependencies } from './package.json' 3 | 4 | export default defineConfig({ 5 | clean: true, 6 | // outDir: resolve(__dirname, './dist'), 7 | target: 'node14', 8 | format: ['esm', 'cjs'], 9 | dts: true, 10 | // entry: [resolve(__dirname, 'src/index.ts')], 11 | entry: ['src/index.ts'], 12 | esbuildOptions(options) { 13 | if (options.format === 'cjs') options.outExtension = { '.js': '.cjs' } 14 | }, 15 | external: [ 16 | ...Object.keys(peerDependencies), 17 | 'unplugin-auto-import/vite', 18 | 'unplugin-vue-components/vite', 19 | ], 20 | }) 21 | -------------------------------------------------------------------------------- /packages/xterm-playground/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/xterm-playground/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + Typescript + Vite 2 | 3 | This template should help get you started developing with Vue 3 and Typescript in Vite. The template uses Vue 3 ` 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/xterm-playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xterm-playground", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vue-tsc --noEmit && vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "vue-termui": "workspace:*", 12 | "xterm": "^4.18.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/xterm-playground/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vue-terminal/vue-termui/8ec18fb9adb1d18931f649511ffd1e33c83dbb10/packages/xterm-playground/public/favicon.ico -------------------------------------------------------------------------------- /packages/xterm-playground/src/App.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /packages/xterm-playground/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vue-terminal/vue-termui/8ec18fb9adb1d18931f649511ffd1e33c83dbb10/packages/xterm-playground/src/assets/logo.png -------------------------------------------------------------------------------- /packages/xterm-playground/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 35 | 36 | 53 | -------------------------------------------------------------------------------- /packages/xterm-playground/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /packages/xterm-playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'xterm/css/xterm.css' 2 | import { createApp } from 'vue' 3 | import App from './App.vue' 4 | 5 | createApp(App).mount('#app') 6 | -------------------------------------------------------------------------------- /packages/xterm-playground/src/tui/TuiApp.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 47 | -------------------------------------------------------------------------------- /packages/xterm-playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "esnext", 6 | "sourceMap": true, 7 | "lib": ["esnext", "dom"] 8 | }, 9 | "include": [ 10 | "vite.config.ts", 11 | "src/**/*.ts", 12 | "src/**/*.d.ts", 13 | "src/**/*.tsx", 14 | "src/**/*.vue", 15 | "./auto-imports.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /packages/xterm-playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import AutoImport from 'unplugin-auto-import/vite' 3 | import Vue from '@vitejs/plugin-vue' 4 | 5 | export default defineConfig({ 6 | mode: 'build', 7 | publicDir: false, 8 | resolve: { 9 | alias: { 10 | // 'vue-termui': '../core/src/index.ts', 11 | '#ansi-styles': 'ansi-styles', 12 | }, 13 | }, 14 | 15 | plugins: [ 16 | AutoImport({ 17 | imports: [ 18 | { 19 | // 'vue-termui': VueTuiExports, 20 | }, 21 | ], 22 | }), 23 | Vue({ 24 | script: { 25 | templateOptions: { 26 | compilerOptions: { 27 | nodeTransforms: [ 28 | (node, context) => { 29 | // context. 30 | // return () => { } 31 | }, 32 | ], 33 | }, 34 | }, 35 | }, 36 | template: { 37 | compilerOptions: { 38 | whitespace: 'condense', 39 | comments: false, 40 | // getTextMode: node => ???, 41 | isCustomElement: (tag) => tag.startsWith('tui:'), 42 | }, 43 | }, 44 | }), 45 | ], 46 | }) 47 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'examples/*' 4 | -------------------------------------------------------------------------------- /scripts/docs-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # check if doc files changes for netlify 4 | # needed because we cannot use && in netlify.toml 5 | 6 | # exit 0 will skip the build while exit 1 will build 7 | 8 | # - check any change in docs 9 | # - check for new version of vite related deps 10 | # - changes in netlify conf 11 | # - a commit message that starts with docs like docs: ... or docs(nuxt): ... 12 | 13 | # All paths are relative to packages/docs 14 | 15 | git diff --quiet 'HEAD^' HEAD ./ && ! git diff 'HEAD^' HEAD ../../pnpm-lock.yaml | grep --quiet vite && git diff --quiet 'HEAD^' HEAD ./netlify.toml && ! git log -1 --pretty=%B | grep '^docs' 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "esnext", 5 | "module": "esnext", 6 | "useDefineForClassFields": false, 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "jsx": "preserve", 10 | "sourceMap": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "lib": ["esnext"], 14 | "paths": { 15 | "vue-termui": ["packages/core/src/index.ts"], 16 | "vite-plugin-vue-termui": ["packages/vite-plugin-vue-termui/src/index.ts"] 17 | }, 18 | "skipLibCheck": true 19 | }, 20 | "include": [ 21 | "auto-imports.d.ts", 22 | "packages/*/src/**/*.ts", 23 | "packages/*/src/**/*.d.ts", 24 | "packages/*/src/**/*.vue", 25 | "packages/*/vite.config.ts" 26 | ], 27 | "references": [{ "path": "./tsconfig.node.json" }] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vitest.config.ts"] 8 | } 9 | --------------------------------------------------------------------------------