Add key strokes callback

This commit is contained in:
Michaël Lemaire 2021-06-28 20:21:32 +02:00
parent 731f601cdb
commit 285550e766
9 changed files with 275 additions and 119 deletions

View file

@ -1,17 +1,26 @@
import { AnsiColorMode, AnsiTerminalDisplay } from "./ansi.ts"; import { AnsiColorMode, AnsiTerminalDisplay } from "./ansi.ts";
import { Buffer, describe, expect, it } from "./testing.ts"; import { Buffer, describe, expect, it } from "./testing.ts";
function createTestDisplay(): {
stdout: Buffer;
stdin: Buffer;
display: AnsiTerminalDisplay;
} {
const stdout = new Buffer();
const stdin = new Buffer();
const display = new AnsiTerminalDisplay(stdout, stdin);
return { stdout, stdin, display };
}
describe(AnsiTerminalDisplay, () => { describe(AnsiTerminalDisplay, () => {
it("clears the screen", async () => { it("clears the screen", async () => {
const stdout = new Buffer(); const { stdout, display } = createTestDisplay();
const display = new AnsiTerminalDisplay(stdout);
await display.clear(); await display.clear();
checkSequence(stdout, "![2J"); checkSequence(stdout, "![2J");
}); });
it("writes truecolor characters", async () => { it("writes truecolor characters", async () => {
const stdout = new Buffer(); const { stdout, display } = createTestDisplay();
const display = new AnsiTerminalDisplay(stdout);
await display.setupPalette([ await display.setupPalette([
{ r: 0.0, g: 0.0, b: 0.0 }, { r: 0.0, g: 0.0, b: 0.0 },
{ r: 0.5, g: 0.1, b: 1.0 }, { r: 0.5, g: 0.1, b: 1.0 },
@ -21,8 +30,7 @@ describe(AnsiTerminalDisplay, () => {
}); });
it("falls back to 256 colors", async () => { it("falls back to 256 colors", async () => {
const stdout = new Buffer(); const { stdout, display } = createTestDisplay();
const display = new AnsiTerminalDisplay(stdout);
const palette = await display.setupPalette([ const palette = await display.setupPalette([
{ r: 0.0, g: 0.0, b: 0.0 }, { r: 0.0, g: 0.0, b: 0.0 },
], AnsiColorMode.COLORS256); ], AnsiColorMode.COLORS256);
@ -38,8 +46,7 @@ describe(AnsiTerminalDisplay, () => {
}); });
it("moves the cursor only when needed", async () => { it("moves the cursor only when needed", async () => {
const stdout = new Buffer(); const { stdout, display } = createTestDisplay();
const display = new AnsiTerminalDisplay(stdout);
display.forceSize({ w: 4, h: 3 }); display.forceSize({ w: 4, h: 3 });
await display.setChar({ x: 0, y: 0 }, { ch: "a", fg: 0, bg: 0 }); await display.setChar({ x: 0, y: 0 }, { ch: "a", fg: 0, bg: 0 });
await display.setChar({ x: 1, y: 0 }, { ch: "b", fg: 0, bg: 0 }); await display.setChar({ x: 1, y: 0 }, { ch: "b", fg: 0, bg: 0 });
@ -50,8 +57,7 @@ describe(AnsiTerminalDisplay, () => {
}); });
it("changes colors only when needed", async () => { it("changes colors only when needed", async () => {
const stdout = new Buffer(); const { stdout, display } = createTestDisplay();
const display = new AnsiTerminalDisplay(stdout);
display.forceSize({ w: 10, h: 1 }); display.forceSize({ w: 10, h: 1 });
await display.setupPalette([ await display.setupPalette([
{ r: 0.0, g: 0.0, b: 0.0 }, { r: 0.0, g: 0.0, b: 0.0 },

42
ansi.ts
View file

@ -1,4 +1,5 @@
import { BufferLocation, BufferSize, Char, Color } from "./base.ts"; import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
import { readKeypress } from "./deps.ts";
import { Display } from "./display.ts"; import { Display } from "./display.ts";
export enum AnsiColorMode { export enum AnsiColorMode {
@ -15,11 +16,15 @@ export class AnsiTerminalDisplay implements Display {
private palette_fg: readonly Uint8Array[] = []; private palette_fg: readonly Uint8Array[] = [];
private width = 1; private width = 1;
private state = { x: -1, y: -1, f: -1, b: -1 }; // current location and color private state = { x: -1, y: -1, f: -1, b: -1 }; // current location and color
private keys: string[] = [];
constructor( constructor(
private writer: Deno.Writer = Deno.stdout, private writer: Deno.Writer = Deno.stdout,
private reader: Deno.Reader = Deno.stdin, private reader: Deno.Reader = Deno.stdin,
) { ) {
if (hasRawMode(reader)) {
this.readKeyPresses(reader); // purposefully not awaited
}
} }
async getSize(): Promise<BufferSize> { async getSize(): Promise<BufferSize> {
@ -67,6 +72,10 @@ export class AnsiTerminalDisplay implements Display {
await this.writer.write(CLEAR); await this.writer.write(CLEAR);
} }
async setCursorVisibility(visible: boolean): Promise<void> {
await this.writer.write(visible ? escape("[?25h") : escape("[?25l"));
}
async setChar(at: BufferLocation, char: Char): Promise<void> { async setChar(at: BufferLocation, char: Char): Promise<void> {
let { x, y, f, b } = this.state; let { x, y, f, b } = this.state;
@ -102,12 +111,36 @@ export class AnsiTerminalDisplay implements Display {
this.state = { x, y, f, b }; 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 * Force the display size for subsequent prints
*/ */
forceSize(size: BufferSize) { forceSize(size: BufferSize) {
this.width = size.w; this.width = size.w;
} }
private async readKeyPresses(reader: Deno.Reader & { rid: number }) {
for await (const keypress of readKeypress(reader)) {
let key = keypress.key;
if (key) {
if (keypress.shiftKey) {
key = "shift+" + (key.length == 1 ? key.toLocaleLowerCase() : key);
}
if (keypress.metaKey) {
key = "alt+" + key;
}
if (keypress.ctrlKey) {
key = "ctrl+" + key;
}
this.keys.push(key);
}
}
}
} }
function escape(sequence: string): Uint8Array { function escape(sequence: string): Uint8Array {
@ -147,3 +180,12 @@ function get256Colors(): readonly Color[] {
} }
const CLEAR = escape("[2J"); const CLEAR = escape("[2J");
/**
* Check if a reader will be compatible with raw mode
*/
function hasRawMode(
reader: Deno.Reader,
): reader is Deno.Reader & { rid: number } {
return typeof (<any> reader).rid == "number";
}

40
config.ts Normal file
View file

@ -0,0 +1,40 @@
import { Color } from "./base.ts";
/**
* Color palette requirements.
*
* The array represents the "ideal" colors desired by the application.
* When drawing things, *bg* and *fg* color information should be an index
* in this palette.
*
* For each palette index, a single color can be requested, or an
* array of accepted alternatives, with decreasing priority.
*/
export type UIPalette = ReadonlyArray<Color | ReadonlyArray<Color>>;
/**
* Configuration of the UI
*/
export type UIConfig = Readonly<{
// Set the automatic loop interval on init
// If left undefined, a call to ui.loop() will be required after init
loopInterval?: number;
// Set if anything is rendered at all
renderingEnabled: boolean;
// Ignore the ctrl+c key combo to quit the UI
ignoreCtrlC: boolean;
// Initially hide the cursor
hideCursor: boolean;
// Palette of colors that will be used by the UI
palette: UIPalette;
// Callback to receive key strokes
onKeyStroke: (key: string) => void;
}>;
export const UI_CONFIG_DEFAULTS: UIConfig = {
renderingEnabled: true,
ignoreCtrlC: false,
hideCursor: true,
palette: [],
onKeyStroke: () => {},
};

24
demo.ts
View file

@ -1,16 +1,24 @@
#!./run #!./run
import { AnsiTerminalDisplay } from "./ansi.ts"; import { AnsiTerminalDisplay } from "./ansi.ts";
import { UIConfig } from "./config.ts";
import { TextUI } from "./ui.ts"; import { TextUI } from "./ui.ts";
const display = new AnsiTerminalDisplay(); const display = new AnsiTerminalDisplay();
const ui = new TextUI(display); let x = 0;
await ui.init([ const config: Partial<UIConfig> = {
{ r: 0, g: 0, b: 0 }, palette: [
{ r: 1, g: 1, b: 1 }, { r: 0, g: 0, b: 0 },
{ r: 0, g: 1, b: 1 }, { r: 1, g: 1, b: 1 },
]); { r: 0, g: 1, b: 1 },
],
onKeyStroke: (key) => {
ui.drawing.color(1, 0).text(key, { x, y: 7 });
x += key.length + 1;
},
};
const ui = new TextUI(display, config);
await ui.init();
ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 }); ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 });
ui.drawing.color(0, 1).text("world", { x: 10, y: 5 }); ui.drawing.color(0, 1).text("world", { x: 10, y: 5 });
await ui.flush(); await ui.loop();
await new Promise((resolve) => setTimeout(resolve, 3000));

View file

@ -1 +1,2 @@
export * from "https://code.thunderk.net/typescript/functional/raw/1.0.0/all.ts"; export * from "https://code.thunderk.net/typescript/functional/raw/1.0.0/all.ts";
export { readKeypress } from "https://deno.land/x/keypress@0.0.7/mod.ts";

View file

@ -3,11 +3,13 @@ import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
/** /**
* Display protocol, to allow the UI to draw things on "screen" * Display protocol, to allow the UI to draw things on "screen"
*/ */
export interface Display { export class Display {
/** /**
* Get the displayable grid size * Get the displayable grid size
*/ */
getSize(): Promise<BufferSize>; async getSize(): Promise<BufferSize> {
return { w: 1, h: 1 };
}
/** /**
* Setup the palette for color display * Setup the palette for color display
@ -17,15 +19,32 @@ export interface Display {
* *
* From this call forward, colors will be received by numbered index in the returned array. * From this call forward, colors will be received by numbered index in the returned array.
*/ */
setupPalette(colors: readonly Color[]): Promise<readonly Color[]>; async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
return [];
}
/** /**
* Clear the whole screen * Clear the whole screen
*/ */
clear(): Promise<void>; async clear(): Promise<void> {
}
/**
* Set the cursor visibility
*/
async setCursorVisibility(visible: boolean): Promise<void> {
}
/** /**
* Draw a single character on screen * Draw a single character on screen
*/ */
setChar(at: BufferLocation, char: Char): Promise<void>; async setChar(at: BufferLocation, char: Char): Promise<void> {
}
/**
* Get the keys pressed since last call
*/
async getKeyStrokes(): Promise<string[]> {
return [];
}
} }

31
mod.ts
View file

@ -1,15 +1,28 @@
import { AnsiTerminalDisplay } from "./ansi.ts"; import { AnsiTerminalDisplay } from "./ansi.ts";
import { TextUI, UIPalette } from "./ui.ts"; import { UIConfig } from "./config.ts";
import { Display } from "./display.ts";
import { TextUI } from "./ui.ts";
export { TextUI } from "./ui.ts"; export { TextUI } from "./ui.ts";
export type UIConfig = { export const UI_DISPLAY_TYPES = {
palette: UIPalette; autodetect: undefined,
}; ansi: AnsiTerminalDisplay,
dummy: Display,
} as const;
export async function createTextUI(
config: Partial<UIConfig>,
display_type: keyof typeof UI_DISPLAY_TYPES = "autodetect",
): Promise<TextUI> {
if (display_type == "autodetect") {
// TODO detect platform
display_type = "ansi";
}
var display = new UI_DISPLAY_TYPES[display_type]();
var ui = new TextUI(display, config);
await ui.init();
export async function createTextUI(config: UIConfig): Promise<TextUI> {
// TODO detect platform
var display = new AnsiTerminalDisplay();
var ui = new TextUI(display);
await ui.init(config.palette);
return ui; return ui;
} }

View file

@ -10,8 +10,8 @@ describe(TextUI, () => {
{ r: 1.0, g: 0.0, b: 0.0 }, { r: 1.0, g: 0.0, b: 0.0 },
]; ];
const display = new PaletteTestDisplay(palette); const display = new PaletteTestDisplay(palette);
const ui = new TextUI(display); const ui = new TextUI(display, { palette });
await ui.init(palette); await ui.init();
ui.drawing.color(1, 0).text("x", { x: 0, y: 0 }); ui.drawing.color(1, 0).text("x", { x: 0, y: 0 });
await ui.flush(); await ui.flush();
expect(display.last).toEqual({ ch: "x", bg: 0, fg: 1 }); expect(display.last).toEqual({ ch: "x", bg: 0, fg: 1 });
@ -28,31 +28,25 @@ describe(TextUI, () => {
{ r: 0.1, g: 0.2, b: 0.1 }, { r: 0.1, g: 0.2, b: 0.1 },
]; ];
const display = new PaletteTestDisplay(display_palette); const display = new PaletteTestDisplay(display_palette);
const ui = new TextUI(display); const ui = new TextUI(display, { palette: app_palette });
await ui.init(app_palette); await ui.init();
ui.drawing.color(0, 1).text("x", { x: 0, y: 0 }); ui.drawing.color(0, 1).text("x", { x: 0, y: 0 });
await ui.flush(); await ui.flush();
expect(display.last).toEqual({ ch: "x", bg: 2, fg: 1 }); expect(display.last).toEqual({ ch: "x", bg: 2, fg: 1 });
}); });
}); });
class PaletteTestDisplay implements Display { class PaletteTestDisplay extends Display {
last = SPACE; last = SPACE;
constructor(public palette: Color[]) { constructor(public palette: Color[]) {
} super();
async getSize(): Promise<BufferSize> {
return { w: 1, h: 1 };
} }
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> { async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
return this.palette; return this.palette;
} }
async clear(): Promise<void> {
}
async setChar(at: BufferLocation, char: Char): Promise<void> { async setChar(at: BufferLocation, char: Char): Promise<void> {
this.last = char; this.last = char;
} }

183
ui.ts
View file

@ -5,6 +5,7 @@ import {
Color, Color,
PaletteMap, PaletteMap,
} from "./base.ts"; } from "./base.ts";
import { UI_CONFIG_DEFAULTS, UIConfig, UIPalette } from "./config.ts";
import { cmp } from "./deps.ts"; import { cmp } from "./deps.ts";
import { Display } from "./display.ts"; import { Display } from "./display.ts";
@ -12,10 +13,13 @@ import { Display } from "./display.ts";
* Common abstraction for a textual UI * Common abstraction for a textual UI
*/ */
export class TextUI { export class TextUI {
private config: UIConfig;
private screen = new CharBuffer({ w: 1, h: 1 }); private screen = new CharBuffer({ w: 1, h: 1 });
private palettemap: PaletteMap = []; private palettemap: PaletteMap = [];
private quitting = false;
constructor(private display: Display) { constructor(private display: Display, config: Partial<UIConfig>) {
this.config = { ...UI_CONFIG_DEFAULTS, ...config };
} }
get drawing(): BufferDrawing { get drawing(): BufferDrawing {
@ -23,13 +27,42 @@ export class TextUI {
} }
/** /**
* Initializes the display * Initializes the UI and display
*
* If config.loopInterval is defined, the UI loop is
* started but not awaited.
*/ */
async init(palette: UIPalette): Promise<void> { async init(): Promise<void> {
var size = await this.display.getSize(); var size = await this.display.getSize();
this.screen = new CharBuffer(size); this.screen = new CharBuffer(size);
this.palettemap = await this.getPaletteMapping(palette); this.palettemap = await getPaletteMapping(
await this.display.clear(); this.config.palette,
this.display,
);
if (this.config.renderingEnabled) {
if (this.config.hideCursor) {
await this.display.setCursorVisibility(false);
}
await this.display.clear();
}
if (this.config.loopInterval) {
this.loop(this.config.loopInterval); // purposefully not awaited
}
}
/**
* Quit the UI (this will exit the executable)
*/
async quit(): Promise<void> {
this.quitting = true;
if (this.config.renderingEnabled) {
await this.display.clear();
await this.display.setCursorVisibility(true);
}
if (typeof Deno != "undefined") {
Deno.exit();
}
} }
/** /**
@ -43,6 +76,10 @@ export class TextUI {
* Flush the internal buffer to the display * Flush the internal buffer to the display
*/ */
async flush(): Promise<void> { async flush(): Promise<void> {
if (!this.config.renderingEnabled) {
return;
}
// TODO only dirty chars // TODO only dirty chars
const { w, h } = this.screen.getSize(); const { w, h } = this.screen.getSize();
for (let y = 0; y < h; y++) { for (let y = 0; y < h; y++) {
@ -56,82 +93,78 @@ export class TextUI {
* Start the event loop, waiting for input * Start the event loop, waiting for input
*/ */
async loop(refresh = 1000): Promise<void> { async loop(refresh = 1000): Promise<void> {
while (true) { while (!this.quitting) {
for (const key of await this.display.getKeyStrokes()) {
if (!this.config.ignoreCtrlC && key == "ctrl+c") {
await this.quit();
} else {
this.config.onKeyStroke(key);
}
}
await this.flush(); await this.flush();
await new Promise((resolve) => setTimeout(resolve, refresh)); await new Promise((resolve) => setTimeout(resolve, refresh));
} }
} }
private async getPaletteMapping(
palette: UIPalette,
): Promise<PaletteMap> {
// get the colors supported by display
const app_colors = palette.map((c): Color[] => Array.isArray(c) ? c : [c]);
const all_colors = app_colors.reduce((acc, val) => acc.concat(val), []);
const display_colors = await this.display.setupPalette(all_colors);
// rank all supported colors by proximity to each app color
let ranked: {
color: Color;
idx: number;
penalty: number;
matches: { color: Color; idx: number; distance: number }[];
}[] = [];
app_colors.forEach((colors, idx) => {
colors.forEach((color, alt) => {
ranked.push({
color,
idx,
penalty: alt + 1,
matches: display_colors.map((display_color, didx) => {
return {
color: display_color,
idx: didx,
distance: colorDistance(color, display_color),
};
}).sort(cmp({ key: (info) => info.distance })),
});
});
});
// TODO negatively score colors too much near previously chosen ones
// find the best color mapping for each source color
const result = palette.map(() => -1);
while (ranked.length > 0) {
ranked.sort(
cmp({ key: (info) => info.matches[0].distance * info.penalty }),
);
const best = ranked[0];
const app_idx = best.idx;
const display_idx = best.matches[0].idx;
result[app_idx] = display_idx;
for (const color of ranked) {
color.matches = color.matches.filter((match) =>
match.idx !== display_idx
);
}
ranked = ranked.filter((color) =>
color.idx !== app_idx && color.matches.length > 0
);
}
return result;
}
} }
/** async function getPaletteMapping(
* Color palette requirements. palette: UIPalette,
* display: Display,
* The array represents the "ideal" colors desired by the application. ): Promise<PaletteMap> {
* When drawing things, *bg* and *fg* color information should be an index // get the colors supported by display
* in this palette. const app_colors = palette.map((c): Color[] => Array.isArray(c) ? c : [c]);
* const all_colors = app_colors.reduce((acc, val) => acc.concat(val), []);
* For each palette index, a single color can be requested, or an const display_colors = await display.setupPalette(all_colors);
* array of accepted alternatives, with decreasing priority.
*/ // rank all supported colors by proximity to each app color
export type UIPalette = ReadonlyArray<Color | ReadonlyArray<Color>>; let ranked: {
color: Color;
idx: number;
penalty: number;
matches: { color: Color; idx: number; distance: number }[];
}[] = [];
app_colors.forEach((colors, idx) => {
colors.forEach((color, alt) => {
ranked.push({
color,
idx,
penalty: alt + 1,
matches: display_colors.map((display_color, didx) => {
return {
color: display_color,
idx: didx,
distance: colorDistance(color, display_color),
};
}).sort(cmp({ key: (info) => info.distance })),
});
});
});
// TODO negatively score colors too much near previously chosen ones
// find the best color mapping for each source color
const result = palette.map(() => -1);
while (ranked.length > 0) {
ranked.sort(
cmp({ key: (info) => info.matches[0].distance * info.penalty }),
);
const best = ranked[0];
const app_idx = best.idx;
const display_idx = best.matches[0].idx;
result[app_idx] = display_idx;
for (const color of ranked) {
color.matches = color.matches.filter((match) =>
match.idx !== display_idx
);
}
ranked = ranked.filter((color) =>
color.idx !== app_idx && color.matches.length > 0
);
}
return result;
}
function colorDistance(e1: Color, e2: Color): number { function colorDistance(e1: Color, e2: Color): number {
/*return (e2.r - e1.r) * (e2.r - e1.r) + /*return (e2.r - e1.r) * (e2.r - e1.r) +