Compare commits

...

4 commits

31 changed files with 562 additions and 470 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
deno.d.ts
.vscode
.local
.output
web/*.js

View file

@ -1,7 +1,10 @@
# TODO
- Add click events
- Add keystrokes event for web displays
- Add click events for ansi display
- Fix resizing on web_div display
- Ignore ctrl+c on web display (generally speaking, on displays that does not
support proper 'quit')
- 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)

7
cli.ts Executable file
View file

@ -0,0 +1,7 @@
#!./run
import { runUIDemo } from "./src/demo.ts";
if (import.meta.main) {
await runUIDemo();
}

1
config/fmt.flags Normal file
View file

@ -0,0 +1 @@
--ignore=web-demo/textui.js

29
demo.ts
View file

@ -1,29 +0,0 @@
#!./run
import { AnsiTerminalDisplay } from "./ansi.ts";
import { UIConfig } from "./config.ts";
import { TextUI } from "./ui.ts";
const display = new AnsiTerminalDisplay();
let x = 0;
const config: Partial<UIConfig> = {
palette: [
{ r: 0, g: 0, b: 0 },
{ r: 1, g: 1, b: 1 },
{ r: 0, g: 1, b: 1 },
],
onResize: draw,
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();
draw();
await ui.loop();
function draw() {
ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 });
ui.drawing.color(0, 1).text("world", { x: 10, y: 5 });
}

6
deps.testing.ts Normal file
View file

@ -0,0 +1,6 @@
export {
describe,
expect,
it,
} from "https://js.thunderk.net/testing@1.0.0/mod.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";

View file

@ -1,62 +0,0 @@
import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
/**
* Display protocol, to allow the UI to draw things on "screen"
*/
export class Display {
/**
* Init the display (will be the first method called)
*/
async init(): Promise<void> {
}
/**
* Restore the display as before *init*
*/
async uninit(): Promise<void> {
}
/**
* Get the current grid size
*/
async getSize(): Promise<BufferSize> {
return { w: 0, h: 0 };
}
/**
* Setup the palette for color display
*
* If the display supports the whole RGB range, it may return the array as-is.
* If the display only supports a limited palette, it may return only supported colors.
*
* From this call forward, colors will be received by numbered index in the returned array.
*/
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
return [];
}
/**
* Set the cursor visibility
*/
async setCursorVisibility(visible: boolean): Promise<void> {
}
/**
* Flush the display
*/
async flush(): Promise<void> {
}
/**
* Draw a single character on screen
*/
async setChar(at: BufferLocation, char: Char): Promise<void> {
}
/**
* Get the keys pressed since last call
*/
async getKeyStrokes(): Promise<string[]> {
return [];
}
}

43
mod.ts
View file

