Refactored webext classes to be more SRP

The biggest refactor is separating out the DocumentBuilder from the DOM
Manager.

I also made consistent use of the mixin pattern I'd only half
implemented.
This commit is contained in:
Thomas Buckley-Houston 2018-04-09 18:36:46 +08:00
parent 34497c88aa
commit 5b6cc89770
16 changed files with 339 additions and 334 deletions

View File

@ -1,4 +1,4 @@
import Boot from 'background/boot'
import BackgroundManager from 'background/manager'
new Boot();
new BackgroundManager();

View File

@ -1,4 +1,4 @@
import FrameBuilder from 'dom/frame_builder';
import DOMManager from 'dom/manager';
new FrameBuilder();
new DOMManager();

View File

@ -2,7 +2,7 @@ import stripAnsi from 'strip-ansi';
// Here we keep the public functions used to mediate communications between
// the background process, tabs and the terminal.
export default (Base) => class extends Base {
export default (MixinBase) => class extends MixinBase {
sendToCurrentTab(message) {
this.currentTab().channel.postMessage(message);
}

View File

@ -1,12 +1,12 @@
import mixins from 'mixin_factory';
import HubMixin from 'background/hub_mixin';
import utils from 'utils';
import CommonMixin from 'background/common_mixin';
import TTYCommandsMixin from 'background/tty_commands_mixin';
import TabCommandsMixin from 'background/tab_commands_mixin';
// Boots the background process. Mainly involves connecting to the websocket server
// launched by the Browsh CLI client and setting up listeners for new tabs that
// have our webextension content script inside them.
export default class extends mixins(HubMixin, TTYCommandsMixin, TabCommandsMixin) {
export default class extends utils.mixins(CommonMixin, TTYCommandsMixin, TabCommandsMixin) {
constructor() {
super();
// Keep track of connections to active tabs

View File

@ -1,29 +1,29 @@
export default class BaseBuilder {
_sendMessage(message) {
export default (MixinBase) => class extends MixinBase {
sendMessage(message) {
this.channel.postMessage(message);
}
_snap(number) {
snap(number) {
return parseInt(Math.round(number));
}
_log(...messages) {
this._sendMessage(`/log,${JSON.stringify(messages)}`);
log(...messages) {
this.sendMessage(`/log,${JSON.stringify(messages)}`);
}
_logPerformance(work, reference) {
logPerformance(work, reference) {
let start = performance.now();
work();
let end = performance.now();
this._firstFrameLog(`${reference}: ${end - start}ms`);
this.firstFrameLog(`${reference}: ${end - start}ms`);
}
// If you're logging large objects and using a high-ish FPS (<1000ms) then you might
// crash the browser. So use this function instead.
_firstFrameLog(...logs) {
firstFrameLog(...logs) {
if (this._is_first_frame_finished) return;
if (DEVELOPMENT) {
this._log(logs);
this.log(logs);
}
}
}

View File

@ -0,0 +1,143 @@
import charWidthInTTY from 'string-width';
import utils from 'utils';
import CommonMixin from 'dom/common_mixin';
import GraphicsBuilderMixin from 'dom/graphics_builder_mixin';
import TextBuilderMixin from 'dom/text_builder_mixin';
// Takes the graphics and text from the current DOM, 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 extends utils.mixins(CommonMixin, GraphicsBuilderMixin, TextBuilderMixin) {
constructor(channel) {
super();
this.channel = channel;
this.is_graphics_mode = true;
}
makeFrame() {
this._setupDimensions();
this._compileFrame();
this._buildFrame();
}
_compileFrame() {
this.getScreenshotWithText();
this.getScreenshotWithoutText();
this.getScaledScreenshot();
this.buildFormattedText();
}
_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);
}
}
}
_setupDimensions() {
if (!this.tty_width || !this.tty_height) {
throw new Error("DocumentBuilder doesn't have the TTY dimensions");
}
// A frame is 'taller' than the TTY because of the special UTF8 half-block
this.frame_width = this.tty_width;
// trick. Also we need to reserve 2 lines at the top for the tabs and URL bar.
this.frame_height = (this.tty_height - 2) * 2;
}
// 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.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._buildTtyRow(this._bg_row, this._fg_row, y);
this.frame = this.frame.concat(this._row);
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, padding, char;
this._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)) {
this._addCharacter(tty_index);
char = this.tty_grid[tty_index][0]
padding = this._calculateCharWidthPadding(char);
for (let p = 0; p < padding; p++) {
x++;
this._addCharacter(tty_index, ' ');
}
} else {
this._addGraphicsBlock(x, fg_row, bg_row);
}
}
}
_addCharacter(tty_index, padding = false) {
const cell = this.tty_grid[tty_index];
let char = padding ? padding : cell[0];
const fg = cell[1];
const bg = cell[2];
if (this.is_graphics_mode) {
this._row = this._row.concat(utils.ttyCell(fg, bg, char));
} else {
// TODO: Somehow communicate clickable text
this._row = this._row.concat(utils.ttyPlainCell(char));
}
}
_addGraphicsBlock(x, fg_row, bg_row) {
if (this.is_graphics_mode) {
this._row = this._row.concat(utils.ttyCell(fg_row[x], bg_row[x], '▄'));
} else {
this._row = this._row.concat(utils.ttyPlainCell(' '));
}
}
// Deal with UTF8 characters that take up more than a single cell in the TTY.
// TODO:
// 1. Do all terminals deal with wide characters the same?
// 2. Use CSS or JS so that wide characters actually flow in the DOM as 2
// monospaced characters. This will allow pages of nothing but wide
// characters to properly display.
_calculateCharWidthPadding(char) {
return charWidthInTTY(char) - 1;
}
// We need to know this because we want all empty cells to be 'transparent'
_doesCellHaveACharacter(index) {
if (this.tty_grid[index] === undefined) return false;
const char = this.tty_grid[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;
}
}

View File

@ -1,14 +1,12 @@
import BaseBuilder from 'dom/base_builder';
// Converts an instance of the viewport into a an array of pixel values.
// Note, that it does this both with and without the text visible in order
// Converts an instance of the visible DOM into an array of pixel values.
// Note that it does this both with and without the text visible in order
// to aid in a clean separation of the graphics and text in the final frame
// rendered in the terminal.
export default class GraphicsBuilder extends BaseBuilder {
export default (MixinBase) => class extends MixinBase {
constructor() {
super();
this.off_screen_canvas = document.createElement('canvas');
this.ctx = this.off_screen_canvas.getContext('2d');
this._off_screen_canvas = document.createElement('canvas');
this._ctx = this._off_screen_canvas.getContext('2d');
this._updateCurrentViewportDimensions();
}
@ -34,54 +32,52 @@ export default class GraphicsBuilder extends BaseBuilder {
return [rgb[0], rgb[1], rgb[2]];
}
getSnapshotWithText() {
this._logPerformance(() => {
this._getSnapshotWithText();
}, 'get snapshot with text');
getScreenshotWithText() {
this.logPerformance(() => {
this._getScreenshotWithText();
}, 'get screenshot with text');
}
getSnapshotWithoutText() {
this._logPerformance(() => {
this._getSnapshotWithoutText();
}, 'get snapshot without text');
getScreenshotWithoutText() {
this.logPerformance(() => {
this._getScreenshotWithoutText();
}, 'get screenshot without text');
}
getScaledSnapshot(frame_width, frame_height) {
this._logPerformance(() => {
this._getScaledSnapshot(frame_width, frame_height);
}, 'get scaled snapshot');
getScaledScreenshot() {
this.logPerformance(() => {
this._getScaledScreenshot();
}, 'get scaled screenshot');
}
_getSnapshotWithoutText() {
_getScreenshotWithoutText() {
this._hideText();
this.pixels_without_text = this._getSnapshot();
this.pixels_without_text = this._getScreenshot();
this._showText();
return this.pixels_without_text;
}
_getSnapshotWithText() {
this.pixels_with_text = this._getSnapshot();
_getScreenshotWithText() {
this.pixels_with_text = this._getScreenshot();
return this.pixels_with_text;
}
_getScaledSnapshot(frame_width, frame_height) {
this.frame_width = frame_width;
this.frame_height = frame_height;
_getScaledScreenshot() {
this._scaleCanvas();
this.scaled_pixels = this._getSnapshot();
this.scaled_pixels = this._getScreenshot();
this._unScaleCanvas();
this._is_first_frame_finished = true;
return this.scaled_pixels;
}
_hideText() {
this.styles = document.createElement("style");
document.head.appendChild(this.styles);
this.styles.sheet.insertRule(
this._styles = document.createElement("style");
document.head.appendChild(this._styles);
this._styles.sheet.insertRule(
'html * {' +
' color: transparent !important;' +
// Note the disabling of transition effects here. Some websites have a fancy fade
// animation when changing colours, which we don't have time for in taking a snapshot.
// animation when changing colours, which we don't have time for in taking a screenshot.
// However, a drawback here is that, when we remove this style the transition actually
// kicks in - not that the terminal sees it because, by the nature of this style change
// here, we only ever capture the screen when text is invisible. However, I wonder if
@ -93,13 +89,12 @@ export default class GraphicsBuilder extends BaseBuilder {
}
_showText() {
this.styles.parentNode.removeChild(this.styles);
this._styles.parentNode.removeChild(this._styles);
}
_getSnapshot() {
_getScreenshot() {
this._updateCurrentViewportDimensions()
let pixel_data = this._getPixelData();
return pixel_data;
return this._getPixelData();
}
// Deal with page scrolling and other viewport changes.
@ -112,28 +107,28 @@ export default class GraphicsBuilder extends BaseBuilder {
width: window.innerWidth,
height: window.innerHeight
}
if (!this.is_scaled) {
if (!this._is_scaled) {
// Resize our canvas to match the viewport. I guess this makes for efficient
// use of memory?
this.off_screen_canvas.width = this.viewport.width;
this.off_screen_canvas.height = this.viewport.height;
this._off_screen_canvas.width = this.viewport.width;
this._off_screen_canvas.height = this.viewport.height;
}
}
// Scale the screenshot so that 1 pixel approximates half a TTY cell.
_scaleCanvas() {
this.is_scaled = true;
this._is_scaled = true;
const scale_x = this.frame_width / this.viewport.width;
const scale_y = this.frame_height / this.viewport.height;
this._hideText();
this.ctx.save();
this.ctx.scale(scale_x, scale_y);
this._ctx.save();
this._ctx.scale(scale_x, scale_y);
}
_unScaleCanvas() {
this.ctx.restore();
this._ctx.restore();
this._showText();
this.is_scaled = false;
this._is_scaled = false;
}
// Get an array of RGB values.
@ -141,14 +136,14 @@ export default class GraphicsBuilder extends BaseBuilder {
_getPixelData() {
let width, height;
let background_colour = 'rgb(255,255,255)';
if (this.is_scaled) {
if (this._is_scaled) {
width = this.frame_width;
height = this.frame_height;
} else {
width = this.viewport.width;
height = this.viewport.height;
}
this.ctx.drawWindow(
this._ctx.drawWindow(
window,
this.viewport.x_scroll,
this.viewport.y_scroll,
@ -156,6 +151,6 @@ export default class GraphicsBuilder extends BaseBuilder {
this.viewport.height,
background_colour
);
return this.ctx.getImageData(0, 0, width, height).data;
return this._ctx.getImageData(0, 0, width, height).data;
}
}

View File

@ -1,34 +1,24 @@
import charWidthInTTY from 'string-width';
import utils from 'utils';
import BaseBuilder from 'dom/base_builder';
import GraphicsBuilder from 'dom/graphics_builder';
import TextBuilder from 'dom/text_builder';
import CommonMixin from 'dom/common_mixin';
import DocumentBuilder from 'dom/document_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{
// Entrypoint for managing a single tab
export default class extends utils.mixins(CommonMixin) {
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._is_graphics_mode = true;
this._setupInit();
}
sendFrame() {
this._setupDimensions();
this._compileFrame();
this._buildFrame();
this.document_builder.makeFrame();
this._sendTabInfo();
if (!this._is_first_frame_finished) {
this._sendMessage('/status,parsing_complete');
this.sendMessage('/status,parsing_complete');
}
this._sendMessage(`/frame,${JSON.stringify(this.frame)}`);
this.sendMessage(`/frame,${JSON.stringify(this.document_builder.frame)}`);
this._is_first_frame_finished = true;
}
@ -69,20 +59,23 @@ export default class FrameBuilder extends BaseBuilder{
this._postCommsInit();
}
_registrationError(error) {
this.log(error);
}
_postCommsInit() {
this._log('Webextension postCommsInit()');
this.log('Webextension postCommsInit()');
this.document_builder = new DocumentBuilder(this.channel)
this._sendTabInfo();
this._sendMessage('/status,page_init');
this.sendMessage('/status,page_init');
this._calculateMonospaceDimensions();
this.graphics_builder.channel = this.channel;
this.text_builder.channel = this.channel;
this._requestInitialTTYSize();
this._listenForBackgroundMessages();
window.addEventListener("unload", () => {
this._sendMessage('/status,window_unload')
this.sendMessage('/status,window_unload')
});
window.addEventListener('error', (event) => {
this._log("TAB JS: " + event)
this.log("TAB JS: " + event)
});
}
@ -92,9 +85,9 @@ export default class FrameBuilder extends BaseBuilder{
this._handleBackgroundMessage(message);
}
catch(error) {
this._log(`'${error.name}' ${error.message}`);
this._log(`@${error.fileName}:${error.lineNumber}`);
this._log(error.stack);
this.log(`'${error.name}' ${error.message}`);
this.log(`@${error.fileName}:${error.lineNumber}`);
this.log(error.stack);
}
});
}
@ -108,9 +101,12 @@ export default class FrameBuilder extends BaseBuilder{
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}`);
this.document_builder.tty_width = parseInt(parts[1]);
this.document_builder.tty_height = parseInt(parts[2]);
this.log(
`Tab received TTY size: ` +
`${this.document_builder.tty_width}x${this.document_builder.tty_height}`
);
break;
case '/stdin':
input = JSON.parse(utils.rebuildArgsToSingleArg(parts));
@ -127,7 +123,7 @@ export default class FrameBuilder extends BaseBuilder{
window.stop();
break;
default:
this._log('Unknown command sent to tab', message);
this.log('Unknown command sent to tab', message);
}
}
@ -140,16 +136,16 @@ export default class FrameBuilder extends BaseBuilder{
_handleSpecialKeys(input) {
switch (input.key) {
case 257: // up arow
window.scrollBy(0, -2 * this.char_height);
window.scrollBy(0, -2 * this.document_builder.char_height);
break;
case 258: // down arrow
window.scrollBy(0, 2 * this.char_height);
window.scrollBy(0, 2 * this.document_builder.char_height);
break;
case 266: // page up
window.scrollBy(0, -this.tty_height * this.char_height);
window.scrollBy(0, -this.document_builder.tty_height * this.document_builder.char_height);
break;
case 267: // page down
window.scrollBy(0, this.tty_height * this.char_height);
window.scrollBy(0, this.document_builder.tty_height * this.document_builder.char_height);
break;
case 18: // CTRL+r
window.location.reload();
@ -161,7 +157,7 @@ export default class FrameBuilder extends BaseBuilder{
switch (input.char) {
case 'M':
if (input.mod === 4) {
this._is_graphics_mode = !this._is_graphics_mode;
this.document_builder.is_graphics_mode = !this.document_builder.is_graphics_mode;
}
break;
}
@ -197,38 +193,39 @@ export default class FrameBuilder extends BaseBuilder{
element.dispatchEvent(event);
}
// The user clicks on a TTY grid which has a significantly lower resolution than the
// actual browser window. So we scale the coordinates up as if the user clicked on the
// the central "pixel" of a TTY cell.
_getDOMCoordsFromMouseCoords(x, y) {
let dom_x, dom_y, char, original_position;
y = y - 2; // Because of the UI header bar
const index = (y * this.tty_width) + x;
if (this.text_builder.tty_grid[index] !== undefined) {
char = this.text_builder.tty_grid[index][0];
if (this.document_builder.tty_grid[index] !== undefined) {
char = this.document_builder.tty_grid[index][0];
} else {
char = false;
}
if (!char || char === '▄') {
dom_x = (x * this.char_width);
dom_y = (y * this.char_height);
dom_x = (x * this.document_builder.char_width);
dom_y = (y * this.document_builder.char_height);
} else {
original_position = this.text_builder.tty_grid[index][4];
// Recall that text can be shifted from its original position in the browser in order
// to snap it consistently to the TTY grid.
original_position = this.document_builder.tty_grid[index][4];
dom_x = original_position.x;
dom_y = original_position.y;
}
return [
dom_x + (this.char_width / 2),
dom_y + (this.char_height / 2)
dom_x + (this.document_builder.char_width / 2),
dom_y + (this.document_builder.char_height / 2)
];
}
_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.sendMessage('/request_tty_size');
}
// This is critical in order for the terminal to match the browser as closely as possible.
@ -247,12 +244,17 @@ export default class FrameBuilder extends BaseBuilder{
_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}`);
this.document_builder.char_width = dom_rect.width;
this.document_builder.char_height = dom_rect.height + 2; // TODO: WTF is this magic number?
this.sendMessage(
`/char_size,` +
`${this.document_builder.char_width},` +
`${this.document_builder.char_height}`
);
this.log(
`Tab char dimensions: ` +
`${this.document_builder.char_width}x${this.document_builder.char_height}`
);
}
// Back when printing was done by physical stamps, it was convention to measure the
@ -274,135 +276,12 @@ export default class FrameBuilder extends BaseBuilder{
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. Also we need to reserve 2 lines at the top for the tabs and URL bar.
this.frame_height = (this.tty_height - 2) * 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);
}
}
}
_sendTabInfo() {
const title_object = document.getElementsByTagName("title");
let info = {
url: document.location.href,
title: title_object.length ? title_object[0].innerHTML : ""
}
this._sendMessage(`/tab_info,${JSON.stringify(info)}`);
}
// 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._buildTtyRow(this._bg_row, this._fg_row, y);
this.frame = this.frame.concat(this._row);
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, padding, char;
this._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)) {
this._addCharacter(tty_index);
char = this.formatted_text[tty_index][0]
padding = this._calculateCharWidthPadding(char);
for (let p = 0; p < padding; p++) {
x++;
this._addCharacter(tty_index, ' ');
}
} else {
this._addGraphicsBlock(x, fg_row, bg_row);
}
}
}
_addCharacter(tty_index, padding = false) {
const cell = this.formatted_text[tty_index];
let char = padding ? padding : cell[0];
const fg = cell[1];
const bg = cell[2];
if (this._is_graphics_mode) {
this._row = this._row.concat(utils.ttyCell(fg, bg, char));
} else {
// TODO: Somehow communicate clickable text
this._row = this._row.concat(utils.ttyPlainCell(char));
}
}
_addGraphicsBlock(x, fg_row, bg_row) {
if (this._is_graphics_mode) {
this._row = this._row.concat(utils.ttyCell(fg_row[x], bg_row[x], '▄'));
} else {
this._row = this._row.concat(utils.ttyPlainCell(' '));
}
}
// Deal with UTF8 characters that take up more than a single cell in the TTY.
// TODO:
// 1. Do all terminals deal with wide characters the same?
// 2. Use CSS or JS so that wide characters actually flow in the DOM as 2
// monospaced characters. This will allow pages of nothing but wide
// characters to properly display.
_calculateCharWidthPadding(char) {
return charWidthInTTY(char) - 1;
}
// 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;
this.sendMessage(`/tab_info,${JSON.stringify(info)}`);
}
}

View File

@ -1,61 +1,52 @@
import _ from 'lodash';
import BaseBuilder from 'dom/base_builder';
// Convert the text on the page into a snapped 2-dimensional grid to be displayed directly
// in the terminal.
export default class TextBuillder extends BaseBuilder {
constructor(frame_builder) {
export default (MixinBase) => class extends MixinBase {
constructor() {
super();
this.graphics_builder = frame_builder.graphics_builder;
this.frame_builder = frame_builder;
this._parse_started_elements = [];
}
getFormattedText() {
buildFormattedText() {
this._updateState();
this._getTextNodes();
this._positionTextNodes();
this._is_first_frame_finished = true;
return this.tty_grid;
}
_updateState() {
this.tty_grid = [];
this.tty_dom_width = this.frame_builder.tty_width;
this.tty_dom_width = this.tty_width;
// For Tabs and URL bar.
this.tty_dom_height = this.frame_builder.tty_height - 2;
this.char_width = this.frame_builder.char_width;
this.char_height = this.frame_builder.char_height;
this.pixels_with_text = this.graphics_builder.pixels_with_text;
this.pixels_without_text = this.graphics_builder.pixels_without_text;
this.tty_dom_height = this.tty_height - 2;
this._parse_started_elements = [];
}
// This is relatively cheap: around 50ms for a 13,000 word Wikipedia page
_getTextNodes() {
this._logPerformance(() => {
this.logPerformance(() => {
this.__getTextNodes();
}, 'tree walker');
}
// This should be around 125ms for a largish Wikipedia page of 13,000 words
_positionTextNodes() {
this._logPerformance(() => {
this.logPerformance(() => {
this.__positionTextNodes();
}, 'position text nodes');
}
// Search through every node in the DOM looking for displayable text.
__getTextNodes() {
this.text_nodes = [];
this._text_nodes = [];
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{ acceptNode: this._isRelevantTextNode },
false
);
while(walker.nextNode()) this.text_nodes.push(walker.currentNode);
while(walker.nextNode()) this._text_nodes.push(walker.currentNode);
}
// Does the node contain text that we want to display?
@ -77,7 +68,7 @@ export default class TextBuillder extends BaseBuilder {
__positionTextNodes() {
let range = document.createRange();
let bounding_box;
for (const node of this.text_nodes) {
for (const node of this._text_nodes) {
range.selectNode(node);
bounding_box = range.getBoundingClientRect();
if (this._isBoxOutsideViewport(bounding_box)) continue;
@ -94,16 +85,16 @@ export default class TextBuillder extends BaseBuilder {
_isBoxOutsideViewport(bounding_box) {
const is_top_in =
bounding_box.top >= 0 &&
bounding_box.top < this.graphics_builder.viewport.height;
bounding_box.top < this.viewport.height;
const is_bottom_in =
bounding_box.bottom >= 0 &&
bounding_box.bottom < this.graphics_builder.viewport.height;
bounding_box.bottom < this.viewport.height;
const is_left_in =
bounding_box.left >= 0 &&
bounding_box.left < this.graphics_builder.viewport.width;
bounding_box.left < this.viewport.width;
const is_right_in =
bounding_box.right >= 0 &&
bounding_box.right < this.graphics_builder.viewport.width;
bounding_box.right < this.viewport.width;
return !((is_top_in || is_bottom_in) && (is_left_in || is_right_in));
}
@ -207,9 +198,9 @@ export default class TextBuillder extends BaseBuilder {
// Round and snap a DOM rectangle as if it were placed in the terminal
_convertBoxToTTYUnits(viewport_dom_rect) {
return {
col_start: this._snap(viewport_dom_rect.left / this.char_width),
row: this._snap(viewport_dom_rect.top / this.char_height),
width: this._snap(viewport_dom_rect.width / this.char_width),
col_start: this.snap(viewport_dom_rect.left / this.char_width),
row: this.snap(viewport_dom_rect.top / this.char_height),
width: this.snap(viewport_dom_rect.width / this.char_width),
}
}
@ -270,10 +261,10 @@ export default class TextBuillder extends BaseBuilder {
// arrays during testing - rounding to the top-left saves having to write and extra
// column and row.
const half = 0.449;
const offset_x = this._snap(original_position.x + (this.char_width * half));
const offset_y = this._snap(original_position.y + (this.char_height * half));
const offset_x = this.snap(original_position.x + (this.char_width * half));
const offset_y = this.snap(original_position.y + (this.char_height * half));
if (this._isCharCentreOutsideViewport(offset_x, offset_y)) return false;
return this.graphics_builder.getPixelsAt(offset_x, offset_y);
return this.getPixelsAt(offset_x, offset_y);
}
// Check if the char is in the viewport again because of x increments, y potentially
@ -281,9 +272,9 @@ export default class TextBuillder extends BaseBuilder {
// unicode block.
_isCharCentreOutsideViewport(x, y) {
if (
x >= this.graphics_builder.viewport.width ||
x >= this.viewport.width ||
x < 0 ||
y >= this.graphics_builder.viewport.height ||
y >= this.viewport.height ||
y < 0
) return false;
}

View File

@ -1,5 +0,0 @@
export default function (...mixins) {
return mixins.reduce((base, mixin) => {
return mixin(base);
}, class {});
}

View File

@ -1,4 +1,10 @@
export default {
mixins: function (...mixins) {
return mixins.reduce((base, mixin) => {
return mixin(base);
}, class {});
},
ttyCell: function (fg_colour, bg_colour, character) {
fg_colour = fg_colour || [255, 255, 255];
bg_colour = bg_colour || [0, 0, 0];

View File

@ -0,0 +1,32 @@
import sandbox from 'helper';
import {expect} from 'chai';
import DocumentBuilder from 'dom/document_builder';
import canvas_pixels from 'fixtures/canvas_pixels';
import tty_grid from 'fixtures/tty_grid';
describe('Document Builder', ()=> {
let document_builder;
beforeEach(()=> {
document_builder = new DocumentBuilder();
document_builder.tty_grid = tty_grid;
sandbox.stub(DocumentBuilder.prototype, '_getPixelData').returns(canvas_pixels);
sandbox.stub(DocumentBuilder.prototype, 'buildFormattedText').returns();
});
it('should merge pixels and text into a 1D array', ()=> {
document_builder.tty_width = 3;
document_builder.tty_height = 2 + 2;
document_builder.makeFrame();
const frame = document_builder.frame.join(',');
expect(frame).to.eq(
'0,0,0,111,111,111,▄,' +
'111,111,111,222,222,222,😐,' +
'0,0,0,111,111,111,▄,' +
'111,111,111,222,222,222,😄,' +
'111,111,111,0,0,0,▄,' +
'111,111,111,222,222,222,😂'
);
});
});

View File

@ -1,33 +0,0 @@
import sandbox from 'helper';
import {expect} from 'chai';
import FrameBuilder from 'dom/frame_builder';
import GraphicsBuilder from 'dom/graphics_builder';
import TextBuilder from 'dom/text_builder';
import canvas_pixels from 'fixtures/canvas_pixels';
import text_grid from 'fixtures/text_grid';
describe('Frame Builder', ()=> {
let frame_builder;
beforeEach(()=> {
sandbox.stub(GraphicsBuilder.prototype, '_getPixelData').returns(canvas_pixels);
sandbox.stub(TextBuilder.prototype, 'getFormattedText').returns(text_grid);
frame_builder = new FrameBuilder();
});
it('should merge pixels and text into a 1D array', ()=> {
frame_builder.tty_width = 3;
frame_builder.tty_height = 2 + 2;
frame_builder.sendFrame();
const frame = frame_builder.frame.join(',');
expect(frame).to.eq(
'0,0,0,111,111,111,▄,' +
'111,111,111,222,222,222,😐,' +
'0,0,0,111,111,111,▄,' +
'111,111,111,222,222,222,😄,' +
'111,111,111,0,0,0,▄,' +
'111,111,111,222,222,222,😂'
);
});
});

View File

@ -1,17 +1,16 @@
import sinon from 'sinon';
import GraphicsBuilder from 'dom/graphics_builder';
import FrameBuilder from 'dom/frame_builder';
import DocumentBuilder from 'dom/document_builder';
import MockRange from 'mocks/range'
var sandbox = sinon.sandbox.create();
beforeEach(() => {
sandbox.stub(GraphicsBuilder.prototype, '_hideText').returns(true);
sandbox.stub(GraphicsBuilder.prototype, '_showText').returns(true);
sandbox.stub(GraphicsBuilder.prototype, '_scaleCanvas').returns(true);
sandbox.stub(GraphicsBuilder.prototype, '_unScaleCanvas').returns(true);
sandbox.stub(FrameBuilder.prototype, '_sendMessage').returns(true);
sandbox.stub(DocumentBuilder.prototype, '_hideText').returns(true);
sandbox.stub(DocumentBuilder.prototype, '_showText').returns(true);
sandbox.stub(DocumentBuilder.prototype, '_scaleCanvas').returns(true);
sandbox.stub(DocumentBuilder.prototype, '_unScaleCanvas').returns(true);
sandbox.stub(DocumentBuilder.prototype, 'sendMessage').returns(true);
});
afterEach(() => {

View File

@ -1,9 +1,7 @@
import sandbox from 'helper';
import { expect } from 'chai';
import FrameBuilder from 'dom/frame_builder';
import TextBuilder from 'dom/text_builder';
import GraphicsBuilder from 'dom/graphics_builder';
import DocumentBuilder from 'dom/document_builder';
import text_nodes from 'fixtures/text_nodes';
import {
with_text,
@ -11,7 +9,6 @@ import {
scaled
} from 'fixtures/canvas_pixels';
let text_builder;
// To save us hand-writing large pixel arrays, let's just have an unrealistically
// small window, it's not a problem, because we'll never actually have to view real
@ -19,32 +16,33 @@ let text_builder;
window.innerWidth = 3;
window.innerHeight = 4;
let document_builder;
function setup() {
let frame_builder = new FrameBuilder();
frame_builder.tty_width = 3
frame_builder.tty_height = 2 + 2
frame_builder.char_width = 1
frame_builder.char_height = 2
frame_builder.graphics_builder.getSnapshotWithText();
frame_builder.graphics_builder.getSnapshotWithoutText();
frame_builder.graphics_builder.getScaledSnapshot();
text_builder = new TextBuilder(frame_builder);
document_builder = new DocumentBuilder();
document_builder.tty_width = 3
document_builder.tty_height = 2 + 2
document_builder.char_width = 1
document_builder.char_height = 2
document_builder.getScreenshotWithText();
document_builder.getScreenshotWithoutText();
document_builder.getScaledScreenshot();
}
describe('Text Builder', () => {
beforeEach(() => {
let getPixelsStub = sandbox.stub(GraphicsBuilder.prototype, '_getPixelData');
let getPixelsStub = sandbox.stub(DocumentBuilder.prototype, '_getPixelData');
getPixelsStub.onCall(0).returns(with_text);
getPixelsStub.onCall(1).returns(without_text);
getPixelsStub.onCall(2).returns(scaled);
setup();
text_builder.text_nodes = text_nodes;
document_builder._text_nodes = text_nodes;
});
it('should convert text nodes to a grid', () => {
text_builder._updateState();
text_builder._positionTextNodes();
const grid = text_builder.tty_grid;
document_builder._updateState();
document_builder._positionTextNodes();
const grid = document_builder.tty_grid;
expect(grid[0]).to.deep.equal([
't', [255, 255, 255],
[0, 0, 0],