Fix web display events

This commit is contained in:
Michaël Lemaire 2021-08-26 20:12:36 +02:00
parent 770651f428
commit 204d29a69e
14 changed files with 208 additions and 57 deletions

View file

@ -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)

View file

@ -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;

13
ansi.ts
View file

@ -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<string[]> {
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 });
}
}
}

View file

@ -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", () => {

View file

@ -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: () => {},
};

6
deps.testing.ts Normal file
View file

@ -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";

View file

@ -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";

28
display.test.ts Normal file
View file

@ -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 } },
]);
});
});

View file

@ -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<void> {
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<string[]> {
return [];
async pushEvent(event: DisplayEvent): Promise<void> {
if (!this.events.some((ev) => sameEvent(ev, event))) {
this.events.push(event);
}
}
/**
* Get the queued events
*/
async getEvents(auto_resize = true): Promise<DisplayEventCombined[]> {
// 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;
}

View file

@ -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";

View file

@ -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";

29
ui.ts
View file

@ -107,19 +107,24 @@ export class TextUI {
*/
async loop(refresh = 100): Promise<void> {
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);
}
}

View file

@ -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 });

115
web.ts
View file

@ -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<readonly Color[]> {
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<void> {
@ -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",
};