@ -1,40 +1,3 @@
import { AnsiTerminalDisplay } from "./ansi.ts";
import { UIConfig } from "./config.ts";
import { Display } from "./display.ts";
import { TextUI } from "./ui.ts";
import {
CanvasTerminalDisplay,
DivTerminalDisplay,
PreTerminalDisplay,
} from "./web.ts";
export { TextUI };
export const UI_DISPLAY_TYPES = {
autodetect: undefined,
ansi: AnsiTerminalDisplay,
web_pre: PreTerminalDisplay,
web_div: DivTerminalDisplay,
web_canvas: CanvasTerminalDisplay,
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") {
if (typeof (window as any).document != "undefined") {
display_type = "web_canvas";
} else {
display_type = "ansi";
}
}
var display = new UI_DISPLAY_TYPES[display_type]();
var ui = new TextUI(display, config);
await ui.init();
return ui;
}
export { TextUI } from "./src/ui.ts";
export { createTextUI, UI_DISPLAY_TYPES } from "./src/main.ts";
export { runUIDemo } from "./src/demo.ts";

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;
@ -18,7 +18,7 @@ describe(AnsiTerminalDisplay, () => {
await display.init();
checkSequence(stdout, "![2J");
await display.uninit();
checkSequence(stdout, "![2J![2J");
checkSequence(stdout, "![2J![2J![?25h!c");
});
it("writes truecolor characters", async () => {

View file

@ -1,5 +1,5 @@
import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
import { readKeypress } from "./deps.ts";
import { readKeypress } from "../deps.ts";
import { Display } from "./display.ts";
export enum AnsiColorMode {
@ -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
}
@ -33,6 +34,8 @@ export class AnsiTerminalDisplay implements Display {
async uninit(): Promise<void> {
await this.writer.write(CLEAR);
await this.setCursorVisibility(true);
await this.writer.write(RESET);
}
async flush(): Promise<void> {
@ -118,12 +121,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 +141,7 @@ export class AnsiTerminalDisplay implements Display {
if (keypress.ctrlKey) {
key = "ctrl+" + key;
}
this.keys.push(key);
await this.pushEvent({ key });
}
}
}
@ -187,6 +184,7 @@ function get256Colors(): readonly Color[] {
}
const CLEAR = escape("[2J");
const RESET = escape("c");
/**
* Check if a reader will be compatible with raw mode

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

77
src/colors.ts Normal file
View file

@ -0,0 +1,77 @@
import { Color, PaletteMap } from "./base.ts";
import { UIPalette } from "./config.ts";
import { cmp } from "../deps.ts";
import { Display } from "./display.ts";
export async function getPaletteMapping(
palette: UIPalette,
display: Display,
): 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 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) +
(e2.g - e1.g) * (e2.g - e1.g) +
(e2.b - e1.b) * (e2.b - e1.b);*/
const c = (x: number) => Math.round(x * 255);
const rmean = (c(e1.r) + c(e2.r)) / 2;
const r = c(e1.r) - c(e2.r);
const g = c(e1.g) - c(e2.g);
const b = c(e1.b) - c(e2.b);
return Math.sqrt(
(((512 + rmean) * r * r) >> 8) + 4 * g * g + (((767 - rmean) * b * b) >> 8),
);
}

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

2
src/controls.ts Normal file
View file

@ -0,0 +1,2 @@
export interface Control {
}

34
src/demo.ts Executable file
View file

@ -0,0 +1,34 @@
import { UIConfig } from "./config.ts";
import { createTextUI, UI_DISPLAY_TYPES } from "./main.ts";
export async function runUIDemo(
display_type: keyof typeof UI_DISPLAY_TYPES = "autodetect",
): Promise<void> {
let x = 0;
const config: Partial<UIConfig> = {
palette: [
{ r: 0, g: 0, b: 0 },
{ r: 1, g: 1, b: 1 },
{ r: 0, g: 1, b: 1 },
],
onResize: draw,
onKeyStroke: (key) => {
ui.drawing.color(1, 0).text(key, { x, y: 7 });
x += key.length + 1;
},
onMouseClick: (loc) => {
const text = `${loc.x}:${loc.y}`;
ui.drawing.color(1, 0).text(text, { x, y: 7 });
x += text.length + 1;
},
};
const ui = await createTextUI(config, display_type);
await ui.init();
draw();
await ui.loop();
function draw() {
ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 });
ui.drawing.color(0, 1).text("world", { x: 10, y: 5 });
}
}

28
src/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 } },
]);
});
});

106
src/display.ts Normal file
View file

@ -0,0 +1,106 @@
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();
}
/**
* Restore the display as before *init*
*/
async uninit(): Promise<void> {
}
/**
* Get the current grid size
*/
async getSize(): Promise<BufferSize> {
return { w: 0, h: 0 };
}
/**
* Setup the palette for color display
*
* If the display supports the whole RGB range, it may return the array as-is.
* If the display only supports a limited palette, it may return only supported colors.
*
* From this call forward, colors will be received by numbered index in the returned array.
*/
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
return [];
}
/**
* Set the cursor visibility
*/
async setCursorVisibility(visible: boolean): Promise<void> {
}
/**
* Flush the display
*/
async flush(): Promise<void> {
}
/**
* Draw a single character on screen
*/
async setChar(at: BufferLocation, char: Char): Promise<void> {
}
/**
* Push a new event
*/
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;
}

40
src/main.ts Normal file
View file

@ -0,0 +1,40 @@
import { AnsiTerminalDisplay } from "./ansi.ts";
import { UIConfig } from "./config.ts";
import { Display } from "./display.ts";
import { TextUI } from "./ui.ts";
import { CanvasTerminalDisplay, DivTerminalDisplay } from "./web.ts";
export const UI_DISPLAY_TYPES = {
autodetect: undefined,
ansi: AnsiTerminalDisplay,
web_div: DivTerminalDisplay,
web_canvas: CanvasTerminalDisplay,
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") {
if (typeof (window as any).document != "undefined") {
display_type = "web_canvas";
// TODO if canvas is not available, fall back to div
} else if (typeof (Deno as any) != "undefined") {
display_type = "ansi";
} else {
const message = "Cannot initialize display";
if (typeof alert == "function") {
alert(message);
}
throw new Error(message);
}
}
var display = new UI_DISPLAY_TYPES[display_type]();
var ui = new TextUI(display, config);
await ui.init();
return ui;
}

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

132
src/ui.ts Normal file
View file

