diff --git a/TODO.md b/TODO.md index fc66009..85f2b48 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,9 @@ # TODO - Add click events -- Add keystrokes event for web displays - Fix resizing on web_div display +- Prevent ctrl+c on web display - Optimize drawing to display, by allowing sequences of characters with the same colors (if supported by the display) +- Restore ansi terminal properly after exit (ctrl+c does not work anymore, for + example) diff --git a/ansi.test.ts b/ansi.test.ts index 0267553..ef8bcd6 100644 --- a/ansi.test.ts +++ b/ansi.test.ts @@ -1,5 +1,5 @@ import { AnsiColorMode, AnsiTerminalDisplay } from "./ansi.ts"; -import { Buffer, describe, expect, it } from "./testing.ts"; +import { Buffer, describe, expect, it } from "./deps.testing.ts"; function createTestDisplay(): { stdout: Buffer; diff --git a/ansi.ts b/ansi.ts index 941bbbe..9877493 100644 --- a/ansi.ts +++ b/ansi.ts @@ -11,17 +11,18 @@ export enum AnsiColorMode { /** * ANSI terminal display */ -export class AnsiTerminalDisplay implements Display { +export class AnsiTerminalDisplay extends Display { private palette_bg: readonly Uint8Array[] = []; private palette_fg: readonly Uint8Array[] = []; private width = 1; private state = { x: -1, y: -1, f: -1, b: -1 }; // current location and color - private keys: string[] = []; constructor( private writer: Deno.Writer = Deno.stdout, reader: Deno.Reader = Deno.stdin, ) { + super(); + if (hasRawMode(reader)) { this.readKeyPresses(reader); // purposefully not awaited } @@ -118,12 +119,6 @@ export class AnsiTerminalDisplay implements Display { this.state = { x, y, f, b }; } - async getKeyStrokes(): Promise { - const result = this.keys; - this.keys = []; - return result; - } - /** * Force the display size for subsequent prints */ @@ -144,7 +139,7 @@ export class AnsiTerminalDisplay implements Display { if (keypress.ctrlKey) { key = "ctrl+" + key; } - this.keys.push(key); + await this.pushEvent({ key }); } } } diff --git a/base.test.ts b/base.test.ts index f607af0..7b8683e 100644 --- a/base.test.ts +++ b/base.test.ts @@ -1,5 +1,5 @@ import { BufferDrawing, BufferLocation, CharBuffer } from "./base.ts"; -import { describe, expect, it } from "./testing.ts"; +import { describe, expect, it } from "./deps.testing.ts"; describe(CharBuffer, () => { it("initializes empty, sets and gets characters", () => { diff --git a/config.ts b/config.ts index 8b65586..409f60c 100644 --- a/config.ts +++ b/config.ts @@ -32,12 +32,15 @@ export type UIConfig = Readonly<{ onResize: (size: { w: number; h: number }) => void; // Callback to receive key strokes onKeyStroke: (key: string) => void; + // Callback to receive clicks + onMouseClick: (loc: { x: number; y: number }) => void; }>; export const UI_CONFIG_DEFAULTS: UIConfig = { ignoreCtrlC: false, hideCursor: true, palette: [], - onResize: (size) => {}, + onResize: () => {}, onKeyStroke: () => {}, + onMouseClick: () => {}, }; diff --git a/deps.testing.ts b/deps.testing.ts new file mode 100644 index 0000000..309939a --- /dev/null +++ b/deps.testing.ts @@ -0,0 +1,6 @@ +export { + describe, + expect, + it, +} from "https://js.thunderk.net/devtools@1.3.0/testing.ts"; +export { Buffer } from "https://deno.land/std@0.106.0/io/buffer.ts"; diff --git a/deps.ts b/deps.ts index c7c1b69..1c40cdc 100644 --- a/deps.ts +++ b/deps.ts @@ -1,2 +1,2 @@ -export * from "https://code.thunderk.net/typescript/functional/raw/1.0.0/all.ts"; +export { cmp } from "https://js.thunderk.net/functional@1.0.0/all.ts"; export { readKeypress } from "https://deno.land/x/keypress@0.0.7/mod.ts"; diff --git a/display.test.ts b/display.test.ts new file mode 100644 index 0000000..3565da8 --- /dev/null +++ b/display.test.ts @@ -0,0 +1,28 @@ +import { Display } from "./display.ts"; +import { describe, expect, it } from "./deps.testing.ts"; + +describe(Display, () => { + it("buffers unique events", async () => { + const display = new Display(); + await display.pushEvent({ key: "a" }); + await display.pushEvent({ key: "b" }); + await display.pushEvent({ key: "a" }); + await display.pushEvent({ click: { x: 0, y: 0 } }); + await display.pushEvent({ click: { x: 1, y: 0 } }); + await display.pushEvent({ click: { x: 0, y: 0 } }); + await display.pushEvent({ size: { w: 1, h: 1 } }); + await display.pushEvent({ size: { w: 1, h: 2 } }); + await display.pushEvent({ size: { w: 1, h: 1 } }); + await display.pushEvent({ key: "b" }); + await display.pushEvent({ click: { x: 1, y: 0 } }); + await display.pushEvent({ size: { w: 1, h: 2 } }); + expect(await display.getEvents()).toEqual([ + { key: "a" }, + { key: "b" }, + { click: { x: 0, y: 0 } }, + { click: { x: 1, y: 0 } }, + { size: { w: 1, h: 1 } }, + { size: { w: 1, h: 2 } }, + ]); + }); +}); diff --git a/display.ts b/display.ts index 95d27a8..9f71415 100644 --- a/display.ts +++ b/display.ts @@ -1,13 +1,31 @@ import { BufferLocation, BufferSize, Char, Color } from "./base.ts"; +type DisplayKeyEvent = { key: string }; +type DisplayClickEvent = { click: BufferLocation }; +type DisplaySizeEvent = { size: BufferSize }; +type DisplayEvent = Readonly< + | DisplayKeyEvent + | DisplayClickEvent + | DisplaySizeEvent +>; +type DisplayEventCombined = Partial< + & DisplayKeyEvent + & DisplayClickEvent + & DisplaySizeEvent +>; + /** * Display protocol, to allow the UI to draw things on "screen" */ export class Display { + private events: DisplayEvent[] = []; + private known_size = { w: 0, h: 0 }; + /** * Init the display (will be the first method called) */ async init(): Promise { + this.known_size = await this.getSize(); } /** @@ -54,9 +72,35 @@ export class Display { } /** - * Get the keys pressed since last call + * Push a new event */ - async getKeyStrokes(): Promise { - return []; + async pushEvent(event: DisplayEvent): Promise { + if (!this.events.some((ev) => sameEvent(ev, event))) { + this.events.push(event); + } + } + + /** + * Get the queued events + */ + async getEvents(auto_resize = true): Promise { + // TODO check only a few cycles? + if (auto_resize) { + const size = await this.getSize(); + if (size.w != this.known_size.w || size.h != this.known_size.h) { + this.known_size = size; + await this.pushEvent({ size }); + } + } + + const result = this.events; + this.events = []; + return result; } } + +function sameEvent(ev1: DisplayEventCombined, ev2: DisplayEventCombined) { + return ev1.key == ev2.key && ev1.click?.x == ev2.click?.x && + ev1.click?.y == ev2.click?.y && ev1.size?.w == ev2.size?.w && + ev1.size?.h == ev2.size?.h; +} diff --git a/testing.ts b/testing.ts deleted file mode 100644 index 9d35faf..0000000 --- a/testing.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "https://code.thunderk.net/typescript/devtools/raw/1.2.2/testing.ts"; -export { Buffer } from "https://deno.land/std@0.96.0/io/buffer.ts"; diff --git a/ui.test.ts b/ui.test.ts index dc7915c..8933723 100644 --- a/ui.test.ts +++ b/ui.test.ts @@ -1,5 +1,5 @@ import { BufferLocation, Char, Color, SPACE } from "./base.ts"; -import { describe, expect, it } from "./testing.ts"; +import { describe, expect, it } from "./deps.testing.ts"; import { Display } from "./display.ts"; import { TextUI } from "./ui.ts"; diff --git a/ui.ts b/ui.ts index e7ce785..9542450 100644 --- a/ui.ts +++ b/ui.ts @@ -107,19 +107,24 @@ export class TextUI { */ async loop(refresh = 100): Promise { while (!this.quitting) { - // handle resize - const dsize = await this.display.getSize(); - const bsize = this.buffer.getSize(); - if (dsize.w != bsize.w || dsize.h != bsize.h) { - await this.resize(dsize); - } + // handle events + for (const event of await this.display.getEvents()) { + const { key, click, size } = event; - // handle keystrokes - for (const key of await this.display.getKeyStrokes()) { - if (!this.config.ignoreCtrlC && key == "ctrl+c") { - await this.quit(); - } else { - this.config.onKeyStroke(key); + if (key) { + if (!this.config.ignoreCtrlC && key == "ctrl+c") { + await this.quit(); + } else { + this.config.onKeyStroke(key); + } + } + + if (size) { + await this.resize(size); + } + + if (click) { + this.config.onMouseClick(click); } } diff --git a/web-demo/demo.js b/web-demo/demo.js index b7b7bcf..a64bd55 100644 --- a/web-demo/demo.js +++ b/web-demo/demo.js @@ -2,6 +2,7 @@ import { createTextUI } from "./textui.js"; export async function demo(display_type) { await new Promise((resolve) => setTimeout(resolve, 500)); + let x = 0; const ui = await createTextUI({ palette: [ { r: 0, g: 0, b: 0 }, @@ -9,6 +10,10 @@ export async function demo(display_type) { { r: 0, g: 1, b: 1 }, ], onResize: draw, + onKeyStroke: (key) => { + ui.drawing.color(1, 0).text(key, { x, y: 7 }); + x += key.length + 1; + }, }, display_type); function draw() { ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 }); diff --git a/web.ts b/web.ts index 3f2a164..ad43bf9 100644 --- a/web.ts +++ b/web.ts @@ -4,9 +4,10 @@ import { Display } from "./display.ts"; // // Base for all web-based terminal displays // -class WebDisplay implements Display { +class WebDisplay extends Display { readonly document = (window as any).document; - readonly parent = this.document.body; + readonly parent = this.document.getElementById("-textui-container-") || + this.document.body; spacing = 2; font_height = 24; char_size = this.estimateCharSize(this.font_height); @@ -21,7 +22,16 @@ class WebDisplay implements Display { this.updateGrid(); this.resizing = false; }); + window.addEventListener("keydown", (event) => { + const key = convertKeyEvent(event); + if (key !== null) { + this.pushEvent({ key }); + } + }); } + this.parent.style.overflow = "hidden"; + this.parent.style.padding = "0"; + this.parent.style.margin = "0"; this.updateGrid(); } @@ -37,6 +47,9 @@ class WebDisplay implements Display { async setupPalette(colors: readonly Color[]): Promise { this.palette = colors; + if (colors.length > 0) { + this.parent.style.background = color2RGB(colors[0]); + } return colors; } @@ -73,7 +86,9 @@ class WebDisplay implements Display { // // Update the grid to match the display size // - updateGrid(): void { + // Returns true if the size changed + // + updateGrid(): boolean { const target_size = this.getTargetSize(); let char_size = { x: 0, y: 0 }; let font_height = 24; @@ -96,10 +111,26 @@ class WebDisplay implements Display { recomputeSize(); } - console.debug("Resizing", { font_height, char_size, width, height }); - this.font_height = font_height; - this.char_size = char_size; - this.size = { w: width, h: height }; + if ( + width != this.size.w || height != this.size.h || + font_height != this.font_height || char_size.x != this.char_size.x || + char_size.y != this.char_size.y + ) { + console.debug("Resizing", { + target_size, + font_height, + char_size, + width, + height, + }); + this.font_height = font_height; + this.char_size = char_size; + this.size = { w: width, h: height }; + this.pushEvent({ size: this.size }); + return true; + } else { + return false; + } } } @@ -249,26 +280,30 @@ export class CanvasTerminalDisplay extends WebDisplay { }; } - override updateGrid(): void { - super.updateGrid(); + override updateGrid(): boolean { + if (super.updateGrid()) { + const swidth = this.char_size.x * this.size.w; + const sheight = this.char_size.y * this.size.h; - const swidth = this.char_size.x * this.size.w; - const sheight = this.char_size.y * this.size.h; + this.draw.resize(this.char_size.x, this.char_size.y, this.font_height); + this.compose.resize( + swidth * this.ratio, + sheight * this.ratio, + this.font_height, + ); + this.present.resize( + swidth * this.ratio, + sheight * this.ratio, + this.font_height, + ); - this.draw.resize(this.char_size.x, this.char_size.y, this.font_height); - this.compose.resize( - swidth * this.ratio, - sheight * this.ratio, - this.font_height, - ); - this.present.resize( - swidth * this.ratio, - sheight * this.ratio, - this.font_height, - ); + this.present.element.style.width = `${Math.floor(swidth)}px`; + this.present.element.style.height = `${Math.floor(sheight)}px`; - this.present.element.style.width = `${Math.floor(swidth)}px`; - this.present.element.style.height = `${Math.floor(sheight)}px`; + return true; + } else { + return false; + } } override async init(): Promise { @@ -368,7 +403,7 @@ class Canvas { this.element.width = Math.floor(width); this.element.height = Math.floor(height); - this.ctx.font = `${font_height}px intrusion`; + this.ctx.font = `${font_height}px textui, monospace`; this.ctx.textAlign = "center"; this.ctx.textBaseline = "middle"; } @@ -419,3 +454,33 @@ function color2RGB({ r, g, b }: Color): string { Math.round(b * 255) })`; } + +function convertKeyEvent(event: any): string | null { + if (!event.key) { + return null; + } + + const keycode = event.key.toLocaleLowerCase(); + let key = MAPPING_KEYS[keycode] ?? keycode; + + if (event.shiftKey) { + key = "shift+" + key.toLocaleLowerCase(); + } + if (event.altKey) { + key = "alt+" + key; + } + if (event.ctrlKey) { + key = "ctrl+" + key; + } + + return key; +} + +const MAPPING_KEYS: { [key: string]: string } = { + "arrowdown": "down", + "arrowup": "up", + "arrowleft": "left", + "arrowright": "right", + "enter": "return", + " ": "space", +};