@bitblit/asyncglk
Version:
A Typescript Glk library
461 lines (457 loc) • 14.4 kB
JavaScript
/*
Glk Windows
===========
Copyright (c) 2023 Dannii Willis
MIT licenced
https://github.com/curiousdannii/asyncglk
*/
import { cloneDeep } from 'lodash-es';
import { winmethod_Above, winmethod_BorderMask, winmethod_DirMask, winmethod_DivisionMask, winmethod_Fixed, winmethod_Left, winmethod_Right, wintype_Blank, wintype_Graphics, wintype_Pair, wintype_TextBuffer, wintype_TextGrid } from './constants.js';
import { WindowStream } from './streams.js';
class WindowBase {
// TODO: Make most of these private?
box = {
bottom: 0,
left: 0,
right: 0,
top: 0,
};
disprock = 0;
echostr = null;
input = {
id: 0,
};
next = null;
parent = null;
prev = null;
rock;
str;
constructor(rock) {
this.rock = rock;
this.str = new WindowStream(this);
}
clear() { }
put_string(_str, _style) { }
set_css(_name, _val) { }
set_hyperlink(_val) { }
set_style(_style) { }
update() {
if (this.type === 'blank' || this.type === 'pair') {
return {
content: null,
input: null,
size: null,
};
}
const input_update = {
id: this.input.id,
};
copy_prop(this.input, input_update, 'hyperlink');
copy_prop(this.input, input_update, 'mouse');
const box = this.box;
const height = box.bottom - box.top;
const width = box.right - box.left;
return {
content: null,
input: input_update,
size: {
height,
hidden: height === 0 || width === 0,
id: this.disprock,
left: box.left,
rock: this.rock,
top: box.top,
type: this.type,
width,
},
};
}
}
export class BlankWindow extends WindowBase {
type = 'blank';
typenum = wintype_Blank;
}
export class TextWindow extends WindowBase {
cleared = true;
line_input_buf;
request_echo_line_input = true;
stylehints;
uni_input;
constructor(rock, stylehints) {
super(rock);
this.stylehints = cloneDeep(stylehints);
}
update() {
const update = super.update();
// TODO: don't resend stylehints when only metrics have changed?
if (Object.keys(this.stylehints).length) {
update.size.styles = this.stylehints;
}
const input = this.input;
const input_update = update.input;
if (input.type) {
copy_prop(input, input_update, 'gen');
copy_prop(input, input_update, 'type');
if (input.type === 'line') {
input_update.maxlen = this.line_input_buf.length;
copy_prop(input, input_update, 'initial');
copy_prop(input, input_update, 'terminators');
}
}
// Clean up the partial output
delete input.initial;
return update;
}
}
export class BufferWindow extends TextWindow {
content;
echo_line_input = true;
last_par;
last_textrun = clone_textrun(BASE_TEXTRUN, true);
type = 'buffer';
typenum = wintype_TextBuffer;
constructor(rock, stylehints) {
super(rock, stylehints);
this.last_textrun = clone_textrun(BASE_TEXTRUN, true);
this.content = [{
content: [this.last_textrun],
}];
this.last_par = this.content[0];
}
clear() {
this.cleared = true;
this.clear_content();
}
clear_content() {
this.last_textrun = clone_textrun(this.last_textrun, false);
this.content = [{
append: true,
content: [this.last_textrun],
}];
this.last_par = this.content[0];
}
clone_textrun(clone_styles, force) {
if (force || this.last_textrun.text !== '') {
this.last_textrun = clone_textrun(this.last_textrun, clone_styles);
this.last_par.content.push(this.last_textrun);
}
}
put_image(img) {
img.hyperlink = this.last_textrun.hyperlink;
this.last_par.content.push(img);
this.clone_textrun(false, true);
}
put_string(str, style) {
const old_style = this.last_textrun.style;
if (style) {
this.set_style(style);
}
for (const [i, line] of str.split('\n').entries()) {
if (i !== 0) {
this.last_textrun = clone_textrun(this.last_textrun, false);
this.last_par = { content: [this.last_textrun] };
this.content.push(this.last_par);
}
this.last_textrun.text += line;
}
if (style) {
this.set_style(old_style);
}
}
set_css(name, val) {
if (this.last_textrun.css_styles[name] !== val) {
this.clone_textrun(true);
if (val === undefined) {
delete this.last_textrun.css_styles[name];
}
else {
this.last_textrun.css_styles[name] = val;
}
}
}
set_flow_break() {
this.last_par.flowbreak = true;
}
set_hyperlink(val) {
if (val === 0) {
val = undefined;
}
if (val !== this.last_textrun.hyperlink) {
this.clone_textrun(false);
if (val) {
this.last_textrun.hyperlink = val;
}
else {
delete this.last_textrun.hyperlink;
}
}
}
set_style(style) {
if (style !== this.last_textrun.style) {
this.clone_textrun(false);
this.last_textrun.style = style;
}
}
update() {
const update = super.update();
// Clone the textrun now because the css_style could get deleted in cleanup_paragraph_styles
this.last_textrun = clone_textrun(this.last_textrun, false);
// Exclude empty text runs
const paragraphs = this.content;
for (const par of paragraphs) {
par.content = cleanup_paragraph_styles(par.content.filter((text) => 'special' in text || text.text));
}
// Only send an update if there is new content or the window has been cleared
if (this.cleared || paragraphs.length > 1 || paragraphs[0].content.length) {
update.content = {
id: this.disprock,
// Send an empty object for a blank line
text: paragraphs.map(par => par.append || par.flowbreak || par.content.length ? par : {}),
};
if (this.cleared) {
update.content.clear = true;
this.cleared = false;
// TODO: fg bg
}
}
this.clear_content();
return update;
}
}
export class GraphicsWindow extends WindowBase {
draw = [];
height = 0;
type = 'graphics';
typenum = wintype_Graphics;
uni_input;
width = 0;
clear() {
this.draw = this.draw.filter(op => op.special === 'setcolor').slice(-1);
this.draw.push({ special: 'fill' });
}
update() {
const update = super.update();
if (this.draw.length) {
update.content = {
draw: this.draw,
id: this.disprock,
};
this.draw = [];
}
update.size.graphheight = this.height;
update.size.graphwidth = this.width;
return update;
}
}
export class GridWindow extends TextWindow {
current_styles = clone_textrun(BASE_TEXTRUN, true);
height = 0;
lines = [];
type = 'grid';
typenum = wintype_TextGrid;
width = 0;
x = 0;
y = 0;
clear() {
this.cleared = true;
const height = this.height;
this.update_size(0, this.width);
this.update_size(height, this.width);
this.x = this.y = 0;
}
fit_cursor() {
if (this.x >= this.width) {
this.x = 0;
this.y++;
}
if (this.y >= this.height) {
return true;
}
}
put_string(str, style) {
const old_style = this.current_styles.style;
if (style) {
this.set_style(style);
}
for (const ch of str) {
if (this.fit_cursor()) {
break;
}
if (ch === '\n') {
this.x = 0;
this.y++;
continue;
}
const line = this.lines[this.y];
line.changed = true;
line.content[this.x++] = clone_textrun(this.current_styles, false, ch);
}
if (style) {
this.set_style(old_style);
}
}
set_css(name, val) {
if (this.current_styles.css_styles[name] !== val) {
this.current_styles = clone_textrun(this.current_styles, true);
if (val === undefined) {
delete this.current_styles.css_styles[name];
}
else {
this.current_styles.css_styles[name] = val;
}
}
}
set_hyperlink(val) {
if (val) {
this.current_styles.hyperlink = val;
}
else {
delete this.current_styles.hyperlink;
}
}
set_style(style) {
this.current_styles.style = style;
}
update_size(height, width) {
const oldheight = this.height;
const oldwidth = this.width;
this.height = height;
this.width = width;
// Resize the lines array if necessary
const blank_space = clone_textrun(BASE_TEXTRUN, true, ' ');
if (oldheight > height) {
this.lines.length = height;
}
else if (height > oldheight) {
for (let i = oldheight; i < height; i++) {
const new_line = [];
for (let c = 0; c < width; c++) {
new_line.push(clone_textrun(blank_space, false, ' '));
}
this.lines[i] = {
changed: true,
content: new_line,
};
}
}
// Fix the width of old rows
const rows_to_fix = Math.min(height, oldheight);
if (rows_to_fix && width !== oldwidth) {
for (let i = 0; i < rows_to_fix; i++) {
const line = this.lines[i];
if (width < oldwidth) {
line.changed = true;
line.content.length = width;
}
else {
line.changed = true;
for (let c = oldwidth; c < width; c++) {
line.content.push(clone_textrun(blank_space, false, ' '));
}
}
}
}
}
update() {
const update = super.update();
if (this.lines.find(line => line.changed)) {
update.content = {
id: this.disprock,
lines: this.lines.flatMap((line, index) => {
if (!line.changed) {
return [];
}
line.changed = false;
return {
content: cleanup_paragraph_styles(line.content.reduce((acc, cur) => {
if (!acc.length) {
return [clone_textrun(cur, false, cur.text)];
}
// Combine adjacent characters when they have the same textrun properties
const last = acc[acc.length - 1];
if (cur.css_styles === last.css_styles && cur.hyperlink === last.hyperlink && cur.style === last.style) {
last.text += cur.text;
}
else {
acc.push(clone_textrun(cur, false, cur.text));
}
return acc;
}, [])),
line: index,
};
}),
};
if (this.cleared) {
update.content.clear = true;
this.cleared = false;
// TODO: fg bg
}
}
if (this.input.type) {
const input_update = update.input;
if (this.fit_cursor()) {
input_update.xpos = this.width - 1;
input_update.ypos = this.height - 1;
}
else {
input_update.xpos = this.x;
input_update.ypos = this.y;
}
}
update.size.gridheight = this.height;
update.size.gridwidth = this.width;
return update;
}
}
export class PairWindow extends WindowBase {
backward;
child1 = null;
child2 = null;
border;
dir;
fixed;
key;
size;
type = 'pair';
typenum = wintype_Pair;
vertical;
constructor(keywin, method, size) {
super(0);
this.border = (method & winmethod_BorderMask) === winmethod_BorderMask;
this.dir = method & winmethod_DirMask;
this.fixed = (method & winmethod_DivisionMask) === winmethod_Fixed;
this.key = keywin;
this.size = size;
this.backward = this.dir === winmethod_Left || this.dir === winmethod_Above;
this.vertical = this.dir === winmethod_Left || this.dir === winmethod_Right;
}
}
const BASE_TEXTRUN = {
css_styles: {},
style: 'normal',
text: '',
};
/** Remove css_styles from a paragraph when empty */
function cleanup_paragraph_styles(par) {
return par.map(tr => {
if (!('special' in tr)) {
if (!Object.keys(tr.css_styles).length) {
delete tr.css_styles;
}
}
return tr;
});
}
/** Clone a text run */
function clone_textrun(tr, clone_styles, new_text = '') {
return Object.assign({}, tr, {
css_styles: clone_styles ? Object.assign({}, tr.css_styles) : tr.css_styles,
text: new_text,
});
}
/** Copy a property from one InputUpdate to another */
function copy_prop(from, to, prop) {
if (from[prop]) {
to[prop] = from[prop];
}
}