@ -0,0 +1,132 @@
import { BufferDrawing, BufferSize, CharBuffer, PaletteMap } from "./base.ts";
import { getPaletteMapping } from "./colors.ts";
import { UI_CONFIG_DEFAULTS, UIConfig } from "./config.ts";
import { Display } from "./display.ts";
/**
* Common abstraction for a textual UI
*/
export class TextUI {
private config: UIConfig;
private buffer = new CharBuffer({ w: 1, h: 1 });
private palettemap: PaletteMap = [];
private quitting = false;
constructor(private display: Display, config: Partial<UIConfig>) {
this.config = { ...UI_CONFIG_DEFAULTS, ...config };
}
get drawing(): BufferDrawing {
return new BufferDrawing(this.buffer, this.palettemap);
}
/**
* Initializes the UI and display
*
* If config.loopInterval is defined, the UI loop is
* started but not awaited.
*/
async init(): Promise<void> {
var size = await this.display.getSize();
this.buffer = new CharBuffer(size);
await this.display.init();
this.palettemap = await getPaletteMapping(
this.config.palette,
this.display,
);
if (this.config.hideCursor) {
await this.display.setCursorVisibility(false);
}
await this.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;
await this.clear();
await this.display.setCursorVisibility(true);
await this.display.uninit();
if (typeof Deno != "undefined") {
Deno.exit();
}
}
/**
* Get the current display size
*/
getSize(): BufferSize {
return this.buffer.getSize();
}
/**
* Flush the internal buffer to the display
*/
async flush(): Promise<void> {
await this.buffer.forEachDirty((at, char) =>
this.display.setChar(at, char)
);
await this.display.flush();
}
/**
* Clear the whole screen
*/
async clear(bg = 0): Promise<void> {
const { w, h } = this.getSize();
const drawing = this.drawing.color(bg, bg);
for (let y = 0; y < h; y++) {
drawing.text(Array(w).fill(" ").join(""), { x: 0, y });
}
await this.flush();
}
/**
* Resize the buffer to match the display
*/
async resize(size: BufferSize): Promise<void> {
this.buffer = new CharBuffer(size);
await this.clear();
this.config.onResize(size);
}
/**
* Start the event loop, waiting for input
*/
async loop(refresh = 100): Promise<void> {
while (!this.quitting) {
// handle events
for (const event of await this.display.getEvents()) {
const { key, click, size } = event;
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);
}
}
// flush
await this.flush();
// wait
await new Promise((resolve) => setTimeout(resolve, refresh));
}
}
}

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;
}
@ -46,10 +59,6 @@ class WebDisplay implements Display {
async setChar(at: BufferLocation, char: Char): Promise<void> {
}
async getKeyStrokes(): Promise<string[]> {
return [];
}
//
// Get the size in pixels of the target area
//
@ -73,7 +82,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,47 +107,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 };
}
}
//
// Basic terminal display using a single "pre" tag
//
export class PreTerminalDisplay extends WebDisplay {
element: any;
override async init(): Promise<void> {
await super.init();
if (!this.element) {
this.element = this.document.createElement("pre");
this.parent.appendChild(this.element);
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;
}
const { w, h } = this.size;
const line = Array(w).fill(" ").join("");
this.element.textContent = Array(h).fill(line).join("\n");
}
override async uninit(): Promise<void> {
if (this.element) {
this.parent.removeChild(this.element);
this.element = null;
}
await super.uninit();
}
override async setChar(at: BufferLocation, char: Char): Promise<void> {
const { w, h } = this.size;
const offset = at.y * (w + 1) + at.x;
const text = this.element.textContent;
this.element.textContent = text.slice(0, offset) + char.ch +
text.slice(offset + 1);
}
}
@ -201,11 +191,9 @@ export class DivTerminalDisplay extends WebDisplay {
div.style.overflow = "hidden";
this.parent.appendChild(div);
divs.push(div);
/*div.addEventListener("click", () => {
if (this.onclick) {
this.onclick({ x, y });
}
});*/
div.addEventListener("click", () => {
this.pushEvent({ click: { x, y } });
});
}
}
@ -231,14 +219,14 @@ export class CanvasTerminalDisplay extends WebDisplay {
this.compose = new Canvas(this.document, undefined);
this.present = new Canvas(this.document, this.parent);
/*this.present.element.addEventListener("click", (ev) => {
if (this.onclick) {
this.onclick({
this.present.element.addEventListener("click", (ev: any) => {
this.pushEvent({
click: {
x: Math.round((ev.offsetX * this.ratio) / this.char_size.x),
y: Math.round((ev.offsetY * this.ratio) / this.char_size.y),
});
}
});*/
},
});
});
}
override getTargetSize(): { x: number; y: number } {
@ -249,26 +237,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 +360,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 +411,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",
};

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

