diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml index 849eb9c..079cf7d 100644 --- a/.github/workflows/deno.yml +++ b/.github/workflows/deno.yml @@ -42,7 +42,7 @@ jobs: deno run -A --no-check https://deno.land/x/anzu@1.0.0/src/cli.ts \ -i ./ "/.+\.ts/" \ -e "deps.ts" \ - -l "// Copyright 2022 Im-Beast. All rights reserved. MIT license." \ + -l "// Copyright 2023 Im-Beast. All rights reserved. MIT license." \ -p - name: Push changes diff --git a/LICENSE.md b/LICENSE.md index 01ffce4..8eaaf77 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # The MIT License (MIT) -## Copyright © 2021-2022 Im-Beast +## Copyright © 2021-2023 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 diff --git a/README.md b/README.md index fac1afa..7776294 100644 --- a/README.md +++ b/README.md @@ -22,37 +22,33 @@ Simple [Deno](https://github.com/denoland/deno/) module that allows easy creatio ## 🖥️ OS Support -| Operating system | Linux | macOS | Windows¹,² | WSL | -| -------------------- | ----- | ----- | ----------------------- | ---- | -| Base | ✔️ | ✔️ | ✔️ | ✔️ | -| Keyboard support | ✔️ | ✔️ | ✔️ | ✔️ | -| Mouse support | ✔️ | ✔️ | ❌ | ✔️ | -| Required permissions | none | none | --unstable --allow-ffi³ | none | +| Operating system | Linux | macOS | Windows¹,² | WSL | +| -------------------- | ----- | ----- | --------------------- | ---- | +| Base | ✔️ | ✔️ | ✔️ | ✔️ | +| Keyboard support | ✔️ | ✔️ | ✔️ | ✔️ | +| Mouse support | ✔️ | ✔️ | ✔️ | ✔️ | +| Required permissions | none | none | none | none | ¹ - [WSL](https://docs.microsoft.com/en-us/windows/wsl/install) is a heavily recommended way to run Tui on Windows, if you need to stick to clean Windows, please consider using [Windows Terminal](https://github.com/Microsoft/Terminal). +Windows without WSL is slower at writing to the console, so performance might be worse on it. ² - If unicode characters are displayed incorrectly type `chcp 65001` into the console to change active console code page to use UTF-8 encoding. -³ - Related to [this issue](https://github.com/denoland/deno/issues/5945), in order to recognize all pressed keys -(including arrows etc.) on Windows Tui uses `C:\Windows\System32\msvcrt.dll` to read pressed keys via `_getch` function, -see code [here](./src/key_reader.ts?plain=1#L116). - ## 🎓 Get started +#### Replace {version} with relevant module versions + 1. Create Tui instance ```ts -import { crayon } from "https://deno.land/x/crayon@3.3.2/mod.ts"; +import { crayon } from "https://deno.land/x/crayon@version/mod.ts"; import { Canvas, Tui } from "https://deno.land/x/tui@version/mod.ts"; const tui = new Tui({ - style: crayon.bgBlue, - canvas: new Canvas({ - refreshRate: 1000 / 60, // Run in 60FPS - stdout: Deno.stdout, - }), + style: crayon.bgBlack, // Make background black + refreshRate: 1000 / 60, // Run in 60FPS }); tui.dispatch(); // Close Tui on CTRL+C @@ -61,11 +57,10 @@ tui.dispatch(); // Close Tui on CTRL+C 2. Enable interaction using keyboard and mouse ```ts -import { handleKeyboardControls, handleKeypresses, handleMouseControls } from "https://deno.land/x/tui@version/mod.ts"; - +import { handleInput, handleKeyboardControls, handleMouseControls } from "https://deno.land/x/tui@version/mod.ts"; ... -handleKeypresses(tui); +handleInput(tui); handleMouseControls(tui); handleKeyboardControls(tui); ``` @@ -73,31 +68,50 @@ handleKeyboardControls(tui); 3. Add some components ```ts -import { ButtonComponent } from "https://deno.land/x/tui@version/src/components/mod.ts"; +import { Button } from "https://deno.land/x/tui@version/src/components/mod.ts"; ... -let value = 0; -const button = new ButtonComponent({ - tui, +// Create signal to make number automatically reactive +const number = new Signal(0); + +const button = new Button({ + parent: tui, + zIndex: 0, + label: { + text: new Computed(() => number.value.toString()), // cast number to string + }, theme: { base: crayon.bgRed, focused: crayon.bgLightRed, active: crayon.bgYellow, }, rectangle: { - column: 15, - row: 3, + column: 1, + row: 1, height: 5, width: 10, }, - label: String(value), }); -button.on("stateChange", () => { - if (button.state !== "active") return; - button.label = String(++value); -}) +// Subscribe for button state changes +button.state.subscribe((state) => { + // If button is active (pressed) make number bigger by one + if (state === "active") { + ++number.value; + } +}); + +// Listen to mousePress event +button.on("mousePress", ({ drag, movementX, movementY }) => { + if (!drag) return; + + // Use peek() to get signal's value when it happens outside of Signal/Computed/Effect + const rectangle = button.rectangle.peek(); + // Move button by how much mouse has moved while dragging it + rectangle.column += movementX; + rectangle.row += movementY; +}); ``` 4. Run Tui @@ -115,7 +129,7 @@ tui.run();
Code should be well document and easy to follow what's going on. This project follows [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) spec. -
If your pull request's code could introduce understandability trouble, please add comments to it. +
If your pull request's code can be hard to understand, please add comments to it. ## 📝 Licensing diff --git a/deno.jsonc b/deno.jsonc index 4a20779..0dcc80c 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,15 +1,11 @@ { "tasks": { - "demo": "deno run --watch --unstable -A ./examples/demo.ts" + "demo": "deno run --watch --allow-hrtime ./examples/demo.ts" }, "fmt": { "options": { "lineWidth": 120 } }, - "lint": { - "rules": { - "exclude": ["no-control-regex"] - } - } + "lock": false } diff --git a/deno.lock b/deno.lock deleted file mode 100644 index 25022bd..0000000 --- a/deno.lock +++ /dev/null @@ -1,9 +0,0 @@ -{ - "version": "2", - "remote": { - "https://deno.land/std@0.155.0/fmt/colors.ts": "ff7dc9c9f33a72bd48bc24b21bbc1b4545d8494a431f17894dbc5fe92a938fc4", - "https://deno.land/std@0.155.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c", - "https://deno.land/std@0.155.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832", - "https://deno.land/std@0.155.0/testing/asserts.ts": "ac295f7fd22a7af107580e2475402a8c386cb1bf18bf837ae266ac0665786026" - } -} diff --git a/examples/bouncing_box.ts b/examples/bouncing_box.ts deleted file mode 100644 index 1f9173c..0000000 --- a/examples/bouncing_box.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2022 Im-Beast. All rights reserved. MIT license. -// Simple example showing box bouncing similiar to famous DVD Screensaver - -import { crayon } from "https://deno.land/x/crayon@3.3.2/mod.ts"; - -import { Tui } from "../mod.ts"; -import { BoxComponent } from "../src/components/box.ts"; - -const tui = new Tui({ - style: crayon.bgBlack.white, -}); - -tui.dispatch(); - -let hue = 0; -const box = new BoxComponent({ - tui, - theme: { - base: (text: string) => crayon.bgHsl(++hue % 360, 50, 50)(text), - }, - rectangle: { - column: 1, - row: 1, - width: 6, - height: 3, - }, -}); - -const moveDirection = { - x: 1, - y: 0.5, -}; - -tui.run(); - -tui.on("update", () => { - // Frequency of this type being dispatched is based on `tui.updateRate` - // If you would like to support lower or higher update rates you should calculate delta time and base box movement on that - const canvasSize = tui.canvas.size; - - if (box.rectangle.row + box.rectangle.height >= canvasSize.rows || box.rectangle.row <= 0) { - moveDirection.y *= -1; - } - - if (box.rectangle.column + box.rectangle.width >= canvasSize.columns || box.rectangle.column <= 0) { - moveDirection.x *= -1; - } - - box.rectangle.column += moveDirection.x; - box.rectangle.row += moveDirection.y; -}); diff --git a/examples/demo.ts b/examples/demo.ts index c6792ee..90f0c4a 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -1,49 +1,49 @@ -// Copyright 2022 Im-Beast. All rights reserved. MIT license. -// Demo showcasing every component +// Copyright 2023 Im-Beast. All rights reserved. MIT license. +import { crayon } from "https://deno.land/x/crayon@3.3.3/mod.ts"; -import { crayon } from "https://deno.land/x/crayon@3.3.2/mod.ts"; - -import { handleKeyboardControls, handleKeypresses } from "../src/keyboard.ts"; -import { handleMouseControls } from "../src/mouse.ts"; import { Tui } from "../src/tui.ts"; -import { Canvas } from "../src/canvas.ts"; - -import { BoxComponent } from "../src/components/box.ts"; -import { ButtonComponent } from "../src/components/button.ts"; -import { CheckboxComponent } from "../src/components/checkbox.ts"; -import { ComboboxComponent } from "../src/components/combobox.ts"; -import { FrameComponent } from "../src/components/frame.ts"; -import { ProgressBarComponent } from "../src/components/progress_bar.ts"; -import { SliderComponent } from "../src/components/slider.ts"; -import { TextboxComponent } from "../src/components/textbox.ts"; -import { Theme } from "../src/theme.ts"; -import { LabelComponent } from "../src/components/label.ts"; -import { ScrollableViewComponent } from "../src/components/scrollable_view.ts"; -import { TableComponent } from "../src/components/table.ts"; +import { handleInput } from "../src/input.ts"; +import { handleKeyboardControls, handleMouseControls } from "../src/controls.ts"; + +import { Box } from "../src/components/box.ts"; +import { Text } from "../src/components/text.ts"; +import { Frame } from "../src/components/frame.ts"; +import { Input } from "../src/components/input.ts"; +import { Label } from "../src/components/label.ts"; +import { Table } from "../src/components/table.ts"; +import { Button } from "../src/components/button.ts"; +import { Slider } from "../src/components/slider.ts"; +import { CheckBox } from "../src/components/checkbox.ts"; +import { ComboBox } from "../src/components/combobox.ts"; +import { ProgressBar } from "../src/components/progressbar.ts"; -const baseTheme: Theme = { - base: crayon.bgLightBlue, - focused: crayon.bgCyan, - active: crayon.bgBlue, -}; +import { Theme } from "../src/theme.ts"; +import { View } from "../src/view.ts"; +import { Component } from "../mod.ts"; +import { TextBox } from "../src/components/textbox.ts"; +import { Computed } from "../src/signals.ts"; +import { Signal } from "../src/signals.ts"; -const tuiStyle = crayon.bgBlack.white; const tui = new Tui({ - style: tuiStyle, - canvas: new Canvas({ - refreshRate: 1000 / 60, - stdout: Deno.stdout, - }), + style: crayon.bgBlack, + refreshRate: 1000 / 60, }); -tui.dispatch(); - -handleKeypresses(tui); +handleInput(tui); handleMouseControls(tui); handleKeyboardControls(tui); +tui.dispatch(); +tui.run(); + +const baseTheme: Theme = { + base: crayon.bgLightBlue, + focused: crayon.bgCyan, + active: crayon.bgBlue, + disabled: crayon.bgLightBlack.black, +}; -new BoxComponent({ - tui, +new Box({ + parent: tui, theme: baseTheme, rectangle: { column: 2, @@ -51,10 +51,11 @@ new BoxComponent({ height: 5, width: 10, }, + zIndex: 0, }); -new ButtonComponent({ - tui, +new Button({ + parent: tui, theme: baseTheme, rectangle: { column: 15, @@ -62,10 +63,11 @@ new ButtonComponent({ height: 5, width: 10, }, + zIndex: 0, }); -new CheckboxComponent({ - tui, +new CheckBox({ + parent: tui, theme: baseTheme, rectangle: { column: 28, @@ -73,10 +75,12 @@ new CheckboxComponent({ height: 1, width: 1, }, + checked: false, + zIndex: 0, }); -new ComboboxComponent({ - tui, +new ComboBox({ + parent: tui, theme: baseTheme, rectangle: { column: 38, @@ -84,12 +88,13 @@ new ComboboxComponent({ height: 1, width: 7, }, - options: ["one", "two", "three", "four"], + items: ["one", "two", "three", "four"], + placeholder: "numer", zIndex: 2, }); -new ComboboxComponent({ - tui, +new ComboBox({ + parent: tui, theme: baseTheme, rectangle: { column: 38, @@ -97,25 +102,30 @@ new ComboboxComponent({ height: 1, width: 7, }, - options: ["one", "two", "three", "four"], - label: "numer", + items: ["one", "two", "three", "four"], + placeholder: "numer", zIndex: 1, }); -const progressBar1 = new ProgressBarComponent({ - tui, - theme: { - ...baseTheme, - progress: { - base: crayon.bgLightBlue.green, - focused: crayon.bgCyan.lightGreen, - active: crayon.bgBlue.lightYellow, - }, +const progress = new Signal(0); +let progressDir = 1; +const progressBarTheme = { + ...baseTheme, + progress: { + base: crayon.bgLightBlue.green, + focused: crayon.bgCyan.lightGreen, + active: crayon.bgBlue.lightYellow, }, - value: 50, +}; + +new ProgressBar({ + parent: tui, + orientation: "horizontal", + direction: "normal", + theme: progressBarTheme, + value: progress, min: 0, max: 100, - direction: "horizontal", smooth: true, rectangle: { column: 48, @@ -123,39 +133,35 @@ const progressBar1 = new ProgressBarComponent({ row: 3, width: 10, }, + zIndex: 0, }); -new LabelComponent({ - tui, - align: { - horizontal: "center", - vertical: "center", - }, +new ProgressBar({ + parent: tui, + orientation: "horizontal", + direction: "reversed", + theme: progressBarTheme, + value: progress, + min: 0, + max: 100, + smooth: true, rectangle: { - column: 75, - row: 3, - // Automatically adjust size - height: -1, - width: -1, + column: 48, + height: 2, + row: 6, + width: 10, }, - theme: { base: tuiStyle }, - value: "Centered text\nThat automatically adjusts its rectangle size\n!@#!\nSo cool\nWOW", + zIndex: 0, }); -const progressBar2 = new ProgressBarComponent({ - tui, - theme: { - ...baseTheme, - progress: { - base: crayon.bgLightBlue.green, - focused: crayon.bgCyan.lightGreen, - active: crayon.bgBlue.lightYellow, - }, - }, - value: 75, +new ProgressBar({ + parent: tui, + orientation: "vertical", + direction: "normal", + theme: progressBarTheme, + value: progress, min: 0, max: 100, - direction: "vertical", smooth: true, rectangle: { column: 48, @@ -163,97 +169,123 @@ const progressBar2 = new ProgressBarComponent({ row: 10, width: 2, }, + zIndex: 0, }); -new SliderComponent({ - tui, - theme: { - ...baseTheme, - thumb: { - base: crayon.bgMagenta, - }, +new ProgressBar({ + parent: tui, + orientation: "vertical", + direction: "reversed", + theme: progressBarTheme, + value: progress, + min: 0, + max: 100, + smooth: true, + rectangle: { + column: 52, + height: 5, + row: 10, + width: 2, }, + zIndex: 0, +}); + +new Label({ + parent: tui, + align: { + horizontal: "center", + vertical: "center", + }, + rectangle: { + column: 75, + row: 3, + width: 20, + }, + theme: { base: tui.style }, + text: "Centered text\nThat automatically adjusts its rectangle size\n!@#!\nSo cool\nWOW", + zIndex: 0, +}); + +const sliderTheme = { + ...baseTheme, + thumb: { + base: crayon.bgMagenta, + }, +}; + +new Slider({ + parent: tui, + orientation: "horizontal", + theme: sliderTheme, + adjustThumbSize: true, value: 5, min: 1, max: 10, step: 1, - direction: "horizontal", rectangle: { column: 61, height: 2, row: 3, width: 10, }, + zIndex: 0, }); -new SliderComponent({ - tui, - theme: { - ...baseTheme, - thumb: { - base: crayon.bgMagenta, - }, - }, +new Slider({ + parent: tui, + orientation: "vertical", + theme: sliderTheme, + adjustThumbSize: true, value: 5, min: 1, max: 10, step: 1, - direction: "vertical", rectangle: { column: 61, height: 5, row: 10, width: 2, }, + zIndex: 0, }); -new TextboxComponent({ - tui, - theme: baseTheme, - multiline: false, +const cursorBaseTheme = { + ...baseTheme, + cursor: { base: crayon.invert }, +}; + +new Input({ + parent: tui, + placeholder: "type smth", + theme: cursorBaseTheme, rectangle: { column: 2, row: 11, + width: 14, height: 1, - width: 10, }, - value: "hi", + zIndex: 0, }); -new TextboxComponent({ - tui, - theme: { - ...baseTheme, - placeholder: crayon.lightBlack, - }, - multiline: false, +new Input({ + parent: tui, + placeholder: "smth secret", + theme: cursorBaseTheme, + password: true, rectangle: { column: 2, - row: 15, - height: 1, - width: 10, - }, - placeholder: "example", -}); - -new TextboxComponent({ - tui, - theme: baseTheme, - multiline: false, - hidden: true, - rectangle: { - column: 15, - row: 11, + row: 13, + width: 14, height: 1, - width: 10, }, - value: "hi!", + zIndex: 0, }); -new TextboxComponent({ - tui, +new TextBox({ + parent: tui, + zIndex: 0, theme: { - ...baseTheme, + ...cursorBaseTheme, lineNumbers: { base: crayon.bgBlue.white, }, @@ -261,24 +293,21 @@ new TextboxComponent({ base: crayon.bgLightBlue, }, }, - multiline: true, lineNumbering: true, lineHighlighting: true, - hidden: false, rectangle: { column: 29, row: 11, height: 5, width: 12, }, - value: "hello!\nwhats up?", }); -new TableComponent({ - tui, +new Table({ + parent: tui, theme: { base: crayon.bgBlack.white, - frame: { focused: crayon.bgBlack.bold }, + frame: { base: crayon.bgBlack }, header: { base: crayon.bgBlack.bold.lightBlue }, selectedRow: { base: crayon.bold.bgBlue.white, @@ -291,7 +320,10 @@ new TableComponent({ height: 8, row: 11, }, - headers: ["ID", "Name"], + headers: [ + { title: "ID" }, + { title: "Name" }, + ], data: [ ["0", "Thomas Jeronimo"], ["1", "Jeremy Wanker"], @@ -301,135 +333,192 @@ new TableComponent({ ["5", "Bernardo Robertson"], ["6", "Hershel Grant"], ], - framePieces: "rounded", + charMap: "rounded", + zIndex: 0, }); -const scrollView = new ScrollableViewComponent({ - tui, - theme: { - base: crayon.bgLightBlack.lightWhite, - scrollbar: { - vertical: { - thumb: baseTheme.active, - track: baseTheme.base, - }, - horizontal: { - thumb: baseTheme.active, - track: baseTheme.base, - }, - corner: baseTheme.base, - }, - }, +const view = new View({ rectangle: { - column: 100, - row: 11, - width: 20, - height: 8, + column: 125, + row: 1, + width: 10, + height: 10, + }, + maxOffset: { + columns: 0, + rows: 20, }, }); -new LabelComponent({ - tui, - view: scrollView, - theme: { base: scrollView.style }, - align: { - horizontal: "center", - vertical: "top", +const viewBackground = new Box({ + parent: tui, + rectangle: view.rectangle, + theme: { + base: crayon.bgLightBlack, }, + zIndex: 1, +}); +// @ts-ignore- +viewBackground.NOFRAME = true; + +const viewScrollbar = new Slider({ + parent: tui, + min: 0, + max: view.maxOffset.rows, + value: 0, + step: 1, + orientation: "vertical", + adjustThumbSize: true, rectangle: { - column: 4, - row: 2, - height: -1, - width: -1, + column: view.rectangle.column + view.rectangle.width - 1, + row: view.rectangle.row, + height: view.rectangle.height, + width: 1, + }, + theme: { + thumb: { base: crayon.bgRed }, + base: crayon.bgLightBlue, }, - value: "Scroll down", + zIndex: 2, }); +// @ts-ignore- +viewScrollbar.NOFRAME = true; -new LabelComponent({ - tui, - view: scrollView, - theme: { base: scrollView.style }, - align: { - horizontal: "center", - vertical: "top", +viewScrollbar.value.subscribe((value) => { + view.offset.rows = value; +}); + +const box = new Box({ + parent: tui, + view, + rectangle: { + column: 2, + row: 1, + width: 4, + height: 2, }, + theme: { + base: crayon.bgRed, + }, + zIndex: 2, +}); + +box.interact = () => { + box.state.value = "focused"; +}; + +box.on("mousePress", ({ drag, movementX, movementY }) => { + if (!drag) return; + const rectangle = box.rectangle.value; + rectangle.column += movementX; + rectangle.row += movementY; +}); + +const moveButton = new Button({ + parent: tui, rectangle: { - column: 4, - row: 12, - height: -1, - width: -1, + column: 2, + row: 15, + width: 6, + height: 2, }, - value: "Scroll right", + label: { text: "move\nme" }, + theme: { + base: crayon.bgGreen, + focused: crayon.bgLightGreen, + active: crayon.bgMagenta, + }, + zIndex: 2, }); -new ButtonComponent({ - tui, - view: scrollView, - theme: baseTheme, +moveButton.on("mousePress", (event) => { + if (!event.drag) return; + const rectangle = moveButton.rectangle.value; + rectangle.column += event.movementX; + rectangle.row += event.movementY; +}); + +new Text({ + parent: tui, + view, rectangle: { - column: 30, - row: 12, - height: 1, - width: 7, + column: 2, + row: 13, }, - label: "Hello!!", + theme: baseTheme, + text: "wopa", + zIndex: 2, }); // Generate frames and labels for every component -queueMicrotask(() => { +tui.canvas.on("render", () => { + const components: Component[] = []; + const tuiStyleTheme = { base: tui.style }; + for (const component of tui.components) { - const { rectangle, view } = component; - if (!rectangle) continue; - - const name = component.constructor.name.replace("Component", ""); - const theme = { - base: component.view?.style ?? tuiStyle, - }; - - new LabelComponent({ - tui, - view, - theme, - align: { - horizontal: "left", - vertical: "top", - }, - rectangle: { - column: rectangle.column - 1, - row: rectangle.row - 2, - height: -1, - width: -1, - }, - value: name, - }); - - new FrameComponent({ - tui, - view, - component, - framePieces: "rounded", - theme: { - base: tuiStyle, - focused: tuiStyle.bold, - }, - }); + if ( + // @ts-expect-error NOFRAME + component.view.peek() || component.parent !== tui || component.NOFRAME || + component === performanceStats + ) { + continue; + } + + const name = component.constructor.name; + + const { column, row } = component.rectangle.peek(); + components.push( + new Text({ + parent: tui, + theme: tuiStyleTheme, + visible: false, + rectangle: { + column: column - 1, + row: row - 2, + }, + text: name, + zIndex: component.zIndex, + }), + new Frame({ + parent: tui, + rectangle: component.rectangle, + visible: false, + charMap: "rounded", + theme: tuiStyleTheme, + zIndex: component.zIndex, + }), + ); } -}); -let direction = 1; -let avgFps = 60; + tui.on("keyPress", ({ ctrl, meta, shift, key }) => { + if (!ctrl || key !== "f" || meta || shift) return; + for (const component of components) { + component.visible.value = !component.visible.value; + } + }); +}, true); -tui.run(); +const fps = new Signal(60); +let lastRender = 0; -tui.on("update", () => { - avgFps = ((avgFps * 99) + tui.canvas.fps) / 100; - const fpsText = `${avgFps.toFixed(2)} FPS`; - tui.canvas.draw(0, 0, baseTheme.base(fpsText)); +const performanceStats = new Text({ + parent: tui, + rectangle: { column: 0, row: 0 }, + theme: baseTheme, + text: new Computed(() => + `\ +FPS: ${fps.value.toFixed(2)}\ + | Components: ${tui.components.size}\ + | Drawn objects: ${tui.canvas.drawnObjects.length}\ + | Press CTRL+F to toggle Frame/Label visibility` + ), + zIndex: 0, +}); - if (progressBar1.value === progressBar1.max || progressBar1.value === progressBar1.min) { - direction *= -1; - } +tui.canvas.on("render", () => { + fps.value = 1000 / (performance.now() - lastRender); + lastRender = performance.now(); - progressBar1.value += direction; - progressBar2.value += direction; + progress.value += progressDir; + if (progress.peek() >= 100 || progress.peek() <= 0) progressDir *= -1; }); diff --git a/examples/draggable_box.ts b/examples/draggable_box.ts deleted file mode 100644 index 28c03ee..0000000 --- a/examples/draggable_box.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2022 Im-Beast. All rights reserved. MIT license. -// Example of creating your own component by extending provided ones - -import { crayon } from "https://deno.land/x/crayon@3.3.2/mod.ts"; - -import { handleKeypresses, handleMouseControls, PlaceComponentOptions, Tui } from "../mod.ts"; -import { BoxComponent } from "../src/components/box.ts"; - -const tui = new Tui({ - style: crayon.bgBlack.white, -}); - -tui.dispatch(); - -handleKeypresses(tui); -handleMouseControls(tui); - -class DraggableBoxComponent extends BoxComponent { - constructor(options: PlaceComponentOptions) { - super(options); - - tui.on("mousePress", ({ drag, x, y }) => { - if (!drag || this.state === "base") return; - - this.rectangle.column = x; - this.rectangle.row = y; - }); - } - - // Make component interactable - interact(): void { - this.state = "focused"; - } -} - -new DraggableBoxComponent({ - tui, - theme: { - base: crayon.bgBlue, - focused: crayon.bgLightBlue, - }, - rectangle: { - column: 1, - row: 1, - width: 6, - height: 3, - }, -}); - -new DraggableBoxComponent({ - tui, - theme: { - base: crayon.bgYellow, - focused: crayon.bgLightYellow, - }, - rectangle: { - column: 1, - row: 1, - width: 6, - height: 3, - }, - zIndex: 1, -}); - -new DraggableBoxComponent({ - tui, - theme: { - base: crayon.bgMagenta, - focused: crayon.bgLightMagenta, - }, - rectangle: { - column: 1, - row: 1, - width: 6, - height: 3, - }, - zIndex: 2, -}); - -new DraggableBoxComponent({ - tui, - theme: { - base: crayon.bgGreen, - focused: crayon.bgLightGreen, - }, - rectangle: { - column: 1, - row: 1, - width: 6, - height: 3, - }, - zIndex: 3, -}); - -tui.run(); diff --git a/mod.ts b/mod.ts index d23c80a..444cdc5 100644 --- a/mod.ts +++ b/mod.ts @@ -1,11 +1,14 @@ -// Copyright 2022 Im-Beast. All rights reserved. MIT license. -export * from "./src/canvas.ts"; +// Copyright 2023 Im-Beast. All rights reserved. MIT license. export * from "./src/component.ts"; +export * from "./src/controls.ts"; export * from "./src/event_emitter.ts"; -export * from "./src/key_reader.ts"; -export * from "./src/keyboard.ts"; -export * from "./src/mouse.ts"; +export * from "./src/input.ts"; export * from "./src/theme.ts"; export * from "./src/tui.ts"; export * from "./src/types.ts"; + +export * from "./src/canvas/mod.ts"; + export * from "./src/utils/mod.ts"; + +export * from "./src/input_reader/mod.ts"; diff --git a/src/canvas.ts b/src/canvas.ts deleted file mode 100644 index ed9a1b9..0000000 --- a/src/canvas.ts +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright 2022 Im-Beast. All rights reserved. MIT license. - -import { EmitterEvent, EventEmitter } from "./event_emitter.ts"; -import { Timing } from "./types.ts"; - -import { sleep } from "./utils/async.ts"; -import { textWidth } from "./utils/strings.ts"; -import { moveCursor } from "./utils/ansi_codes.ts"; -import { fits, fitsInRectangle } from "./utils/numbers.ts"; -import { isFullWidth, stripStyles, UNICODE_CHAR_REGEXP } from "./utils/strings.ts"; - -import type { ConsoleSize, Rectangle, Stdout } from "./types.ts"; -import { Deffered } from "./utils/deffered.ts"; - -const textEncoder = new TextEncoder(); - -/** Interface defining object that {Canvas}'s constructor can interpret */ -export interface CanvasOptions { - /** How often canvas tries to find differences in its frameBuffer and render */ - refreshRate: number; - /** Stdout to which canvas will render frameBuffer */ - stdout: Stdout; -} - -/** Map that contains events that {Canvas} can dispatch */ -export type CanvasEventMap = { - render: EmitterEvent<[Timing]>; - frame: EmitterEvent<[Timing]>; - resize: EmitterEvent<[ConsoleSize]>; -}; - -/** Canvas implementation that can be drawn onto and then rendered on terminal screen */ -export class Canvas extends EventEmitter { - size: ConsoleSize; - refreshRate: number; - stdout: Stdout; - frameBuffer: string[][]; - previousFrameBuffer: this["frameBuffer"]; - lastRender: number; - fps: number; - - constructor(options: CanvasOptions) { - super(); - - this.refreshRate = options.refreshRate; - this.stdout = options.stdout; - this.frameBuffer = []; - this.previousFrameBuffer = []; - this.fps = 0; - this.lastRender = 0; - this.size = Deno.consoleSize(); - - switch (Deno.build.os) { - case "windows": - this.on("render", (timing) => { - if (timing !== Timing.Pre) return; - this.resizeCanvas(Deno.consoleSize()); - }); - break; - default: - Deno.addSignalListener("SIGWINCH", () => { - this.resizeCanvas(Deno.consoleSize()); - }); - break; - } - } - - /** - * Change `size` property, then clear `frameBuffer` and `previousFrameBuffer` to force re-render all of the canvas - * If `size` parameter matches canvas's `size` property then nothing happens - */ - resizeCanvas(size: ConsoleSize): void { - const { columns, rows } = this.size; - if (size.columns === columns && size.rows === rows) return; - - this.size = size; - this.frameBuffer = []; - this.previousFrameBuffer = []; - this.emit("resize", size); - } - - /** - * Render value starting on column and row on canvas - * - * When rectangle is given: - * If particular part of the rendering doesn't fit within rectangle boundaries then it's not drawn - */ - draw(column: number, row: number, value: string, rectangle?: Rectangle): void { - if (typeof value !== "string" || value.length === 0 || typeof column !== "number" || typeof row !== "number") { - return; - } - - column = ~~column; - row = ~~row; - - const stripped = stripStyles(value); - - if (stripped.length === 0) return; - - const frameBufferRow = this.frameBuffer[row] ||= []; - - if (stripped.length === 1) { - if (!fitsInRectangle(column, row, rectangle)) return; - - frameBufferRow[column] = value; - - if (frameBufferRow[column + 1] === undefined) { - const style = value.replace(stripped, "").replaceAll("\x1b[0m", ""); - frameBufferRow[column + 1] = `${style} \x1b[0m`; - } - return; - } - - const resetStyleIndex = value.indexOf("\x1b[0m"); - const noResetStyle = resetStyleIndex === -1 ? "" : value.substring(0, resetStyleIndex); - const borderIndex = noResetStyle.lastIndexOf("m", noResetStyle.length - stripped.length); - - const distinctStyles = ( - noResetStyle.substring(0, borderIndex) + - noResetStyle.substring(borderIndex).replace(stripped, "") - ).split("\x1b[0m").filter((v) => v.length > 0); - - if (distinctStyles.length > 1) { - for (const [i, style] of distinctStyles.entries()) { - const previousStyle = distinctStyles?.[i - 1]; - this.draw(column + textWidth(previousStyle), row, style, rectangle); - } - return; - } - - const style = distinctStyles[0] ?? ""; - - if (value.includes("\n")) { - for (const [i, line] of value.split("\n").entries()) { - this.draw(column, row + i, style + line + "\x1b[0m", rectangle); - } - return; - } - - const realCharacters = stripped.match(UNICODE_CHAR_REGEXP); - if (!realCharacters?.length) return; - - if (rectangle && !fits(row, rectangle.row, rectangle.row + rectangle.height)) return; - - let offset = 0; - for (const character of realCharacters) { - const offsetColumn = column + offset; - - if (rectangle && !fits(offsetColumn, rectangle.column, rectangle.column + rectangle.width)) { - offset += isFullWidth(character) ? 2 : 1; - continue; - } - - frameBufferRow[offsetColumn] = `${style}${character}\x1b[0m`; - if (isFullWidth(character)) { - delete frameBufferRow[offsetColumn + 1]; - ++offset; - } else if (offsetColumn + 1 < frameBufferRow.length) { - frameBufferRow[offsetColumn + 1] ??= `${style} \x1b[0m`; - } - - ++offset; - } - } - - /** - * Checks for individual row and column changes in canvas, then renders just the changes. - * In the way yield and emit proper events. - */ - renderFrame(frame: string[][]): void { - this.emit("render", Timing.Pre); - - const { previousFrameBuffer, size } = this; - - rows: - for (let r = 0; r < frame.length; ++r) { - if (r >= size.rows) break rows; - - const previousRow = previousFrameBuffer[r]; - const row = this.frameBuffer[r]; - - if (JSON.stringify(previousRow) === JSON.stringify(row)) { - continue rows; - } - - columns: - for (let c = 0; c < row.length; ++c) { - if (c >= size.columns) continue rows; - - const column = row[c]; - if (previousRow?.[c] === column) continue columns; - - Deno.writeSync( - this.stdout.rid, - textEncoder.encode(moveCursor(r, c) + column), - ); - } - } - - this.lastRender = performance.now(); - this.previousFrameBuffer = structuredClone(frame); - - this.emit("render", Timing.Post); - } - - /** - * Runs a loop in which it checks whether frameBuffer has changed (anything new has been drawn). - * If so, run `renderFrame()` with current frame buffer and in the way yield and emit proper events. - * On each iteration it sleeps for adjusted `refreshRate` time. - */ - render(): () => void { - const deffered = new Deffered(); - - (async () => { - while (deffered.state === "pending") { - let deltaTime = performance.now(); - this.fps = 1000 / (performance.now() - this.lastRender); - - if (this.lastRender === 0 || JSON.stringify(this.frameBuffer) !== JSON.stringify(this.previousFrameBuffer)) { - this.emit("frame", Timing.Pre); - this.renderFrame(this.frameBuffer); - this.emit("frame", Timing.Post); - } - - deltaTime -= performance.now(); - await sleep(this.refreshRate + deltaTime); - } - })(); - - return deffered.resolve; - } -} diff --git a/src/canvas/box.ts b/src/canvas/box.ts new file mode 100644 index 0000000..02405e6 --- /dev/null +++ b/src/canvas/box.ts @@ -0,0 +1,71 @@ +// Copyright 2023 Im-Beast. All rights reserved. MIT license. +import { DrawObject, DrawObjectOptions } from "./draw_object.ts"; +import { BaseSignal } from "../signals.ts"; + +import type { Rectangle } from "../types.ts"; +import { signalify } from "../utils/signals.ts"; + +export interface BoxObjectOptions extends DrawObjectOptions { + rectangle: Rectangle | BaseSignal; + filler?: string | BaseSignal; +} + +export class BoxObject extends DrawObject<"box"> { + filler: BaseSignal; + + constructor(options: BoxObjectOptions) { + super("box", options); + + this.rectangle = signalify(options.rectangle); + this.filler = signalify(options.filler ?? " "); + } + + rerender(): void { + const { canvas, rerenderCells, omitCells } = this; + const { frameBuffer, rerenderQueue } = canvas; + const { rows, columns } = canvas.size.peek(); + + const rectangle = this.rectangle.peek(); + const style = this.style.peek(); + const filler = this.filler.peek(); + + let rowRange = Math.min(rectangle.row + rectangle.height, rows); + let columnRange = Math.min(rectangle.column + rectangle.width, columns); + + const viewRectangle = this.view.peek()?.rectangle; + if (viewRectangle) { + rowRange = Math.min(rowRange, viewRectangle.row + viewRectangle.height); + columnRange = Math.min(columnRange, viewRectangle.column + viewRectangle.width); + } + + for (let row = rectangle.row; row < rerenderCells.length; ++row) { + if (!(row in rerenderCells)) continue; + else if (row >= rowRange) continue; + + const rerenderColumns = rerenderCells[row]; + if (!rerenderColumns) break; + + const omitColumns = omitCells[row]; + + if (omitColumns?.size === rectangle.width) { + omitColumns?.clear(); + continue; + } + + const rowBuffer = frameBuffer[row] ??= []; + const rerenderQueueRow = rerenderQueue[row] ??= new Set(); + + for (const column of rerenderColumns) { + if (omitColumns?.has(column) || column < rectangle.column || column >= columnRange) { + continue; + } + + rowBuffer[column] = style(filler); + rerenderQueueRow.add(column); + } + + rerenderColumns.clear(); + omitColumns?.clear(); + } + } +} diff --git a/src/canvas/canvas.ts b/src/canvas/canvas.ts new file mode 100644 index 0000000..6bf76a5 --- /dev/null +++ b/src/canvas/canvas.ts @@ -0,0 +1,177 @@ +// Copyright 2023 Im-Beast. All rights reserved. MIT license. +import { EmitterEvent, EventEmitter } from "../event_emitter.ts"; + +import { moveCursor } from "../utils/ansi_codes.ts"; +import { SortedArray } from "../utils/sorted_array.ts"; +import { rectangleIntersection } from "../utils/numbers.ts"; + +import type { ConsoleSize, Stdout } from "../types.ts"; +import { DrawObject } from "./draw_object.ts"; +import { BaseSignal } from "../signals.ts"; +import { signalify } from "../utils/signals.ts"; + +const textEncoder = new TextEncoder(); +const textBuffer = new Uint8Array(384 ** 2); + +/** Interface defining object that {Canvas}'s constructor can interpret */ +export interface CanvasOptions { + /** Stdout to which canvas will render frameBuffer */ + stdout: Stdout; + size: ConsoleSize | BaseSignal; +} + +/** Map that contains events that {Canvas} can dispatch */ +export type CanvasEventMap = { + render: EmitterEvent<[]>; +}; + +export class Canvas extends EventEmitter { + stdout: Stdout; + size: BaseSignal; + frameBuffer: string[][]; + resize: boolean; + rerenderQueue: Set[]; + drawnObjects: SortedArray; + + constructor(options: CanvasOptions) { + super(); + + this.resize = true; + this.frameBuffer = []; + this.rerenderQueue = []; + this.stdout = options.stdout; + this.size = signalify(options.size, { deepObserve: true }); + this.drawnObjects = new SortedArray((a, b) => a.zIndex.peek() - b.zIndex.peek() || a.id - b.id); + } + + updateIntersections(object: DrawObject): void { + if (object.outOfBounds) return; + + const { omitCells, objectsUnder } = object; + + const zIndex = object.zIndex.peek(); + const rectangle = object.rectangle.peek(); + + let objectsUnderPointer = 0; + + for (const object2 of this.drawnObjects) { + if (object === object2 || object2.outOfBounds) continue; + + const zIndex2 = object2.zIndex.peek(); + if (zIndex2 < zIndex || (zIndex2 === zIndex && object2.id < object.id)) { + objectsUnder[objectsUnderPointer++] = object2; + continue; + } + + const intersection = rectangleIntersection(rectangle, object2.rectangle.peek(), true); + if (!intersection) continue; + + const rowRange = intersection.row + intersection.height; + const columnRange = intersection.column + intersection.width; + for (let row = intersection.row; row < rowRange; ++row) { + const omitColumns = omitCells[row] ??= new Set(); + + for (let column = intersection.column; column < columnRange; ++column) { + omitColumns.add(column); + } + } + } + + if (objectsUnder.length !== objectsUnderPointer) { + objectsUnder.splice(objectsUnderPointer); + } + } + + render(): void { + const { frameBuffer, drawnObjects, resize } = this; + + if (resize) this.resize = false; + + // TODO: Recalculate object intersections when their rectangle changes + for (const object of drawnObjects) { + object.outOfBounds = false; + + if (resize) { + object.rendered = false; + } + + object.update(); + + // DrawObjects might set outOfBounds in update() + if (!object.outOfBounds) { + object.updateOutOfBounds(); + } + + if (object.outOfBounds) { + continue; + } + + object.updateMovement(); + object.updatePreviousRectangle(); + } + + for (const object of drawnObjects) { + if (object.outOfBounds || (object.rendered && object.rerenderCells.length === 0)) { + continue; + } + + this.updateIntersections(object); + + if (object.rendered) { + object.rerender(); + } else { + object.render(); + object.rendered = true; + } + } + + let drawSequence = ""; + let lastRow = -1; + let lastColumn = -1; + + const { rid } = this.stdout; + const { rerenderQueue } = this; + + for (let row = 0; row < rerenderQueue.length; ++row) { + const columns = rerenderQueue[row]; + if (!columns?.size) continue; + const rowBuffer = frameBuffer[row] ??= []; + + for (const column of columns) { + if (row !== lastRow || column !== lastColumn + 1) { + drawSequence += moveCursor(row, column); + } + + const cell = rowBuffer[column]; + if (drawSequence.length + cell.length > 1024) { + Deno.writeSync( + rid, + textBuffer.subarray( + 0, + textEncoder.encodeInto(moveCursor(lastRow, lastColumn) + drawSequence, textBuffer).written, + ), + ); + drawSequence = moveCursor(row, column); + } + + drawSequence += cell; + + lastRow = row; + lastColumn = column; + } + + columns.clear(); + } + + // Complete final loop draw sequence + Deno.writeSync( + rid, + textBuffer.subarray( + 0, + textEncoder.encodeInto(moveCursor(lastRow, lastColumn) + drawSequence, textBuffer).written, + ), + ); + + this.emit("render"); + } +} diff --git a/src/canvas/draw_object.ts b/src/canvas/draw_object.ts new file mode 100644 index 0000000..4999a0e --- /dev/null +++ b/src/canvas/draw_object.ts @@ -0,0 +1,214 @@ +// Copyright 2023 Im-Beast. All rights reserved. MIT license. +import { fitsInRectangle, rectangleEquals, rectangleIntersection } from "../utils/numbers.ts"; + +import type { Style } from "../theme.ts"; +import type { Canvas } from "./canvas.ts"; +import type { Offset, Rectangle } from "../types.ts"; +import { View } from "../view.ts"; +import { BaseSignal } from "../signals.ts"; +import { signalify } from "../utils/signals.ts"; + +export interface DrawObjectOptions { + canvas: Canvas; + + omitCells?: number[]; + omitCellsPointer?: number; + + view?: View | BaseSignal; + style: Style | BaseSignal