Fix web display events
This commit is contained in:
parent
770651f428
commit
204d29a69e
14 changed files with 208 additions and 57 deletions
4
TODO.md
4
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)
|
||||
|
|
|
@ -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
13
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<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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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
6
deps.testing.ts
Normal 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";
|
2
deps.ts
2
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";
|
||||
|
|
28
display.test.ts
Normal file
28
display.test.ts
Normal 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 } },
|
||||
]);
|
||||
});
|
||||
});
|
50
display.ts
50
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<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;
|
||||
}
|
||||
|
|
|
@ -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";
|
|
@ -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
29
ui.ts
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
115
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<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",
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue