Created
October 16, 2016 01:47
-
-
Save romgrk/01a74a06eb6f6ee26baf97b8c07c47bf to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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