From 285550e766af54923bcf5f79fc0189d418b778d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Lemaire?= Date: Mon, 28 Jun 2021 20:21:32 +0200 Subject: [PATCH] Add key strokes callback --- ansi.test.ts | 26 +++++--- ansi.ts | 42 ++++++++++++ config.ts | 40 +++++++++++ demo.ts | 24 ++++--- deps.ts | 1 + display.ts | 29 ++++++-- mod.ts | 31 ++++++--- ui.test.ts | 18 ++--- ui.ts | 183 ++++++++++++++++++++++++++++++--------------------- 9 files changed, 275 insertions(+), 119 deletions(-) create mode 100644 config.ts diff --git a/ansi.test.ts b/ansi.test.ts index c7d77de..c9b2625 100644 --- a/ansi.test.ts +++ b/ansi.test.ts @@ -1,17 +1,26 @@ import { AnsiColorMode, AnsiTerminalDisplay } from "./ansi.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, () => { it("clears the screen", async () => { - const stdout = new Buffer(); - const display = new AnsiTerminalDisplay(stdout); + const { stdout, display } = createTestDisplay(); await display.clear(); checkSequence(stdout, "![2J"); }); it("writes truecolor characters", async () => { - const stdout = new Buffer(); - const display = new AnsiTerminalDisplay(stdout); + const { stdout, display } = createTestDisplay(); await display.setupPalette([ { r: 0.0, g: 0.0, b: 0.0 }, { r: 0.5, g: 0.1, b: 1.0 }, @@ -21,8 +30,7 @@ describe(AnsiTerminalDisplay, () => { }); it("falls back to 256 colors", async () => { - const stdout = new Buffer(); - const display = new AnsiTerminalDisplay(stdout); + const { stdout, display } = createTestDisplay(); const palette = await display.setupPalette([ { r: 0.0, g: 0.0, b: 0.0 }, ], AnsiColorMode.COLORS256); @@ -38,8 +46,7 @@ describe(AnsiTerminalDisplay, () => { }); it("moves the cursor only when needed", async () => { - const stdout = new Buffer(); - const display = new AnsiTerminalDisplay(stdout); + const { stdout, display } = createTestDisplay(); display.forceSize({ w: 4, h: 3 }); 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 }); @@ -50,8 +57,7 @@ describe(AnsiTerminalDisplay, () => { }); it("changes colors only when needed", async () => { - const stdout = new Buffer(); - const display = new AnsiTerminalDisplay(stdout); + const { stdout, display } = createTestDisplay(); display.forceSize({ w: 10, h: 1 }); await display.setupPalette([ { r: 0.0, g: 0.0, b: 0.0 }, diff --git a/ansi.ts b/ansi.ts index 74d94ce..8b73b09 100644 --- a/ansi.ts +++ b/ansi.ts @@ -1,4 +1,5 @@ import { BufferLocation, BufferSize, Char, Color } from "./base.ts"; +import { readKeypress } from "./deps.ts"; import { Display } from "./display.ts"; export enum AnsiColorMode { @@ -15,11 +16,15 @@ export class AnsiTerminalDisplay implements Display { 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, private reader: Deno.Reader = Deno.stdin, ) { + if (hasRawMode(reader)) { + this.readKeyPresses(reader); // purposefully not awaited + } } async getSize(): Promise { @@ -67,6 +72,10 @@ export class AnsiTerminalDisplay implements Display { await this.writer.write(CLEAR); } + async setCursorVisibility(visible: boolean): Promise { + await this.writer.write(visible ? escape("[?25h") : escape("[?25l")); + } + async setChar(at: BufferLocation, char: Char): Promise { let { x, y, f, b } = this.state; @@ -102,12 +111,36 @@ 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 */ forceSize(size: BufferSize) { 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 { @@ -147,3 +180,12 @@ function get256Colors(): readonly Color[] { } 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 ( reader).rid == "number"; +} diff --git a/config.ts b/config.ts new file mode 100644 index 0000000..11bbb1c --- /dev/null +++ b/config.ts @@ -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>; + +/** + * 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: () => {}, +}; diff --git a/demo.ts b/demo.ts index e40a307..cc9d784 100755 --- a/demo.ts +++ b/demo.ts @@ -1,16 +1,24 @@ #!./run import { AnsiTerminalDisplay } from "./ansi.ts"; +import { UIConfig } from "./config.ts"; import { TextUI } from "./ui.ts"; const display = new AnsiTerminalDisplay(); -const ui = new TextUI(display); -await ui.init([ - { r: 0, g: 0, b: 0 }, - { r: 1, g: 1, b: 1 }, - { r: 0, g: 1, b: 1 }, -]); +let x = 0; +const config: Partial = { + palette: [ + { r: 0, g: 0, b: 0 }, + { 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(0, 1).text("world", { x: 10, y: 5 }); -await ui.flush(); -await new Promise((resolve) => setTimeout(resolve, 3000)); +await ui.loop(); diff --git a/deps.ts b/deps.ts index 14da2b0..c7c1b69 100644 --- a/deps.ts +++ b/deps.ts @@ -1 +1,2 @@ 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"; diff --git a/display.ts b/display.ts index d055145..406e982 100644 --- a/display.ts +++ b/display.ts @@ -3,11 +3,13 @@ import { BufferLocation, BufferSize, Char, Color } from "./base.ts"; /** * Display protocol, to allow the UI to draw things on "screen" */ -export interface Display { +export class Display { /** * Get the displayable grid size */ - getSize(): Promise; + async getSize(): Promise { + return { w: 1, h: 1 }; + } /** * 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. */ - setupPalette(colors: readonly Color[]): Promise; + async setupPalette(colors: readonly Color[]): Promise { + return []; + } /** * Clear the whole screen */ - clear(): Promise; + async clear(): Promise { + } + + /** + * Set the cursor visibility + */ + async setCursorVisibility(visible: boolean): Promise { + } /** * Draw a single character on screen */ - setChar(at: BufferLocation, char: Char): Promise; + async setChar(at: BufferLocation, char: Char): Promise { + } + + /** + * Get the keys pressed since last call + */ + async getKeyStrokes(): Promise { + return []; + } } diff --git a/mod.ts b/mod.ts index 6fec0bd..8f74894 100644 --- a/mod.ts +++ b/mod.ts @@ -1,15 +1,28 @@ 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 type UIConfig = { - palette: UIPalette; -}; +export const UI_DISPLAY_TYPES = { + autodetect: undefined, + ansi: AnsiTerminalDisplay, + dummy: Display, +} as const; + +export async function createTextUI( + config: Partial, + display_type: keyof typeof UI_DISPLAY_TYPES = "autodetect", +): Promise { + 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 { - // TODO detect platform - var display = new AnsiTerminalDisplay(); - var ui = new TextUI(display); - await ui.init(config.palette); return ui; } diff --git a/ui.test.ts b/ui.test.ts index 88970bd..d9ad816 100644 --- a/ui.test.ts +++ b/ui.test.ts @@ -10,8 +10,8 @@ describe(TextUI, () => { { r: 1.0, g: 0.0, b: 0.0 }, ]; const display = new PaletteTestDisplay(palette); - const ui = new TextUI(display); - await ui.init(palette); + const ui = new TextUI(display, { palette }); + await ui.init(); ui.drawing.color(1, 0).text("x", { x: 0, y: 0 }); await ui.flush(); 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 }, ]; const display = new PaletteTestDisplay(display_palette); - const ui = new TextUI(display); - await ui.init(app_palette); + const ui = new TextUI(display, { palette: app_palette }); + await ui.init(); ui.drawing.color(0, 1).text("x", { x: 0, y: 0 }); await ui.flush(); expect(display.last).toEqual({ ch: "x", bg: 2, fg: 1 }); }); }); -class PaletteTestDisplay implements Display { +class PaletteTestDisplay extends Display { last = SPACE; constructor(public palette: Color[]) { - } - - async getSize(): Promise { - return { w: 1, h: 1 }; + super(); } async setupPalette(colors: readonly Color[]): Promise { return this.palette; } - async clear(): Promise { - } - async setChar(at: BufferLocation, char: Char): Promise { this.last = char; } diff --git a/ui.ts b/ui.ts index d03dc63..7f30f1d 100644 --- a/ui.ts +++ b/ui.ts @@ -5,6 +5,7 @@ import { Color, PaletteMap, } from "./base.ts"; +import { UI_CONFIG_DEFAULTS, UIConfig, UIPalette } from "./config.ts"; import { cmp } from "./deps.ts"; import { Display } from "./display.ts"; @@ -12,10 +13,13 @@ import { Display } from "./display.ts"; * Common abstraction for a textual UI */ export class TextUI { + private config: UIConfig; private screen = new CharBuffer({ w: 1, h: 1 }); private palettemap: PaletteMap = []; + private quitting = false; - constructor(private display: Display) { + constructor(private display: Display, config: Partial) { + this.config = { ...UI_CONFIG_DEFAULTS, ...config }; } 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 { + async init(): Promise { var size = await this.display.getSize(); this.screen = new CharBuffer(size); - this.palettemap = await this.getPaletteMapping(palette); - await this.display.clear(); + this.palettemap = await getPaletteMapping( + 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 { + 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 */ async flush(): Promise { + if (!this.config.renderingEnabled) { + return; + } + // TODO only dirty chars const { w, h } = this.screen.getSize(); for (let y = 0; y < h; y++) { @@ -56,82 +93,78 @@ export class TextUI { * Start the event loop, waiting for input */ async loop(refresh = 1000): Promise { - 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 new Promise((resolve) => setTimeout(resolve, refresh)); } } - - private async getPaletteMapping( - palette: UIPalette, - ): Promise { - // 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; - } } -/** - * 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>; +async function getPaletteMapping( + palette: UIPalette, + display: Display, +): Promise { + // 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 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; +} function colorDistance(e1: Color, e2: Color): number { /*return (e2.r - e1.r) * (e2.r - e1.r) +