browsh/webext/src/dom/frame_builder.js

242 lines
8.6 KiB
JavaScript

import BaseBuilder from 'dom/base_builder';
import GraphicsBuilder from 'dom/graphics_builder';
import TextBuilder from 'dom/text_builder';
// Takes the graphics and text from the current viewport, combines them, then
// sends it to the background process where the rest of the UI, like tabs,
// address bar, etc will be added.
export default class FrameBuilder extends BaseBuilder{
constructor() {
super();
this.graphics_builder = new GraphicsBuilder();
this.text_builder = new TextBuilder(this);
// ID for element we place in the DOM to measure the size of a single monospace
// character.
this._measuring_box_id = 'browsh_em_measuring_box';
this._setupInit();
}
sendFrame() {
this._setupDimensions();
this._compileFrame();
this._buildFrame();
this._sendMessage(`/frame,${this.frame}`);
this._is_first_frame_finished = true;
}
_setupInit() {
document.addEventListener("DOMContentLoaded", () => {
this._init();
}, false);
// Whilst developing this webextension the auto reload only reloads this code,
// not the page, so we don't get the `DOMContentLoaded` event to kick everything off.
if (this._isWindowAlreadyLoaded()) this._init(100);
}
_isWindowAlreadyLoaded() {
return !!this._findMeasuringBox();
}
_init(delay = 0) {
// When the webext devtools auto reloads this code, the background process
// can sometimes still be loading, in which case we need to wait.
setTimeout(() => this._registerWithBackground(), delay);
}
_registerWithBackground() {
let sending = browser.runtime.sendMessage('/register');
sending.then(
(r) => this._registrationSuccess(r),
(e) => this._registrationError(e)
);
}
_registrationSuccess(registered) {
this.channel = browser.runtime.connect({
// We need to give ourselves a unique channel name, so the background
// process can identify us amongst other tabs.
name: registered.id.toString()
});
this._postCommsInit();
}
_postCommsInit() {
this._log('Webextension postCommsInit()');
this._calculateMonospaceDimensions();
this.graphics_builder.channel = this.channel;
this.text_builder.channel = this.channel;
this._requestInitialTTYSize();
this._listenForBackgroundMessages();
}
_listenForBackgroundMessages() {
this.channel.onMessage.addListener((message) => {
const parts = message.split(',');
const command = parts[0];
switch (command) {
case '/request_frame':
this.sendFrame();
break;
case '/tty_size':
this.tty_width = parseInt(parts[1]);
this.tty_height = parseInt(parts[2]);
this._log(`Tab received TTY size: ${this.tty_width}x${this.tty_height}`);
break;
default:
this._log('Unknown command sent to tab', message);
}
});
}
_registrationError(error) {
this._log(error);
}
// The background process can't send the TTY size as soon as it gets it because maybe
// the a tab doesn't exist yet. So we request it ourselves - because we'd have to be
// ready in order to request.
_requestInitialTTYSize() {
this._sendMessage('/request_tty_size');
}
// This is critical in order for the terminal to match the browser as closely as possible.
// Ideally we want the browser's window size to be exactly multiples of the terminal's
// dimensions. So if the terminal is 80x40 and the font-size is 12px (12x6 pixels), then
// the window should be 480x480. Also knowing the precise font-size helps the text builder
// map un-snapped text to the best grid cells - grid cells that represent the terminal's
// character positions.
// The reason that we can't just do some basic maths on the CSS `font-size` value we enforce
// is that there are various factors that can skew the actual font dimensions on the page.
// For instance, you can't guarantee that a browser is using exactly the same version of
// a named monospace font. Also different browser families and even different versions of
// the same browser may have subtle differences in how they render text. Furthermore we can
// actually get floating point accuracy if we use `Element.getBoundingClientRect()` which
// further helps as calculations are compounded during our rendering processes.
_calculateMonospaceDimensions() {
const element = this._getOrCreateMeasuringBox();
const dom_rect = element.getBoundingClientRect();
this.char_width = dom_rect.width;
this.char_height = dom_rect.height + 2;
this.text_builder.char_width = this.char_width;
this.text_builder.char_height = this.char_height;
this._sendMessage(`/char_size,${this.char_width},${this.char_height}`);
this._log(`Tab char dimensions: ${this.char_width}x${this.char_height}`);
}
// Back when printing was done by physical stamps, it was convention to measure the
// font-size using the letter 'M', thus where we get the unit 'em' from. Not that it
// should make any difference to us, but it's nice to keep a tradition.
_getOrCreateMeasuringBox() {
let measuring_box = this._findMeasuringBox();
if (measuring_box) return measuring_box;
measuring_box = document.createElement('span');
measuring_box.id = this._measuring_box_id;
measuring_box.style.visibility = 'hidden';
var M = document.createTextNode('M');
measuring_box.appendChild(M);
document.body.appendChild(measuring_box);
return measuring_box;
}
_findMeasuringBox() {
return document.getElementById(this._measuring_box_id);
}
_setupDimensions() {
if (!this.tty_width || !this.tty_height) {
throw new Error("Frame Builder doesn't have the TTY dimensions");
}
this.frame_width = this.tty_width;
// A frame is 'taller' than the TTY because of the special UTF8 half-block
// trick.
this.frame_height = this.tty_height * 2;
}
_compileFrame() {
this.graphics_builder.getSnapshotWithText();
this.graphics_builder.getSnapshotWithoutText();
this.graphics_builder.getScaledSnapshot(
this.frame_width,
this.frame_height
);
this.formatted_text = this.text_builder.getFormattedText();
}
_buildFrame() {
this._logPerformance(() => {
this.__buildFrame();
}, 'build frame');
}
__buildFrame() {
this.frame = "";
this._bg_row = [];
this._fg_row = [];
for (let y = 0; y < this.frame_height; y++) {
for (let x = 0; x < this.frame_width; x++) {
this._buildPixel(x, y);
}
}
}
// Note how we have to keep track of 2 rows of pixels in order to create 1 row of
// the terminal.
_buildPixel(x, y) {
const colour = this.graphics_builder.getScaledPixelAt(x, y);
if (this._bg_row.length < this.frame_width) {
this._bg_row.push(colour);
} else {
this._fg_row.push(colour);
}
if (this._fg_row.length === this.frame_width) {
this.frame += this._buildTtyRow(this._bg_row, this._fg_row, y);
this._bg_row = [];
this._fg_row = [];
}
}
// This is where we implement the UTF8 half-block trick.
// This is a half-black: ▄ Notice how it takes up precisely half a text cell. This
// means that we can get 2 pixel colours from it, the top pixel comes from setting
// the background colour and the bottom pixel comes from setting the foreground
// colour, namely the colour of the text.
// However we can't just write random pixels to a TTY screen, we must collate 2 rows
// of native pixels for every row of the terminal.
_buildTtyRow(bg_row, fg_row, y) {
let tty_index, char;
let row = "";
const tty_row = parseInt(y / 2);
for (let x = 0; x < this.frame_width; x++) {
tty_index = (tty_row * this.frame_width) + x;
if (this._doesCellHaveACharacter(tty_index)) {
char = this.formatted_text[tty_index];
row += this._ttyPixel(char[1], char[2], char[0]);
} else {
row += this._ttyPixel(fg_row[x], bg_row[x], '▄');
}
}
if (tty_row + 1 < this.tty_height) {
row += "\n";
}
return row;
}
// We need to know this because we want all empty cells to be 'transparent'
_doesCellHaveACharacter(index) {
if (this.formatted_text[index] === undefined) return false;
const char = this.formatted_text[index][0];
const is_undefined = char === undefined;
const is_empty = char === '';
const is_space = /^\s+$/.test(char);
const is_not_worth_printing = is_empty || is_space || is_undefined;
return !is_not_worth_printing;
}
// Display a single character in true colour
_ttyPixel(fg, bg, character) {
let fg_code = `\x1b[38;2;${fg[0]};${fg[1]};${fg[2]}m`;
let bg_code = `\x1b[48;2;${bg[0]};${bg[1]};${bg[2]}m`;
return `${fg_code}${bg_code}${character}`;
}
}