Skip to content

Instantly share code, notes, and snippets.

@romgrk
Created October 16, 2016 01:47
Show Gist options
  • Save romgrk/01a74a06eb6f6ee26baf97b8c07c47bf to your computer and use it in GitHub Desktop.
Save romgrk/01a74a06eb6f6ee26baf97b8c07c47bf to your computer and use it in GitHub Desktop.
import _ from 'lodash';
import {
Position, Location, Size,
Token, createToken, attributesFromStore,
minMax, inspect, getFontSize, } from '../common';
import {
create, addClass, removeClass, toggleClass, clearNode,
} from '../dom';
import TextRegion from '../dom/region';
import * as A from './actions';
import NeovimStore from './store';
import {FontAttributes} from './store';
import Input from '../neovim/input';
import DisplayCursor from './display-cursor';
import LineModel from './lineModel';
import Log from '../log';
const log = Log.getLogger('display');
export class TextRange {
startNode: Node;
startOffset: number;
endNode: Node;
endOffset: number;
content: string;
private _prev: Node;
private _next: Node;
constructor(public container: Node,
col: number,
endCol?: number) {
this.startNode = null;
this.endNode = null;
let txt = container.textContent;
endCol = endCol || txt.length;
this.content = txt.substring(col, endCol);
let node = container.firstChild;
let charIndex = 0;
while (node != null) {
let len = node.textContent.length;
if ((this.startNode == null) &&
(charIndex + len) >= col) {
this.startNode = node;
this.startOffset = col - charIndex;
}
if (charIndex + len >= endCol) {
this.endNode = node;
this.endOffset = endCol - charIndex;
break;
}
charIndex += len;
node = node.nextSibling;
}
this._next = this.startNode;
}
insertBefore(node: DocumentFragment) {
this.container.insertBefore(node, this._next);
}
extract(): DocumentFragment {
const r = document.createDocumentFragment();
let aNode = this.startNode;
let bNode = this.endNode;
let oa = this.startOffset;
let ob = this.endOffset;
if (oa != 0) {
aNode = aNode.cloneNode(true);
aNode.textContent = aNode.textContent.slice(0, oa);
this.container.insertBefore(aNode, this.startNode);
}
if (bNode && ob != bNode.textContent.length) {
bNode = bNode.cloneNode(true);
bNode.textContent = bNode.textContent.slice(ob);
this.container.insertBefore(bNode, this.endNode.nextSibling);
this.endNode.textContent =
this.endNode.textContent.slice(0, ob);
}
this.startNode.textContent =
this.startNode.textContent.slice(oa);
const nodes: Node[] = [];
let node = this.startNode;
while (node) {
nodes.push(node);
if (node === this.endNode) break;
node = node.nextSibling;
}
if (this.endNode)
this._next = this.endNode.nextSibling;
else
this._next = null;
nodes.forEach(n => {
this.container.removeChild(n);
r.appendChild(n);
});
return r;
}
}
export default class TextDisplay {
container: HTMLElement;
pointer: HTMLElement;
cursor: DisplayCursor;
input: Input;
private _hl: FontAttributes;
private _attr: string;
private _lines: LineModel[];
private _lastRegion: TextRegion;
private _lastPos: Position;
private _lastNode: Node;
private _lineTimer: NodeJS.Timer;
private _linesToCheck: number[];
private _redrawTimer: NodeJS.Timer;
private _isDragging: boolean;
private _highlightChanged: boolean;
constructor(private store: NeovimStore,
public element: HTMLElement) {
this.container = create('div.container');
element.appendChild(this.container);
this.cursor = new DisplayCursor(store, this);
element.appendChild(this.cursor.element);
this.input = new Input(this.store);
element.appendChild(this.input.element);
this._lines = [];
this._lastRegion = new TextRegion(this, {line: 0, col: 0}, store.size);
this._lastPos = {line: -1, col: -1};
this._isDragging = false;
this._highlightChanged = true;
store.on('update-fg', () => this.onDidUpdateStyle() );
store.on('update-sp', () => this.onDidUpdateStyle() );
store.on('update-bg', () => this.onDidUpdateStyle() );
store.on('line-height-changed', () => this.changeFontSize(store.font_attr.specified_px));
store.on('busy', () => toggleClass(element, 'busy', store.busy) );
// Note: 'update-bg' clears all texts in screen.
// this.clearDisplay(); });
store.on('put', this.put.bind(this) );
store.on('screen-scrolled', this.scroll.bind(this) );
store.on('put', this.onDidInsertText.bind(this) );
store.on('screen-scrolled', this.onDidScroll.bind(this) );
store.on('highlight-changed', this.onDidUpdateHighlight.bind(this) );
store.on('clear-eol', this.clearTilEndOfLine.bind(this) );
store.on('clear-all', this.clearDisplay.bind(this) );
// store.on('scroll-region-updated', this.updateScrollRegion.bind(this));
element.addEventListener('click', this.onClick.bind(this));
element.addEventListener('dblclick', this.onDblClick.bind(this));
element.addEventListener('wheel', this.onWheel.bind(this));
element.addEventListener('mousedown', this.onMouseDown.bind(this));
element.addEventListener('mouseup', this.onMouseUp.bind(this));
element.addEventListener('mousemove', this.onMouseMove.bind(this));
this.input.element.addEventListener(
'keydown', this.onKeydown.bind(this));
this.onDidUpdateStyle();
this.onDidUpdateFont();
this.changeFontSize(store.font_attr.specified_px);
this.checkShouldResize();
}
focus() {
this.input.focus();
}
/*
* Section: Event handlers
*/
onClick(e: MouseEvent) {
log.debug('Event: click', e);
this.focus();
this.element.classList.remove('no-mouse');
const pos = this.getPositionFrom(e);
this.store.dispatcher.dispatch(A.dragStart(e));
this.store.dispatcher.dispatch(A.dragEnd(e));
}
onDblClick(e: MouseEvent) {
log.debug('Event: double-click', e);
const pos = this.getPositionFrom(e);
this.store.dispatcher.dispatch(A.click(e));
}
onMouseDown(e: MouseEvent) {
this.focus();
this.element.classList.remove('no-mouse');
e.preventDefault();
const pos = this.getPositionFrom(e);
this.store.dispatcher.dispatch(A.dragStart(e));
this._isDragging = true;
}
onMouseUp(e: MouseEvent) {
this.element.classList.remove('no-mouse');
e.preventDefault();
const pos = this.getPositionFrom(e);
if (this._isDragging === false)
this.store.dispatcher.dispatch(A.dragStart(e));
this.store.dispatcher.dispatch(A.dragEnd(e));
this._isDragging = false;
}
onMouseMove(e: MouseEvent) {
this.element.classList.remove('no-mouse');
if (e.buttons !== 0) {
e.preventDefault();
const pos = this.getPositionFrom(e);
if (this._isDragging === true)
this.store.dispatcher.dispatch(A.dragUpdate(e));
}
}
onWheel(e: WheelEvent) {
this.element.classList.remove('no-mouse');
const pos = this.getPositionFrom(e);
this.store.dispatcher.dispatch(A.wheelScroll(e));
}
onKeydown (event: Event) {
this.element.classList.add('no-mouse');
}
private onDidUpdateHighlight(): void {
this._highlightChanged = true;
this._attr = attributesFromStore(this.store);
}
private onDidInsertText(chars: string[][]): void {
const {line, col} = this.store.cursor;
const text = chars.map(c => c[0]).join("");
this._lines[line].insert(col, {text, attributes: this._attr});
if (text.length != chars.length) // FIXME
log.warn('text.length != chars.length', text.length, chars.length);
this.redrawLater();
}
private onDidScroll(delta: number): void {
const {top, bottom, left, right} = this.store.scroll_region;
const chWidth = (right + 1) - left;
const chHeight = (bottom + 1) - top;
/* delta > 0 => screen goes up
* delta < 0 => screen goes down */
const absDelta = Math.abs(delta);
const step = (delta > 0) ? -1 : 1;
const start = (delta > 0) ? bottom : top;
const end = (delta > 0) ? top - 1 : bottom + 1;
const fifo = Array(absDelta);
fifo.push = fifo.unshift;
for (let n = start; n != end; n += step) {
let line = this._lines[n];
let items = fifo.pop() as Token[];
let removedItems = line.extract(left, chWidth, items);
fifo.push(removedItems);
}
log.info('Scroll iteration', {start, end, step, delta})
log.info('Scroll region', {top, bottom, chWidth, chHeight})
this.redrawLater();
}
private onDidUpdateStyle(): void {
const {
fg_color,
bg_color,
font_family,
font_attr,
} = this.store;
this.element.style.fontFamily = font_family;
this.element.style.color = fg_color;
this.element.style.backgroundColor = bg_color;
}
private onDidUpdateFont(): void {
const {
line_height,
font_attr} = this.store;
const font_px = font_attr.specified_px;
this.element.style.fontSize = font_px + 'px';
this.element.style.lineHeight = (line_height * font_px ) + 'px';
//this.container.style.minHeight = (line_height * font_px ) + 'px';
}
/*
* Section: Public API
*/
public convertPositionToLocation (line: number, col: number) {
const {width, height} = this.store.font_attr;
const bounds = this.container.getBoundingClientRect();
const x = col * width;
const y = line * height;
const clientX = x + bounds.left;
const clientY = y + bounds.top;
return {
x, y,
clientX, clientY
};
}
public convertLocationToPosition (x: number, y: number) {
return {
line: ~~(y / this.store.font_attr.height),
col: ~~(x / this.store.font_attr.width),
};
}
public changeFontFamily(font: string): void {
this.element.style.fontFamily = font;
this.store.dispatcher.dispatch(
A.updateFontFace(font));
this.recalculateFontSize();
}
public changeFontSize(size_px: number): void {
this.element.style.fontSize = size_px + 'px';
this.store.dispatcher.dispatch(
A.updateFontPx(size_px));
this.recalculateFontSize();
}
public changeLineHeight(new_value: number) {
this.store.dispatcher.dispatch(A.updateLineHeight(new_value));
}
public resize (lines: number, cols: number) {
const heightPx = lines * this.store.font_attr.height;
const widthPx = cols * this.store.font_attr.width;
this.resizeImpl(lines, cols, widthPx, heightPx);
}
public resizeWithPixels (width: number, height: number) {
const lines = ~~(height / this.store.font_attr.height);
const cols = ~~(width / this.store.font_attr.width);
this.resizeImpl(lines, cols, width, height);
}
public checkShouldResize(): void {
const {size} = this.store;
const p = this.element.parentElement;
const cw = p.offsetWidth;
const ch = p.offsetHeight;
if (cw == size.width && ch == size.height)
return;
this.resizeWithPixels(cw, ch);
}
private scroll(delta: number): void {
// delta > 0 => screen goes up
// delta < 0 => screen goes down
const {top, bottom, left, right} = this.store.scroll_region;
const charWidth = (right + 1) - left;
const pos = {line: top, col: left}
const size = {
lines: bottom - top + 1,
cols: right - left + 1}
this._lastRegion.position = pos;
this._lastRegion.size = size;
this._lastRegion.render();
log.debug('Scroll: ', this.store.scroll_region);
const ranges: TextRange[] = [];
for (let n = top; n <= bottom; n++) {
const lineNode = this.getLine(n);
const r = new TextRange(lineNode, left, right + 1);
ranges.push(r);
this.checkLater(n);
}
const fragments = ranges.map(r => r.extract());
const fillSpace = " ".repeat(charWidth);
const absDelta = Math.abs(delta);
for (let i = 0; i < absDelta; i++) {
let frag = document.createElement('span');
if (delta > 0) {
fragments.shift();
fragments.push(frag);
} else {
fragments.pop();
fragments.unshift(frag);
}
}
for (let i = 0; i < ranges.length; i++) {
let range = ranges[i];
let fragment = fragments[i];
range.insertBefore(fragment);
}
}
private put(chars: string[][]) {
const {line, col} = this.store.cursor;
const text = chars.map(c => c[0]).join("");
this.insertText(line, col, text, this._attr);
this.checkLater(line);
}
private insertText(line: number, col: number, text: string, attributes: string) {
const currentLine = this.getLine(line) as HTMLElement;
const widthDiff = col - currentLine.textContent.length;
if (widthDiff >= 0) {
let html = '';
if (widthDiff > 0) {
html += `<span>${" ".repeat(widthDiff)}</span>`;
}
html += `<span ${attributes}>${text}</span>`;
currentLine.insertAdjacentHTML('beforeend', html);
return;
}
/*if (line == this._lastPos.line && col == this._lastPos.col) {
let aNode = this._lastNode.nextSibling;
let bNode = aNode;
let bOffset = 0;
let charIndex = col;
while (bNode != null) {
let len = bNode.textContent.length;
if (charIndex + len >= endCol) {
bOffset = endCol - charIndex;
break;
}
charIndex += len;
bNode = bNode.nextSibling;
}
if (bNode != null)
bNode.textContent = bNode.textContent.slice(bOffset);
while (aNode != null &&
aNode.nextSibling &&
aNode.nextSibling != bNode)
currentLine.removeChild(aNode.nextSibling);
nextNode = bNode;
} else {*/
const endCol = col + text.length;
const n = new TextRange(currentLine, col, endCol);
let aNode = n.startNode;
let bNode = n.endNode;
let aOffset = n.startOffset;
let bOffset = n.endOffset;
if (aNode == bNode) {
aNode = aNode.cloneNode(true);
currentLine.insertBefore(aNode, bNode);
}
if (aNode != null)
aNode.textContent = aNode.textContent.slice(0, aOffset);
if (bNode != null)
bNode.textContent = bNode.textContent.slice(bOffset);
while (aNode != null &&
aNode.nextSibling &&
aNode.nextSibling != bNode)
currentLine.removeChild(aNode.nextSibling);
const html = `<span ${attributes}>${text}</span>`;
if (aNode)
(aNode as HTMLElement).insertAdjacentHTML('afterend', html);
else if (bNode)
(bNode as HTMLElement).insertAdjacentHTML('beforebegin', html);
else
currentLine.insertAdjacentHTML('afterbegin', html);
// this._lastPos = {line, col: endCol};
// this._lastNode = newNode;
// log.debug(_(currentLine.textContent), currentLine.textContent.length);
/* if ((this.store.size.cols - currentLine.textContent.length) < 0)
* log.warn(s([col, endCol]),
* s(currentLine.textContent),
* currentLine.textContent.length); */
}
private clearEmptyNodes(line: Node): void {
const nodes = line.childNodes;
for (let i = 0; i < nodes.length; i++) {
let node = nodes.item(i);
if (node.textContent.length === 0) {
log.debug('clearingEmptyNode:', this.store.cursor);
line.removeChild(node);
}
}
}
private clearDisplay() {
let lineNode = this.container.firstChild;
while (lineNode != null) {
while (lineNode.firstChild)
lineNode.removeChild(lineNode.firstChild);
lineNode.appendChild(emmet('span'));
lineNode = lineNode.nextSibling;
}
for (let n in this._lines)
this._lines[n].content = '';
}
private clearTilEndOfLine () {
const {line, col} = this.store.cursor;
const lineNode = this.getLine(line);
log.debug('clearTilEndOfLine:', this.store.cursor);
let charIndex = 0;
let node = lineNode.firstChild;
while (node != null) {
let len = node.textContent.length;
if (charIndex + len < col) {
charIndex += len;
node = node.nextSibling;
continue;
}
if (charIndex + len > col)
node.textContent = node.textContent.substring(0, col - charIndex);
while (node.nextSibling)
lineNode.removeChild(node.nextSibling);
break;
}
this._lines[line].insert(col, {
text: " ".repeat(this.store.size.cols - col)
});
if (lineNode.textContent.length < this.store.size.cols) {
let width = this.store.size.cols - lineNode.textContent.length;
log.debug('\t: (missing):', width);
// lineNode.appendChild(this.newSpan(" ".repeat(width), null));
}
}
private checkLater(line: number) {
if (!this._lineTimer) {
this._linesToCheck = [];
this._lineTimer = setTimeout(() => {
for (let line of this._linesToCheck) {
let node = this.getLine(line);
if (node) this.clearEmptyNodes(node);
}
this._lineTimer = undefined;
}, 200);
}
this._linesToCheck[line] = line;
}
private redrawLater() {
if (!this._redrawTimer) {
this._redrawTimer = setTimeout(() => {
this._redrawTimer = undefined;
}, 50);
}
}
/*
* Section: Ugly private
*/
public getLine(n: number): Node {
const len = this.container.children.length;
if (n >= len) {
if (this.store.size.lines < n) {
log.warn('getLine: less children than n', len, n)
while (this.container.children.length < this.store.size.lines)
this.container.appendChild(emmet('div'));
} else {
log.warn('getLine: n greater than store.lines', len, n)
return emmet('div');
}
}
const line = this.container.children.item(n);
if (line && line.childElementCount == 0)
line.appendChild(emmet('span'));
return line;
}
public lineAt (n: number): string {
return this.getLine(n).textContent;
}
public getCharAt(line: number, col: number): string {
const lineNode = this.container.children.item(line);
if (lineNode === null) return '';
return lineNode.textContent.charAt(col);
}
public getStyleAt(line: number, col: number) {
const lineNode = this.container.children.item(line);
if (lineNode === null || lineNode.textContent.length <= col)
return null;
let charIndex = 0;
let node = lineNode.firstChild;
while (node != null) {
charIndex += node.textContent.length;
if (charIndex > col)
break;
node = node.nextSibling;
}
return (node as HTMLElement).style;
}
private recalculateFontSize(): void {
const r = getFontSize(this.element);
const widthPx = r.width;
const heightPx = r.height;
this.store.dispatcher.dispatch(
A.updateFontSize(
widthPx, heightPx,
widthPx, heightPx)
);
}
private updateScrollRegion(): void {
throw "Unimplmented updateScrollRegion()";
}
private getPositionFrom (event: MouseEvent) {
const bounds = this.container.getBoundingClientRect();
const {clientX, clientY} = event;
let x = clientX - bounds.left;
let y = clientY - bounds.top;
const pos = this.convertLocationToPosition(x, y);
let e = event as any;
e.line = pos.line;
e.col = pos.col;
return pos;
}
private printScreen(target?: any) {
const {line, col} = this.store.cursor;
let msg = '';
for (let n in this._lines) {
let lnum = n.toString();
let content = this._lines[n].content;
if (n == line.toString()) {
content = content.slice(0, col)
+ '<span class="cursor">'
+ content.charAt(col)
+ '</span>'
+ content.slice(col + 1);
}
lnum += (lnum.length == 1) ? " " : "";
msg += `${lnum}:[${content}]\n`;
}
if (!target) {
log.info(msg);
return;
}
target.innerHTML = msg;
}
private resizeImpl (lines: number, cols: number, width: number, height: number) {
lines = minMax(lines, [10, 200]);
cols = minMax(cols, [10, 400]);
while (this._lines.length < lines)
this._lines.push(new LineModel(cols));
while (this._lines.length > lines)
this._lines.pop();
for (let line of this._lines)
line.setSize(cols);
const children = this.container.children;
while (children.length < lines) {
const lineNode = emmet('div');
this.container.appendChild(lineNode);
}
this.store.dispatcher.dispatch(A.updateScreenSize(width, height));
this.store.dispatcher.dispatch(A.updateScreenBounds(lines, cols));
while (children.length > lines) {
this.container.removeChild(this.container.lastChild);
}
log.debug(`resizeImpl(): lines: ${lines} cols: ${cols} children.length: ${children.length}`);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment