textui/ui.ts

207 lines
5.2 KiB
TypeScript
Raw Normal View History

2021-06-24 22:41:34 +00:00
import {
BufferDrawing,
BufferSize,
CharBuffer,
Color,
PaletteMap,
} from "./base.ts";
2021-06-28 18:21:32 +00:00
import { UI_CONFIG_DEFAULTS, UIConfig, UIPalette } from "./config.ts";
2021-06-27 21:11:49 +00:00
import { cmp } from "./deps.ts";
2021-05-13 22:04:47 +00:00
import { Display } from "./display.ts";
/**
* Common abstraction for a textual UI
*/
export class TextUI {
2021-06-28 18:21:32 +00:00
private config: UIConfig;
2021-07-19 22:48:00 +00:00
private buffer = new CharBuffer({ w: 1, h: 1 });
2021-06-24 22:41:34 +00:00
private palettemap: PaletteMap = [];
2021-06-28 18:21:32 +00:00
private quitting = false;
2021-05-13 22:04:47 +00:00
2021-06-28 18:21:32 +00:00
constructor(private display: Display, config: Partial<UIConfig>) {
this.config = { ...UI_CONFIG_DEFAULTS, ...config };
2021-05-13 22:04:47 +00:00
}
get drawing(): BufferDrawing {
2021-07-19 22:48:00 +00:00
return new BufferDrawing(this.buffer, this.palettemap);
2021-05-13 22:04:47 +00:00
}
/**
2021-06-28 18:21:32 +00:00
* Initializes the UI and display
*
* If config.loopInterval is defined, the UI loop is
* started but not awaited.
2021-05-13 22:04:47 +00:00
*/
2021-06-28 18:21:32 +00:00
async init(): Promise<void> {
2021-05-13 22:04:47 +00:00
var size = await this.display.getSize();
2021-07-19 22:48:00 +00:00
this.buffer = new CharBuffer(size);
await this.display.init();
2021-06-28 18:21:32 +00:00
this.palettemap = await getPaletteMapping(
this.config.palette,
this.display,
);
2021-07-19 22:48:00 +00:00
if (this.config.hideCursor) {
await this.display.setCursorVisibility(false);
2021-06-28 18:21:32 +00:00
}
2021-07-19 22:48:00 +00:00
await this.clear();
2021-06-28 18:21:32 +00:00
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;
2021-07-19 22:48:00 +00:00
await this.clear();
await this.display.setCursorVisibility(true);
await this.display.uninit();
2021-06-28 18:21:32 +00:00
if (typeof Deno != "undefined") {
Deno.exit();
}
2021-05-13 22:04:47 +00:00
}
2021-05-19 13:00:52 +00:00
/**
* Get the current display size
*/
getSize(): BufferSize {
2021-07-19 22:48:00 +00:00
return this.buffer.getSize();
2021-05-19 13:00:52 +00:00
}
2021-05-13 22:04:47 +00:00
/**
* Flush the internal buffer to the display
*/
async flush(): Promise<void> {
2021-07-19 22:48:00 +00:00
await this.buffer.forEachDirty((at, char) =>
2021-06-28 20:42:42 +00:00
this.display.setChar(at, char)
);
2021-07-19 22:48:00 +00:00
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);
2021-05-13 22:04:47 +00:00
}
2021-05-19 13:00:52 +00:00
/**
* Start the event loop, waiting for input
*/
2021-06-28 20:42:42 +00:00
async loop(refresh = 100): Promise<void> {
2021-06-28 18:21:32 +00:00
while (!this.quitting) {
2021-07-19 22:48:00 +00:00
// 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
2021-06-28 18:21:32 +00:00
for (const key of await this.display.getKeyStrokes()) {
if (!this.config.ignoreCtrlC && key == "ctrl+c") {
await this.quit();
} else {
this.config.onKeyStroke(key);
}
}
2021-07-19 22:48:00 +00:00
// flush
2021-05-19 13:00:52 +00:00
await this.flush();
2021-07-19 22:48:00 +00:00
// wait
2021-05-19 13:00:52 +00:00
await new Promise((resolve) => setTimeout(resolve, refresh));
}
}
2021-06-28 18:21:32 +00:00
}
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);
2021-06-24 22:41:34 +00:00
2021-06-28 18:21:32 +00:00
// 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 })),
2021-06-27 21:11:49 +00:00
});
});
2021-06-28 18:21:32 +00:00
});
2021-06-24 22:41:34 +00:00
2021-06-28 18:21:32 +00:00
// TODO negatively score colors too much near previously chosen ones
2021-06-27 21:11:49 +00:00
2021-06-28 18:21:32 +00:00
// 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 }),
);
2021-06-27 21:11:49 +00:00
2021-06-28 18:21:32 +00:00
const best = ranked[0];
const app_idx = best.idx;
const display_idx = best.matches[0].idx;
result[app_idx] = display_idx;
2021-06-27 21:11:49 +00:00
2021-06-28 18:21:32 +00:00
for (const color of ranked) {
color.matches = color.matches.filter((match) =>
match.idx !== display_idx
2021-06-27 21:11:49 +00:00
);
}
2021-06-28 18:21:32 +00:00
ranked = ranked.filter((color) =>
color.idx !== app_idx && color.matches.length > 0
);
2021-06-24 22:41:34 +00:00
}
2021-06-28 18:21:32 +00:00
return result;
2021-05-13 22:04:47 +00:00
}
2021-06-24 22:41:34 +00:00
2021-06-27 21:11:49 +00:00
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),
);
}