206
ui.ts
View file

@ -1,206 +0,0 @@
import {
BufferDrawing,
BufferSize,
CharBuffer,
Color,
PaletteMap,
} from "./base.ts";
import { UI_CONFIG_DEFAULTS, UIConfig, UIPalette } from "./config.ts";
import { cmp } from "./deps.ts";
import { Display } from "./display.ts";
/**
* Common abstraction for a textual UI
*/
export class TextUI {
private config: UIConfig;
private buffer = new CharBuffer({ w: 1, h: 1 });
private palettemap: PaletteMap = [];
private quitting = false;
constructor(private display: Display, config: Partial<UIConfig>) {
this.config = { ...UI_CONFIG_DEFAULTS, ...config };
}
get drawing(): BufferDrawing {
return new BufferDrawing(this.buffer, this.palettemap);
}
/**
* Initializes the UI and display
*
* If config.loopInterval is defined, the UI loop is
* started but not awaited.
*/
async init(): Promise<void> {
var size = await this.display.getSize();
this.buffer = new CharBuffer(size);
await this.display.init();
this.palettemap = await getPaletteMapping(
this.config.palette,
this.display,
);
if (this.config.hideCursor) {
await this.display.setCursorVisibility(false);
}
await this.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;
await this.clear();
await this.display.setCursorVisibility(true);
await this.display.uninit();
if (typeof Deno != "undefined") {
Deno.exit();
}
}
/**
* Get the current display size
*/
getSize(): BufferSize {
return this.buffer.getSize();
}
/**
* Flush the internal buffer to the display
*/
async flush(): Promise<void> {
await this.buffer.forEachDirty((at, char) =>
this.display.setChar(at, char)
);
await this.display.flush();
}
/**
* Clear the whole screen
*/
async clear(bg = 0): Promise<void> {
const { w, h } = this.getSize();
const drawing = this.drawing.color(bg, bg);
for (let y = 0; y < h; y++) {
drawing.text(Array(w).fill(" ").join(""), { x: 0, y });
}
await this.flush();
}
/**
* Resize the buffer to match the display
*/
async resize(size: BufferSize): Promise<void> {
this.buffer = new CharBuffer(size);
await this.clear();
this.config.onResize(size);
}
/**
* Start the event loop, waiting for input
*/
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 keystrokes
for (const key of await this.display.getKeyStrokes()) {
if (!this.config.ignoreCtrlC && key == "ctrl+c") {
await this.quit();
} else {
this.config.onKeyStroke(key);
}
}
// flush
await this.flush();
// wait
await new Promise((resolve) => setTimeout(resolve, refresh));
}
}
}
async function getPaletteMapping(
palette: UIPalette,
display: Display,
): 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 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) +
(e2.g - e1.g) * (e2.g - e1.g) +
(e2.b - e1.b) * (e2.b - e1.b);*/
const c = (x: number) => Math.round(x * 255);
const rmean = (c(e1.r) + c(e2.r)) / 2;
const r = c(e1.r) - c(e2.r);
const g = c(e1.g) - c(e2.g);
const b = c(e1.b) - c(e2.b);
return Math.sqrt(
(((512 + rmean) * r * r) >> 8) + 4 * g * g + (((767 - rmean) * b * b) >> 8),
);
}

1
web-demo/.gitignore vendored
View file

@ -1 +0,0 @@
textui.js

View file

@ -1,18 +0,0 @@
import { createTextUI } from "./textui.js";
export async function demo(display_type) {
await new Promise((resolve) => setTimeout(resolve, 500));
const ui = await createTextUI({
palette: [
{ r: 0, g: 0, b: 0 },
{ r: 1, g: 1, b: 1 },
{ r: 0, g: 1, b: 1 },
],
onResize: draw,
}, display_type);
function draw() {
ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 });
ui.drawing.color(0, 1).text("world", { x: 10, y: 5 });
}
await ui.loop();
}

View file

@ -1,14 +0,0 @@
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="demo.css">
<script type="module">
import { demo } from "./demo.js";
demo("web_pre");
</script>
</head>
<body></body>
</html>

View file

@ -4,8 +4,8 @@
<meta charset="utf-8">
<link rel="stylesheet" href="demo.css">
<script type="module">
import { demo } from "./demo.js";
demo("web_canvas");
import { runUIDemo } from "./mod.js";
runUIDemo("web_canvas");
</script>
</head>

View file

@ -4,8 +4,8 @@
<meta charset="utf-8">
<link rel="stylesheet" href="demo.css">
<script type="module">
import { demo } from "./demo.js";
demo("web_div");
import { runUIDemo } from "./mod.js";
runUIDemo("web_div");
</script>
</head>