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