diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8db3c8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.lab +.vscode \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c3cfe4d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,8 @@ +# The MIT License (MIT) +## Copyright © 2021 Im-Beast + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 3d9bca2..1f4eca7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ -# Deno TUI -Deno TUI is module for creating terminal user interfaces +# ⌨️ Deno Tui -# WIP -Deno TUI is currently **Work In Progress**, look [here](https://github.com/Im-Beast/deno_tui/projects/1) to see whats missing \ No newline at end of file +**Current status:** [**W**ork **I**n **P**rogress](https://github.com/Im-Beast/deno_tui/projects/1) + +## 📚 About +TUI is module for easy creation of Terminal User Interfaces. + +## 🤝 Contributing +I'm open to any idea and criticism. +Feel free to add any commits, issues and pull requests! + +## 📝 Licensing +This project is available under MIT License conditions. \ No newline at end of file diff --git a/box.ts b/box.ts deleted file mode 100644 index 75befad..0000000 --- a/box.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { drawPixel } from "./canvas.ts"; -import { AnyComponent, createComponent } from "./tui.ts"; - -import type { - TuiComponent, - TuiInstance, - TuiRectangle, - TuiStyler, -} from "./tui.ts"; - -export type CreateBoxOptions = { - focusingItems?: AnyComponent[]; - rectangle: TuiRectangle; - styler: TuiStyler; -}; - -export function createBox( - object: TuiInstance | AnyComponent, - options: CreateBoxOptions, -) { - const { row, column, width, height } = options.rectangle; - - const instance = Object.hasOwn(object, "instance") - ? ( object).instance - : object as TuiInstance; - - const box = createComponent( - object, - { - id: "box", - interactive: false, - canvas: object.canvas, - styler: options.styler, - rectangle: options.rectangle, - }, - ); - - box.draw = () => { - const items = [...options.focusingItems || [], box]; - - const focused = items.some((item) => instance.components.focused === item); - const active = focused && instance.components.active; - - const currentStyler = - (focused - ? options.styler.focused - : active - ? options.styler.active - : options.styler) || options.styler; - - for (let r = row; r < row + height; ++r) { - for (let c = column; c < column + width; ++c) { - drawPixel(object.canvas, { - column: c, - row: r, - value: " ", - styler: currentStyler, - }); - } - } - }; - - instance.on("drawLoop", box.draw); - box.on("redraw", box.draw); - - return box; -} diff --git a/button.ts b/button.ts deleted file mode 100644 index 9ff4980..0000000 --- a/button.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { createBox } from "./box.ts"; -import { createFrame } from "./frame.ts"; -import { createLabel } from "./label.ts"; - -import { - createComponent, - TuiComponent, - TuiInstance, - TuiRectangle, - TuiStyler, -} from "./tui.ts"; - -export type CreateButtonOptions = { - text?: string; - styler: TuiStyler; - rectangle: TuiRectangle; -}; - -export function createButton( - object: TuiInstance | TuiComponent, - options: CreateButtonOptions, -) { - const { row, column, width, height } = options.rectangle; - - const instance = Object.hasOwn(object, "instance") - ? ( object).instance - : object as TuiInstance; - - const button = createComponent( - object, - { - id: "button", - interactive: true, - canvas: object.canvas, - rectangle: options.rectangle, - styler: options.styler, - }, - ); - - const funcs: (() => void)[] = []; - - const box = createBox(button, { - rectangle: options.rectangle, - styler: options.styler, - focusingItems: [button], - }); - - funcs.push(box.draw); - - if (options.styler?.border) { - const border = createFrame( - button, - { - rectangle: { - column: column - 1, - row: row - 1, - width: width + 1, - height: height + 1, - }, - styler: options.styler.border, - }, - ); - - funcs.push(border.draw); - } - - if (options.text) { - const label = createLabel( - button, - options.text, - { - rectangle: options.rectangle, - align: { horizontal: "middle", vertical: "middle" }, - styler: options.styler, - }, - ); - - funcs.push(label.draw); - } - - button.draw = () => { - funcs.forEach((func) => func()); - }; - - instance.on("drawLoop", button.draw); - button.on("redraw", button.draw); - - return button; -} diff --git a/checkbox.ts b/checkbox.ts deleted file mode 100644 index 508ccca..0000000 --- a/checkbox.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { drawPixel } from "./canvas.ts"; -import { createFrame } from "./frame.ts"; - -import { - createComponent, - TuiComponent, - TuiInstance, - TuiStyler, -} from "./tui.ts"; - -export type CreateCheckboxOptions = { - styler: TuiStyler; - column: number; - row: number; -}; - -export type CheckboxComponent = TuiComponent<"stateChange", boolean> & { - state: boolean; -}; - -export function createCheckbox( - object: TuiInstance | TuiComponent, - options: CreateCheckboxOptions, -): CheckboxComponent { - const instance = Object.hasOwn(object, "instance") - ? ( object).instance - : object as TuiInstance; - - const checkbox: CheckboxComponent = { - ...createComponent<"stateChange", boolean>( - object, - { - id: "checkbox", - interactive: true, - canvas: object.canvas, - rectangle: { - column: options.column, - row: options.row, - width: 1, - height: 1, - }, - styler: options.styler, - }, - ), - state: false, - }; - - const funcs: (() => void)[] = []; - - if (options.styler?.border) { - const border = createFrame( - checkbox, - { - rectangle: { - column: options.column - 1, - row: options.row - 1, - width: 2, - height: 2, - }, - styler: options.styler.border, - }, - ); - - funcs.push(border.draw); - } - - funcs.push(() => { - const focused = instance.components.focused === checkbox; - const active = checkbox.state || focused && instance.components.active; - - const currentStyler = (focused - ? options.styler.focused - : active - ? options.styler.active - : options.styler) || options.styler; - - drawPixel(instance.canvas, { - column: options.column, - row: options.row, - value: active - ? "✓" - : "✗", - styler: currentStyler, - }); - }); - - checkbox.draw = () => funcs.forEach((func) => func()); - - instance.on("drawLoop", checkbox.draw); - checkbox.on("redraw", checkbox.draw); - - return checkbox; -} diff --git a/frame.ts b/frame.ts deleted file mode 100644 index 22cee1a..0000000 --- a/frame.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { drawPixel } from "./canvas.ts"; -import { createComponent } from "./tui.ts"; - -import type { - AnyComponent, - TuiComponent, - TuiInstance, - TuiRectangle, - TuiStyler, -} from "./tui.ts"; - -export type CreateFrameOptions = { - focusingItems?: AnyComponent[]; - rectangle: TuiRectangle; - styler: TuiStyler; -}; - -export function createFrame( - object: TuiInstance | AnyComponent, - options: CreateFrameOptions, -) { - const { canvas } = object; - const { column, row, width, height } = options.rectangle; - - const instance = Object.hasOwn(object, "instance") - ? ( object).instance - : object as TuiInstance; - - const frame = createComponent( - object, - { - id: "frame", - interactive: false, - canvas: object.canvas, - styler: options.styler, - rectangle: options.rectangle, - }, - ); - - frame.draw = () => { - const items = [...options.focusingItems || [], frame]; - const focused = items.some((item) => instance.components.focused === item); - const active = focused && instance.components.active; - - const currentStyler = - (focused - ? options.styler.focused - : active - ? options.styler.active - : options.styler) || options.styler; - - for (let i = 0; i < width; ++i) { - drawPixel(canvas, { - column: column + i, - row: row, - value: "─", - styler: currentStyler, - }); - - drawPixel(canvas, { - column: column + i, - row: row + height, - value: "─", - styler: currentStyler, - }); - } - - for (let i = 0; i < height; ++i) { - drawPixel(canvas, { - column: column, - row: row + i, - value: "│", - styler: currentStyler, - }); - - drawPixel(canvas, { - column: column + width, - row: row + i, - value: "│", - styler: currentStyler, - }); - } - - drawPixel(canvas, { - column, - row, - value: "┌", - styler: currentStyler, - }); - - drawPixel(canvas, { - column, - row: row + height, - value: "└", - styler: currentStyler, - }); - - drawPixel(canvas, { - column: column + width, - row, - value: "┐", - styler: currentStyler, - }); - - drawPixel(canvas, { - column: column + width, - row: row + height, - value: "┘", - styler: currentStyler, - }); - }; - - instance.on("drawLoop", frame.draw); - frame.on("redraw", frame.draw); - - return frame; -} diff --git a/label.ts b/label.ts deleted file mode 100644 index 727e097..0000000 --- a/label.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createComponent } from "./tui.ts"; - -import type { - TuiComponent, - TuiInstance, - TuiRectangle, - TuiStyler, -} from "./tui.ts"; - -import { drawText } from "./canvas.ts"; - -// This function was created by sindresorhus: https://github.com/sindresorhus/is-fullwidth-code-point/blob/main/index.js -export function isFullWidth(codePoint: number) { - // Code points are derived from: - // https://unicode.org/Public/UNIDATA/EastAsianWidth.txt - return ( - codePoint >= 0x1100 && - (codePoint <= 0x115f || - codePoint === 0x2329 || - codePoint === 0x232a || - (0x2e80 <= codePoint && codePoint <= 0x3247 && codePoint !== 0x303f) || - (0x3250 <= codePoint && codePoint <= 0x4dbf) || - (0x4e00 <= codePoint && codePoint <= 0xa4c6) || - (0xa960 <= codePoint && codePoint <= 0xa97c) || - (0xac00 <= codePoint && codePoint <= 0xd7a3) || - (0xf900 <= codePoint && codePoint <= 0xfaff) || - (0xfe10 <= codePoint && codePoint <= 0xfe19) || - (0xfe30 <= codePoint && codePoint <= 0xfe6b) || - (0xff01 <= codePoint && codePoint <= 0xff60) || - (0xffe0 <= codePoint && codePoint <= 0xffe6) || - (0x1b000 <= codePoint && codePoint <= 0x1b001) || - (0x1f200 <= codePoint && codePoint <= 0x1f251) || - (0x20000 <= codePoint && codePoint <= 0x3fffd)) - ); -} -export function textPixelWidth(text: string): number { - let width = 0; - - for (let i = 0; i < text.length; ++i) { - width += isFullWidth(text.charCodeAt(i)) ? 2 : 1; - } - - return width; -} - -export type CreateLabelOptions = { - styler: TuiStyler; - rectangle: TuiRectangle; - align: { - horizontal: "left" | "middle" | "right"; - vertical: "top" | "middle" | "bottom"; - }; -}; - -export function createLabel( - object: TuiInstance | TuiComponent, - text: string, - options: CreateLabelOptions, -): TuiComponent { - const { column, row, width, height } = options.rectangle; - - const instance = Object.hasOwn(object, "instance") - ? ( object).instance - : object as TuiInstance; - - const label = createComponent( - object, - { - id: "label", - interactive: false, - canvas: object.canvas, - rectangle: options.rectangle, - styler: options.styler, - }, - ); - - const lines = text.split("\n"); - - const funcs = lines.map((line, i) => { - let textWidth = textPixelWidth(line); - if (textWidth > width) { - line = line.slice(0, width); - textWidth = textPixelWidth(line); - } - - let currentColumn = options.rectangle.column; - let currentRow = options.rectangle.row; - - switch (options.align.horizontal) { - case "left": - break; - case "middle": - currentColumn = Math.floor(column + (width / 2 - textWidth / 2)); - break; - case "right": - currentColumn = column + width; - break; - } - - switch (options.align.vertical) { - case "top": - break; - case "middle": - currentRow = Math.floor(row + height / 2 - lines.length / 2); - break; - case "bottom": - currentRow = row + height; - break; - } - - return () => { - const focused = instance.components.focused === label; - const active = focused && instance.components.active; - - const currentStyler = (focused - ? options.styler.focused - : active - ? options.styler.active - : options.styler) || options.styler; - - drawText( - object.canvas, - { - column: currentColumn, - row: currentRow + i, - text: line, - styler: currentStyler, - }, - ); - }; - }); - - label.draw = () => { - funcs.forEach((func) => func()); - }; - - instance.on("drawLoop", label.draw); - label.on("redraw", label.draw); - - return label; -} diff --git a/mod.ts b/mod.ts index 5043f0a..1e3ab73 100644 --- a/mod.ts +++ b/mod.ts @@ -1,12 +1,14 @@ -export * from "./box.ts"; -export * from "./button.ts"; -export * from "./canvas.ts"; -export * from "./checkbox.ts"; -export * from "./event_emitter.ts"; -export * from "./frame.ts"; -export * from "./frame_buffer.ts"; -export * from "./key_reader.ts"; -export * from "./label.ts"; -export * from "./textbox.ts"; -export * from "./tui.ts"; -export * from "./types.ts"; +export * from "./src/canvas.ts"; +export * from "./src/event_emitter.ts"; +export * from "./src/frame_buffer.ts"; +export * from "./src/keyboard.ts"; +export * from "./src/key_reader.ts"; +export * from "./src/tui.ts"; +export * from "./src/tui_component.ts"; +export * from "./src/types.ts"; +export * from "./src/components/box.ts"; +export * from "./src/components/textbox.ts"; +export * from "./src/components/label.ts"; +export * from "./src/components/frame.ts"; +export * from "./src/components/checkbox.ts"; +export * from "./src/components/button.ts"; diff --git a/canvas.ts b/src/canvas.ts similarity index 82% rename from canvas.ts rename to src/canvas.ts index 5c4b7fe..3ae4b75 100644 --- a/canvas.ts +++ b/src/canvas.ts @@ -40,39 +40,22 @@ const rgbRegex = /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\)/; export function getStyle( style: CanvasStyle, - background: boolean, + bg: boolean, ): Crayon { - if (typeof style !== "string") { - throw crayon - `{blue style} property is supposed to be string! Got {bold.red ${typeof style}} instead`; - } + let crayonInstance = crayon; - if (style[0] === "#") { - return crayon()[background ? "bgHex" : "hex"]( - style, - true, - ) as unknown as Crayon< - S, - F, - O - >; + if (style.startsWith("#")) { + crayonInstance = crayon()[bg ? "bgHex" : "hex"](style, true); } else if (rgbRegex.test(style)) { - const [, r, g, b] = style.match(rgbRegex) || []; - return crayon()[background ? "bgRgb" : "rgb"]( - Number(r), - Number(g), - Number(b), - ) as unknown as Crayon; + const [, r, g, b] = style.match(rgbRegex)?.map(Number) || []; + crayonInstance = crayon()[bg ? "bgRgb" : "rgb"](r, g, b); } - const bgStyle = `bg${style[0].toUpperCase()}${style.slice(1)}`; - return crayon()[ - (background ? bgStyle : style) as CrayonStyle - ] as unknown as Crayon< - S, - F, - O - >; + crayonInstance = bg + ? crayon()[`bg${style[0].toUpperCase()}${style.slice(1)}` as CrayonStyle] + : crayon()[style as CrayonStyle]; + + return crayonInstance as unknown as Crayon; } export function styleTextFromStyler( @@ -103,6 +86,10 @@ export function styleText( return getStyle(style, background)(text); } +export function draw(instance: CanvasInstance) { + writeBufferSync(instance.frameBuffer); +} + export function createCanvas( writer: Writer, styler: CanvasStyler, @@ -118,9 +105,17 @@ export function createCanvas( styler, }; + watch(canvas); + return canvas; } +export async function watch(instance: CanvasInstance) { + for await (const _ of Deno.signal("SIGWINCH")) { + draw(instance); + } +} + export function loop( instance: CanvasInstance, interval: number, diff --git a/src/components/box.ts b/src/components/box.ts new file mode 100644 index 0000000..59476a0 --- /dev/null +++ b/src/components/box.ts @@ -0,0 +1,38 @@ +import { drawPixel } from "../canvas.ts"; +import { + createComponent, + CreateComponentOptions, + getCurrentStyler, +} from "../tui_component.ts"; +import { TuiObject } from "../types.ts"; + +export type CreateBoxOptions = Omit< + CreateComponentOptions, + "interactive" | "name" +>; + +export function createBox(object: TuiObject, options: CreateBoxOptions) { + const { row, column, width, height } = options.rectangle; + + const box = createComponent(object, { + name: "box", + interactive: false, + ...options, + draw() { + const styler = getCurrentStyler(box); + + for (let r = row; r < row + height; ++r) { + for (let c = column; c < column + width; ++c) { + drawPixel(object.canvas, { + column: c, + row: r, + value: " ", + styler, + }); + } + } + }, + }); + + return box; +} diff --git a/src/components/button.ts b/src/components/button.ts new file mode 100644 index 0000000..fb51d32 --- /dev/null +++ b/src/components/button.ts @@ -0,0 +1,58 @@ +import { createComponent } from "../tui_component.ts"; +import { TuiObject } from "../types.ts"; +import { createBox, CreateBoxOptions } from "./box.ts"; +import { createFrame } from "./frame.ts"; +import { createLabel, TextAlign } from "./label.ts"; + +export type CreateButtonOptions = CreateBoxOptions & { + text?: string; + textAlign?: TextAlign; +}; + +export function createButton(object: TuiObject, options: CreateButtonOptions) { + const button = createComponent(object, { + name: "button", + interactive: true, + draw() { + button.components.tree.forEach((component) => component.draw); + }, + ...options, + }); + + const focusedWithin = [button, ...options.focusedWithin]; + + createBox(button, { + ...options, + focusedWithin, + }); + + if (options.styler.border) { + const { row, column, width, height } = options.rectangle; + + createFrame(button, { + ...options, + rectangle: { + column: column - 1, + row: row - 1, + width: width + 1, + height: height + 1, + }, + styler: options.styler.border, + focusedWithin, + }); + } + + if (options.text) { + createLabel(button, options.text, { + rectangle: options.rectangle, + align: options.textAlign || { + horizontal: "center", + vertical: "center", + }, + styler: options.styler, + focusedWithin, + }); + } + + return button; +} diff --git a/src/components/checkbox.ts b/src/components/checkbox.ts new file mode 100644 index 0000000..fbfcbab --- /dev/null +++ b/src/components/checkbox.ts @@ -0,0 +1,88 @@ +import { drawPixel } from "../canvas.ts"; +import { + createComponent, + CreateComponentOptions, + getCurrentStyler, + TuiComponent, +} from "../tui_component.ts"; +import { TuiObject, TuiRectangle } from "../types.ts"; +import { createFrame } from "./frame.ts"; + +export type CreateCheckboxOptions = + & Omit< + CreateComponentOptions, + "interactive" | "name" | "rectangle" + > + & { + default: boolean; + column: number; + row: number; + }; + +export type CheckboxComponent = TuiComponent<"valueChange", boolean> & { + value: boolean; +}; + +export function createCheckbox( + object: TuiObject, + options: CreateCheckboxOptions, +): CheckboxComponent { + const { row, column } = options; + + const rectangle: TuiRectangle = { + column, + row, + width: 1, + height: 1, + }; + + const checkbox = { + value: options.default, + ...createComponent<"valueChange", boolean>(object, { + name: "checkbox", + interactive: true, + rectangle, + draw() { + checkbox.components.tree.forEach((component) => component.draw); + + const styler = getCurrentStyler(checkbox, { + active: { + value: checkbox.value, + force: true, + }, + }); + + drawPixel(object.canvas, { + column, + row, + value: checkbox.value ? "✓" : "✗", + styler, + }); + }, + ...options, + }), + }; + + const focusedWithin = [checkbox, ...options.focusedWithin]; + + if (options.styler.border) { + createFrame(checkbox, { + ...options, + rectangle: { + column: column - 1, + row: row - 1, + width: 2, + height: 2, + }, + styler: options.styler.border, + focusedWithin, + }); + } + + checkbox.on("active", () => { + checkbox.value = !checkbox.value; + checkbox.emitter.emit("valueChange", !checkbox.value); + }); + + return checkbox; +} diff --git a/src/components/frame.ts b/src/components/frame.ts new file mode 100644 index 0000000..9af4703 --- /dev/null +++ b/src/components/frame.ts @@ -0,0 +1,82 @@ +import { drawPixel } from "../canvas.ts"; +import { createComponent, getCurrentStyler } from "../tui_component.ts"; +import { TuiObject } from "../types.ts"; +import { CreateBoxOptions } from "./box.ts"; + +export type CreateFrameOptions = CreateBoxOptions; + +export function createFrame(object: TuiObject, options: CreateFrameOptions) { + const { row, column, width, height } = options.rectangle; + const { canvas } = object; + + const frame = createComponent(object, { + name: "frame", + interactive: false, + ...options, + draw() { + const styler = getCurrentStyler(frame); + + for (let w = 0; w < width; ++w) { + drawPixel(canvas, { + column: column + w, + row: row, + value: "─", + styler, + }); + + drawPixel(canvas, { + column: column + w, + row: row + height, + value: "─", + styler, + }); + } + + for (let h = 0; h < height; ++h) { + drawPixel(canvas, { + column: column, + row: row + h, + value: "│", + styler, + }); + + drawPixel(canvas, { + column: column + width, + row: row + h, + value: "│", + styler, + }); + } + + drawPixel(canvas, { + column, + row, + value: "┌", + styler, + }); + + drawPixel(canvas, { + column, + row: row + height, + value: "└", + styler, + }); + + drawPixel(canvas, { + column: column + width, + row, + value: "┐", + styler, + }); + + drawPixel(canvas, { + column: column + width, + row: row + height, + value: "┘", + styler, + }); + }, + }); + + return frame; +} diff --git a/src/components/label.ts b/src/components/label.ts new file mode 100644 index 0000000..2d520a4 --- /dev/null +++ b/src/components/label.ts @@ -0,0 +1,106 @@ +import { drawText } from "../canvas.ts"; +import { createComponent, getCurrentStyler } from "../tui_component.ts"; +import { TuiObject } from "../types.ts"; +import { CreateBoxOptions } from "./box.ts"; + +// This function was created by sindresorhus: https://github.com/sindresorhus/is-fullwidth-code-point/blob/main/index.js +export function isFullWidth(codePoint: number) { + // Code points are derived from: + // https://unicode.org/Public/UNIDATA/EastAsianWidth.txt + return ( + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (0x2e80 <= codePoint && codePoint <= 0x3247 && codePoint !== 0x303f) || + (0x3250 <= codePoint && codePoint <= 0x4dbf) || + (0x4e00 <= codePoint && codePoint <= 0xa4c6) || + (0xa960 <= codePoint && codePoint <= 0xa97c) || + (0xac00 <= codePoint && codePoint <= 0xd7a3) || + (0xf900 <= codePoint && codePoint <= 0xfaff) || + (0xfe10 <= codePoint && codePoint <= 0xfe19) || + (0xfe30 <= codePoint && codePoint <= 0xfe6b) || + (0xff01 <= codePoint && codePoint <= 0xff60) || + (0xffe0 <= codePoint && codePoint <= 0xffe6) || + (0x1b000 <= codePoint && codePoint <= 0x1b001) || + (0x1f200 <= codePoint && codePoint <= 0x1f251) || + (0x20000 <= codePoint && codePoint <= 0x3fffd)) + ); +} + +export function textPixelWidth(text: string): number { + let width = 0; + for (let i = 0; i < text.length; ++i) { + width += isFullWidth(text.charCodeAt(i)) ? 2 : 1; + } + return width; +} + +export type TextAlign = { + horizontal: "left" | "center" | "right"; + vertical: "top" | "center" | "bottom"; +}; + +export type CreateLabelOptions = CreateBoxOptions & { + align: TextAlign; +}; + +export function createLabel( + object: TuiObject, + text: string, + options: CreateLabelOptions, +) { + const { row, column, width, height } = options.rectangle; + + const drawFuncs: (() => void)[] = []; + + const label = createComponent(object, { + name: "label", + interactive: false, + ...options, + draw() { + drawFuncs.forEach((func) => func()); + }, + }); + + const lines = text.split("\n"); + for (let [i, line] of lines.entries()) { + let textWidth = textPixelWidth(line); + while (textWidth > width) { + line = line.slice(0, width); + textWidth = textPixelWidth(line); + } + + let c = column; + let r = row; + + switch (options.align.horizontal) { + case "center": + c = Math.floor(column + (width / 2 - textWidth / 2)); + break; + case "right": + r = column + width; + break; + } + + switch (options.align.vertical) { + case "center": + r = Math.floor(row + height / 2 - lines.length / 2); + break; + case "bottom": + r = row + height; + break; + } + + drawFuncs.push(() => + drawText(object.canvas, { + column: c, + row: r + i, + text: line, + styler: getCurrentStyler(label), + }) + ); + } + + return label; +} diff --git a/src/components/textbox.ts b/src/components/textbox.ts new file mode 100644 index 0000000..ac9a7ca --- /dev/null +++ b/src/components/textbox.ts @@ -0,0 +1,154 @@ +import { drawText } from "../canvas.ts"; +import { + createComponent, + getCurrentStyler, + TuiComponent, +} from "../tui_component.ts"; +import { TuiObject } from "../types.ts"; +import { createBox, CreateBoxOptions } from "./box.ts"; +import { createFrame } from "./frame.ts"; +import { textPixelWidth } from "./label.ts"; + +export type CreateTextboxOptions = CreateBoxOptions & { + hidden: boolean; + multiline: boolean; +}; + +export type TextboxComponent = TuiComponent<"valueChange", string> & { + value: string; +}; + +export function createTextbox( + object: TuiObject, + options: CreateTextboxOptions, +): TextboxComponent { + const { row, column, width, height } = options.rectangle; + + const textbox = { + value: "", + ...createComponent<"valueChange", string>(object, { + name: "textbox", + interactive: true, + draw() { + textbox.components.tree.forEach((component) => component.draw()); + + const styler = getCurrentStyler(textbox); + + let text = options.hidden + ? "*".repeat(textbox.value.length) + : textbox.value; + + let textWidth = textPixelWidth(text); + if (textWidth > width) { + text = text.slice(0, width); + textWidth = textPixelWidth(text); + } + + drawText(object.canvas, { + column, + row, + text, + styler, + }); + }, + ...options, + }), + }; + + const focusedWithin = [textbox, ...options.focusedWithin]; + + createBox(textbox, { + ...options, + focusedWithin, + }); + + if (options.styler.border) { + createFrame(textbox, { + ...options, + rectangle: { + column: column - 1, + row: row - 1, + width: width + 1, + height: height + 1, + }, + styler: options.styler.border, + focusedWithin, + }); + } + + const position = { + x: 0, + y: 0, + }; + + const moveKey = (direction: "up" | "down" | "left" | "right") => { + switch (direction) { + case "left": + position.x = Math.max(--position.x, 0); + break; + case "right": + position.x = Math.min(++position.x, textbox.value.length); + break; + case "up": + position.y = Math.max(--position.y, 0); + break; + case "down": + position.y = Math.min( + ++position.y, + textbox.value.split("\n").length, + ); + break; + } + }; + + const pushCharacter = (character: string) => { + textbox.value = textbox.value.slice(0, position.x) + character + + textbox.value.slice(position.x); + moveKey("right"); + }; + + textbox.on("keyPress", ({ key, ctrl, meta }) => { + const startValue = textbox.value; + + if (!ctrl && !meta && key.length === 1) { + pushCharacter(key); + } + + switch (key) { + case "space": + pushCharacter(" "); + break; + case "left": + case "right": + case "up": + case "down": + moveKey(key); + break; + case "backspace": + textbox.value = textbox.value.substr(0, position.x - 1) + + textbox.value.substr(position.x); + moveKey("left"); + break; + case "delete": + textbox.value = textbox.value.substr(0, position.x) + + textbox.value.substr(position.x + 1); + break; + case "home": + position.x = 0; + break; + case "end": + position.x = textbox.value.length; + break; + } + + if (startValue !== textbox.value) { + textbox.emitter.emit("valueChange", startValue); + textbox.emitter.emit("redraw"); + textbox.instance.emitter.emit("draw"); + } + }); + + textbox.instance.on("draw", textbox.draw); + + return textbox; +} diff --git a/event_emitter.ts b/src/event_emitter.ts similarity index 100% rename from event_emitter.ts rename to src/event_emitter.ts diff --git a/frame_buffer.ts b/src/frame_buffer.ts similarity index 100% rename from frame_buffer.ts rename to src/frame_buffer.ts diff --git a/key_reader.ts b/src/key_reader.ts similarity index 98% rename from key_reader.ts rename to src/key_reader.ts index c413685..27a9c8a 100644 --- a/key_reader.ts +++ b/src/key_reader.ts @@ -30,7 +30,7 @@ export function decodeBuffer(buffer: Uint8Array): KeyPress[] { const decodedBuffer = decoder.decode(buffer); let keys = [decodedBuffer]; - // Splitting seems fine, i'm expecting bugs though + // Splitting seems fine, I'm expecting bugs though if (decodedBuffer.split("\x1b").length > 1) { // deno-lint-ignore no-control-regex keys = decodedBuffer.split(/(?=\x1b)/); @@ -54,7 +54,7 @@ export function decodeBuffer(buffer: Uint8Array): KeyPress[] { keyPress.key = "return"; break; case "\n": - keyPress.key = "enter"; + keyPress.key = "return"; break; case "\t": keyPress.key = "tab"; diff --git a/src/keyboard.ts b/src/keyboard.ts new file mode 100644 index 0000000..caf7069 --- /dev/null +++ b/src/keyboard.ts @@ -0,0 +1,86 @@ +import { drawText } from "./canvas.ts"; +import { KeyPress, readKeypresses } from "./key_reader.ts"; +import { TuiInstance } from "./tui.ts"; + +export const positions: { [instanceId: number]: { x: number; y: number } } = {}; + +export async function handleKeypresses(instance: TuiInstance) { + function emit(keyPress: KeyPress) { + instance.emitter.emit("keyPress", keyPress); + const { component } = instance.components.focused; + if (component) { + component.emitter.emit("keyPress", keyPress); + } + } + + for await (const keyPresses of readKeypresses(instance.reader)) { + keyPresses.forEach(emit); + + if (keyPresses.length > 1) { + emit({ + key: keyPresses.join("+"), + buffer: keyPresses[0].buffer, + ctrl: keyPresses.some((kp) => kp.ctrl), + meta: keyPresses.some((kp) => kp.meta), + shift: keyPresses.some((kp) => kp.shift), + }); + } + } +} + +export function handleKeyboardControls(instance: TuiInstance) { + instance.on("keyPress", (keyPress) => { + if (!keyPress) return; + + const { focused } = instance.components; + + instance.components.isActive = keyPress.key.includes("return"); + if (instance.components.isActive && focused.component) { + focused.component.emitter.emit("active"); + instance.emitter.emit("draw"); + } + + if (keyPress.shift) { + switch (keyPress.key) { + case "up": + focusItem(instance, { x: 0, y: -1 }); + break; + case "down": + focusItem(instance, { x: 0, y: 1 }); + break; + case "left": + focusItem(instance, { x: -1, y: 0 }); + break; + case "right": + focusItem(instance, { x: 1, y: 0 }); + break; + } + } + }); +} + +export function focusItem( + instance: TuiInstance, + vector: { x: -1 | 0 | 1; y: -1 | 0 | 1 }, +) { + const { mapping } = instance.components.focusMap; + const { focused } = instance.components; + + positions[instance.id] ||= { x: 0, y: 0 }; + const position = positions[instance.id]; + position.y = Math.abs((position.y + vector.y) % mapping.length); + position.x = Math.abs((position.x + vector.x) % mapping[position.y].length); + + instance.emitter.emit("draw"); + + focused.id = mapping[position.y][position.x].id; + focused.component = mapping[position.y][position.x]; + + focused.component.emitter.emit("focus"); + + drawText(instance.canvas, { + column: 1, + row: 10, + text: `focused: ${focused.component?.name} pos:${position.x}/${position.y}`, + }); +} diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 0000000..f13220b --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,66 @@ +import { CanvasInstance, createCanvas, draw } from "./canvas.ts"; +import { createEventEmitter, EventEmitter } from "./event_emitter.ts"; +import { KeyPress } from "./key_reader.ts"; +import { AnyComponent } from "./tui_component.ts"; +import { Reader, TuiStyler, Writer } from "./types.ts"; + +export type TuiInstance = { + readonly id: number; + reader: Reader; + writer: Writer; + components: { + focusMap: { + mapping: AnyComponent[][]; + [row: number]: { + [col: number]: AnyComponent[]; + }; + }; + focused: { + id: number; + component: AnyComponent | null; + }; + tree: AnyComponent[]; + isActive: boolean; + }; + canvas: CanvasInstance; + emitter: EventEmitter<"keyPress", KeyPress> & EventEmitter<"draw", undefined>; + on: TuiInstance["emitter"]["on"]; + off: TuiInstance["emitter"]["off"]; + once: TuiInstance["emitter"]["once"]; +}; + +let instanceId = 0; +export function createTui( + reader: Reader, + writer: Writer, + styler: TuiStyler, +): TuiInstance { + const canvas = createCanvas(writer, styler); + + const emitter = createEventEmitter() as TuiInstance["emitter"]; + + const tui: TuiInstance = { + id: instanceId++, + reader, + writer, + components: { + focusMap: { mapping: [] }, + focused: { + id: -1, + component: null, + }, + isActive: false, + tree: [], + }, + canvas, + emitter, + on: emitter.on, + once: emitter.once, + off: emitter.off, + }; + + tui.on("draw", () => draw(canvas)); + tui.emitter.emit("draw"); + + return tui; +} diff --git a/src/tui_component.ts b/src/tui_component.ts new file mode 100644 index 0000000..65f3c5b --- /dev/null +++ b/src/tui_component.ts @@ -0,0 +1,159 @@ +import { CanvasInstance } from "./canvas.ts"; +import { createEventEmitter, EventEmitter } from "./event_emitter.ts"; +import { KeyPress } from "./key_reader.ts"; +import { TuiInstance } from "./tui.ts"; +import { TuiRectangle, TuiStyler } from "./types.ts"; + +export function getInstance(object: TuiInstance | AnyComponent) { + return Object.hasOwn(object, "instance") + ? ( object).instance + : object as TuiInstance; +} + +export type GetCurrentStylerOptions = { + focused?: { + value: boolean; + force?: boolean; + }; + active?: { + value: boolean; + force?: boolean; + }; +}; +export function getCurrentStyler( + component: AnyComponent, + override?: GetCurrentStylerOptions, +) { + const { styler } = component; + const { focused, isActive } = component.instance.components; + + const isFocused = override?.focused || + !override?.focused?.force && (focused.id === component.id || + component.components.focusedWithin.some((c) => c.id === focused.id)); + + return (isFocused && + (override?.active?.value || !override?.active?.force && isActive) + ? styler.active || styler.focused + : isFocused + ? styler.focused + : styler) || styler; +} + +export type TuiComponent = { + readonly id: number; + name: string; + canvas: CanvasInstance; + interactive: boolean; + instance: TuiInstance; + rectangle: TuiRectangle; + emitter: + & EventEmitter<"keyPress", KeyPress> + & EventEmitter<"redraw" | "focus" | "active", undefined> + & EventEmitter; + components: { + focusedWithin: AnyComponent[]; + father: { + components: { + tree: AnyComponent[]; + }; + }; + tree: AnyComponent[]; + }; + styler: TuiStyler; + on: TuiComponent["emitter"]["on"]; + off: TuiComponent["emitter"]["off"]; + once: TuiComponent["emitter"]["once"]; + draw: () => void; +}; + +// deno-lint-ignore no-explicit-any +export type AnyComponent = TuiComponent; + +export type CreateComponentOptions = { + name: string; + styler: TuiStyler; + rectangle: TuiRectangle; + interactive: boolean; + focusedWithin: AnyComponent[]; + draw?: () => void; +}; + +let componentId = 0; +export function createComponent( + object: TuiInstance | AnyComponent, + { name, interactive, styler, rectangle, focusedWithin, draw }: + CreateComponentOptions, +): TuiComponent { + const emitter = createEventEmitter() as TuiComponent< + Events, + Attributes + >["emitter"]; + + const instance = getInstance(object); + + const component: TuiComponent = { + name, + instance, + interactive, + id: componentId++, + canvas: instance.canvas, + styler, + rectangle, + emitter, + on: emitter.on, + once: emitter.once, + off: emitter.off, + components: { + focusedWithin: focusedWithin || [], + father: object, + tree: [], + }, + draw: draw || (() => {}), + }; + + if (component.interactive) { + const { column, row } = component.rectangle; + const { focusMap } = instance.components; + focusMap[row] ||= []; + focusMap[row][column] ||= []; + focusMap[row][column].push(component); + + const mapping: AnyComponent[][] = []; + + const isNotNaN = (n: unknown) => !Number.isNaN(n); + + const rows = (Object.getOwnPropertyNames(focusMap).map(Number)) + .sort((a, b) => a - b).filter(isNotNaN); + + rows.forEach((row, r) => { + mapping[r] ||= []; + + const columns = (Object.getOwnPropertyNames(focusMap[row]).map(Number)) + .sort((a, b) => a - b).filter(isNotNaN); + + columns.forEach((column) => { + const components = focusMap[row][column]; + mapping[r].push(...components); + }); + }); + + focusMap.mapping = mapping; + } + + object.components.tree.push(component); + + instance.on("draw", component.draw); + component.on("redraw", component.draw); + + const redrawCanvas = () => { + component.emitter.emit("redraw"); + instance.emitter.emit("draw"); + }; + + component.on("focus", redrawCanvas); + component.on("active", redrawCanvas); + + redrawCanvas(); + + return component; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e9c9b79 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,24 @@ +import { CanvasStyler } from "./canvas.ts"; +import { TuiInstance } from "./tui.ts"; +import { AnyComponent } from "./tui_component.ts"; + +export type Writer = Deno.Writer & { readonly rid: number }; +export type Reader = Deno.Reader & { readonly rid: number }; + +export type TuiRectangle = { + column: number; + row: number; + width: number; + height: number; +}; + +export type TuiStyler = CanvasStyler & { + active?: CanvasStyler; + focused?: CanvasStyler; + border?: CanvasStyler & { + active?: CanvasStyler; + focused?: CanvasStyler; + }; +}; + +export type TuiObject = TuiInstance | AnyComponent; diff --git a/tests/demo.ts b/tests/demo.ts index cbf6849..9946628 100644 --- a/tests/demo.ts +++ b/tests/demo.ts @@ -1,5 +1,6 @@ import * as Tui from "../mod.ts"; import type { TuiStyler } from "../mod.ts"; +import { handleKeyboardControls, handleKeypresses } from "../mod.ts"; const styler: TuiStyler = { foreground: "white", @@ -7,10 +8,13 @@ const styler: TuiStyler = { }; const tui = Tui.createTui(Deno.stdin, Deno.stdout, styler); +handleKeypresses(tui); +handleKeyboardControls(tui); -const tb = Tui.createTextbox(tui, { +Tui.createTextbox(tui, { + focusedWithin: [], rectangle: { - column: 10, + column: 40, row: 1, height: 1, width: 10, @@ -27,6 +31,14 @@ const tb = Tui.createTextbox(tui, { border: { background: "yellow", foreground: "cyan", + active: { + background: "black", + foreground: "white", + }, + focused: { + background: "magenta", + foreground: "lightMagenta", + }, }, focused: { foreground: "green", @@ -35,9 +47,38 @@ const tb = Tui.createTextbox(tui, { }, }); -tui.components.focused = tb; +Tui.createTextbox(tui, { + focusedWithin: [], + + rectangle: { + column: 10, + row: 1, + height: 1, + width: 10, + }, + hidden: false, + multiline: false, + styler: { + background: "red", + foreground: "white", + active: { + background: "green", + foreground: "white", + }, + border: { + background: "yellow", + foreground: "cyan", + }, + focused: { + foreground: "green", + background: "lightWhite", + }, + }, +}); Tui.createButton(tui, { + focusedWithin: [], + rectangle: { column: 5, row: 5, @@ -48,14 +89,20 @@ Tui.createButton(tui, { styler: { background: "red", foreground: "white", - active: { + focused: { background: "green", foreground: "white", }, + active: { + background: "lightGreen", + foreground: "white", + }, }, }); Tui.createCheckbox(tui, { + focusedWithin: [], + default: false, column: 2, row: 2, styler: { @@ -68,8 +115,10 @@ Tui.createCheckbox(tui, { border: { background: "yellow", foreground: "green", + focused: { + background: "magenta", + foreground: "lightBlue", + }, }, }, }); - -tui.emitter.emit("drawLoop"); diff --git a/textbox.ts b/textbox.ts deleted file mode 100644 index 5b3ec4a..0000000 --- a/textbox.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { createBox } from "./box.ts"; -import { drawText } from "./canvas.ts"; -import { createFrame } from "./frame.ts"; -import { textPixelWidth } from "./label.ts"; - -import { - createComponent, - TuiComponent, - TuiInstance, - TuiRectangle, - TuiStyler, -} from "./tui.ts"; -import { Key } from "./types.ts"; - -export type CreateTextboxOptions = { - styler: TuiStyler; - hidden: boolean; - multiline: boolean; - rectangle: TuiRectangle; -}; - -export type TextboxComponent = TuiComponent<"valueChange", boolean> & { - value: string; -}; - -export function createTextbox( - object: TuiInstance | TuiComponent, - options: CreateTextboxOptions, -): TextboxComponent { - const { column, row, width, height } = options.rectangle; - - const instance = Object.hasOwn(object, "instance") - ? ( object).instance - : object as TuiInstance; - - const textbox: TextboxComponent = { - ...createComponent<"valueChange", boolean>( - object, - { - id: "textbox", - interactive: true, - canvas: object.canvas, - rectangle: { - column, - row, - width, - height, - }, - styler: options.styler, - }, - ), - value: "", - }; - - const funcs: (() => void)[] = []; - - const box = createBox(textbox, { - rectangle: options.rectangle, - styler: options.styler, - focusingItems: [textbox], - }); - - funcs.push(box.draw); - - if (options.styler?.border) { - const border = createFrame( - textbox, - { - rectangle: { - column: column - 1, - row: row - 1, - width: width + 1, - height: height + 1, - }, - styler: options.styler.border, - }, - ); - - funcs.push(border.draw); - } - - funcs.push(() => { - const focused = instance.components.focused === textbox; - const active = focused && instance.components.active; - - const currentStyler = (focused - ? options.styler.focused - : active - ? options.styler.active - : options.styler) || options.styler; - - let text = options.hidden - ? "*".repeat(textbox.value.length) - : textbox.value; - let textWidth = textPixelWidth(text); - if (textWidth > width) { - text = text.slice(0, width); - textWidth = textPixelWidth(text); - } - - drawText(object.canvas, { - column, - row, - text, - styler: currentStyler, - }); - }); - - textbox.draw = () => funcs.forEach((func) => func()); - - const keyPosition = { - x: 0, - y: 0, - }; - - function moveKey(direction: "up" | "down" | "left" | "right") { - switch (direction) { - case "left": - keyPosition.x = Math.max(--keyPosition.x, 0); - break; - case "right": - keyPosition.x = Math.min(++keyPosition.x, textbox.value.length); - break; - case "up": - keyPosition.y = Math.max(--keyPosition.y, 0); - break; - case "down": - keyPosition.y = Math.min( - ++keyPosition.y, - textbox.value.split("\n").length, - ); - break; - } - } - - const pushCharacter = (character: string) => { - textbox.value = textbox.value.slice(0, keyPosition.x) + character + - textbox.value.slice(keyPosition.x); - - moveKey("right"); - }; - - textbox.on("keyPress", (keyPress) => { - if (typeof keyPress === "object") { - const key = keyPress.key as Key; - - if (!keyPress.ctrl && !keyPress.meta && key.length === 1) { - pushCharacter(key); - } - - switch (key) { - case "space": - pushCharacter(" "); - break; - case "left": - case "right": - case "up": - case "down": - moveKey(key); - break; - case "backspace": - textbox.value = textbox.value.substr(0, keyPosition.x - 1) + - textbox.value.substr(keyPosition.x); - moveKey("left"); - break; - case "delete": - textbox.value = textbox.value.substr(0, keyPosition.x) + - textbox.value.substr(keyPosition.x + 1); - break; - case "home": - keyPosition.x = 0; - break; - case "end": - keyPosition.x = textbox.value.length; - break; - } - - textbox.emitter.emit("redraw"); - } - }); - - instance.on("drawLoop", textbox.draw); - textbox.on("redraw", textbox.draw); - - return textbox; -} diff --git a/tui.ts b/tui.ts deleted file mode 100644 index 90f0848..0000000 --- a/tui.ts +++ /dev/null @@ -1,176 +0,0 @@ -import * as Canvas from "./canvas.ts"; -import * as Emitter from "./event_emitter.ts"; - -import { CanvasInstance, CanvasStyler } from "./canvas.ts"; -import type { EventEmitter } from "./event_emitter.ts"; -import { KeyPress, readKeypresses } from "./key_reader.ts"; -import type { Reader, Writer } from "./types.ts"; - -export type TuiRectangle = { - column: number; - row: number; - width: number; - height: number; -}; - -export type TuiStyler = CanvasStyler & { - active?: CanvasStyler; - focused?: CanvasStyler; - border?: CanvasStyler & { - active?: CanvasStyler; - focused?: CanvasStyler; - }; -}; - -export type TuiComponent = { - id: string; - canvas: CanvasInstance; - interactive: boolean; - instance: TuiInstance; - rectangle: TuiRectangle; - emitter: EventEmitter< - "keyPress" | "redraw" | (Events extends string ? Events : "redraw"), - KeyPress | undefined | (Attributes extends void ? undefined : Attributes) - >; - components: { - father: { - components: { - tree: AnyComponent[]; - }; - }; - tree: AnyComponent[]; - }; - styler: TuiStyler; - on: TuiComponent["emitter"]["on"]; - off: TuiComponent["emitter"]["off"]; - once: TuiComponent["emitter"]["once"]; - draw: () => void; -}; - -// deno-lint-ignore no-explicit-any -export type AnyComponent = TuiComponent; - -export type CreateComponentOptions = { - id: string; - canvas: CanvasInstance; - styler: TuiStyler; - rectangle: TuiRectangle; - interactive: boolean; -}; - -export function createComponent( - object: TuiInstance | AnyComponent, - options: CreateComponentOptions, -): TuiComponent { - const emitter: TuiComponent["emitter"] = Emitter - .createEventEmitter(); - - const instance = Object.hasOwn(object, "instance") - ? ( object).instance - : object as TuiInstance; - - const component: TuiComponent = { - instance: instance, - interactive: options.interactive, - id: options.id, - canvas: options.canvas, - styler: options.styler, - rectangle: options.rectangle, - emitter, - on: emitter.on, - once: emitter.once, - off: emitter.off, - components: { - father: object, - tree: [], - }, - draw: () => {}, - }; - - if (component.interactive) { - const { column, row } = component.rectangle; - instance.components.focusMap[row] ||= {}; - instance.components.focusMap[row][column] ||= []; - instance.components.focusMap[row][column].push(component); - } - - object.components.tree.push(component); - - return component; -} - -export type TuiInstance = { - reader: Reader; - writer: Writer; - components: { - reactiveMap: { - [row: number]: { - [col: number]: AnyComponent[]; - }; - }; - focusMap: { [row: number]: { [column: number]: AnyComponent[] } }; - focused: AnyComponent | null; - tree: AnyComponent[]; - active: boolean; - }; - canvas: CanvasInstance; - emitter: EventEmitter<"keyPress" | "drawLoop", KeyPress | undefined>; - on: TuiInstance["emitter"]["on"]; - off: TuiInstance["emitter"]["off"]; - once: TuiInstance["emitter"]["once"]; -}; - -export function createTui( - reader: Reader, - writer: Writer, - styler: TuiStyler, -): TuiInstance { - const canvas = Canvas.createCanvas(writer, styler); - Canvas.loop(canvas, 17); - - const emitter: TuiInstance["emitter"] = Emitter.createEventEmitter(); - - const tui: TuiInstance = { - reader, - writer, - components: { - focusMap: {}, - reactiveMap: {}, - focused: null, - active: false, - tree: [], - }, - canvas, - emitter, - on: emitter.on, - once: emitter.once, - off: emitter.off, - }; - - handleKeyboard(tui); - - return tui; -} - -export async function handleKeyboard(instance: TuiInstance) { - function emit(keyPress: KeyPress) { - instance.emitter.emit("keyPress", keyPress); - if (instance.components.focused) { - instance.components.focused.emitter.emit("keyPress", keyPress); - } - } - - for await (const keyPresses of readKeypresses(instance.reader)) { - keyPresses.forEach(emit); - - if (keyPresses.length > 1) { - emit({ - key: keyPresses.join("+"), - buffer: keyPresses[0].buffer, - ctrl: keyPresses.some((kp) => kp.ctrl), - meta: keyPresses.some((kp) => kp.meta), - shift: keyPresses.some((kp) => kp.shift), - }); - } - } -} diff --git a/types.ts b/types.ts deleted file mode 100755 index b235546..0000000 --- a/types.ts +++ /dev/null @@ -1,101 +0,0 @@ -export type Writer = Deno.Writer & { readonly rid: number }; -export type Reader = Deno.Reader & { readonly rid: number }; - -// Modified version of https://stackoverflow.com/a/68633667/14053734 -export type Range = number extends From - ? number - : _Range; - -type _Range< - From extends number, - To extends number, - R extends unknown[], -> = R["length"] extends To ? To - : - | (R["length"] extends Range<0, From> ? From : R["length"]) - | _Range; - -export type Key = - | Alphabet - | Chars - | "return" - | "enter" - | "tab" - | "backspace" - | "escape" - | "space" - | `f${Range<1, 12>}` - | `${Range<0, 10>}` - | "up" - | "down" - | "left" - | "right" - | "clear" - | "insert" - | "delete" - | `page${"up" | "down"}` - | "home" - | "end" - | "tab"; - -export type Chars = - | "!" - | "@" - | "#" - | "$" - | "%" - | "^" - | "&" - | "*" - | "(" - | ")" - | "-" - | "_" - | "=" - | "+" - | "[" - | "{" - | "]" - | "}" - | "'" - | '"' - | ";" - | ":" - | "," - | "<" - | "." - | ">" - | "/" - | "?" - | "\\" - | "|"; - -export type Alphabet = aToZ | Uppercase; - -type aToZ = - | "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - | "o" - | "p" - | "q" - | "r" - | "s" - | "t" - | "u" - | "v" - | "w" - | "x" - | "y" - | "z";