diff --git a/package.json b/package.json index c4d5ba530..23add34e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "atom", "productName": "Atom", - "version": "0.91.0", + "version": "0.92.0", "description": "A hackable text editor for the 21st Century.", "main": "./src/browser/main.js", "repository": { @@ -36,10 +36,12 @@ "nslog": "0.5.0", "oniguruma": "^1.0.6", "optimist": "0.4.0", - "pathwatcher": "^1.2", + "pathwatcher": "^1.2.1", "property-accessors": "1.x", "q": "^1.0.1", "random-words": "0.0.1", + "react": "^0.10.0", + "reactionary": "^0.8.0", "runas": "0.5.x", "scandal": "0.15.2", "scoped-property-store": "^0.8.0", @@ -51,7 +53,7 @@ "temp": "0.5.0", "text-buffer": "^2.1.0", "theorist": "1.x", - "underscore-plus": "^1.1.2", + "underscore-plus": "^1.2.1", "vm-compatibility-layer": "0.1.0" }, "packageDependencies": { @@ -82,7 +84,7 @@ "image-view": "0.33.0", "keybinding-resolver": "0.17.0", "link": "0.22.0", - "markdown-preview": "0.64.0", + "markdown-preview": "0.65.0", "metrics": "0.32.0", "open-on-github": "0.28.0", "package-generator": "0.30.0", @@ -93,7 +95,7 @@ "status-bar": "0.40.0", "styleguide": "0.29.0", "symbols-view": "0.49.0", - "tabs": "0.36.0", + "tabs": "0.37.0", "timecop": "0.18.0", "tree-view": "0.90.0", "update-package-dependencies": "0.6.0", diff --git a/spec/display-buffer-spec.coffee b/spec/display-buffer-spec.coffee index 001942ed4..2d1eaca30 100644 --- a/spec/display-buffer-spec.coffee +++ b/spec/display-buffer-spec.coffee @@ -943,3 +943,69 @@ describe "DisplayBuffer", -> expect(displayBuffer.getMarkerCount()).toBe initialMarkerCount + 2 expect(marker1.getAttributes()).toEqual a: 1, b: 2 expect(marker2.getAttributes()).toEqual a: 1, b: 3 + + describe "DisplayBufferMarker::getPixelRange()", -> + it "returns the start and end positions of the marker based on the line height and character widths assigned to the DisplayBuffer", -> + marker = displayBuffer.markScreenRange([[5, 10], [6, 4]]) + + displayBuffer.setLineHeight(20) + displayBuffer.setDefaultCharWidth(10) + displayBuffer.setScopedCharWidths(["source.js", "keyword.control.js"], r: 11, e: 11, t: 11, u: 11, n: 11) + + {start, end} = marker.getPixelRange() + expect(start.top).toBe 5 * 20 + expect(start.left).toBe (4 * 10) + (6 * 11) + + describe "::setScrollTop", -> + beforeEach -> + displayBuffer.manageScrollPosition = true + displayBuffer.setLineHeight(10) + + it "disallows negative values", -> + displayBuffer.setHeight(displayBuffer.getScrollHeight() + 100) + expect(displayBuffer.setScrollTop(-10)).toBe 0 + expect(displayBuffer.getScrollTop()).toBe 0 + + it "disallows values that would make ::getScrollBottom() exceed ::getScrollHeight()", -> + displayBuffer.setHeight(50) + maxScrollTop = displayBuffer.getScrollHeight() - displayBuffer.getHeight() + + expect(displayBuffer.setScrollTop(maxScrollTop)).toBe maxScrollTop + expect(displayBuffer.getScrollTop()).toBe maxScrollTop + + expect(displayBuffer.setScrollTop(maxScrollTop + 50)).toBe maxScrollTop + expect(displayBuffer.getScrollTop()).toBe maxScrollTop + + describe "::setScrollLeft", -> + beforeEach -> + displayBuffer.manageScrollPosition = true + displayBuffer.setDefaultCharWidth(10) + + it "disallows negative values", -> + displayBuffer.setWidth(displayBuffer.getScrollWidth() + 100) + expect(displayBuffer.setScrollLeft(-10)).toBe 0 + expect(displayBuffer.getScrollLeft()).toBe 0 + + it "disallows values that would make ::getScrollRight() exceed ::getScrollWidth()", -> + displayBuffer.setWidth(50) + maxScrollLeft = displayBuffer.getScrollWidth() - displayBuffer.getWidth() + + expect(displayBuffer.setScrollLeft(maxScrollLeft)).toBe maxScrollLeft + expect(displayBuffer.getScrollLeft()).toBe maxScrollLeft + + expect(displayBuffer.setScrollLeft(maxScrollLeft + 50)).toBe maxScrollLeft + expect(displayBuffer.getScrollLeft()).toBe maxScrollLeft + + describe "::scrollToScreenPosition(position)", -> + it "sets the scroll top and scroll left so the given screen position is in view", -> + displayBuffer.manageScrollPosition = true + displayBuffer.setLineHeight(10) + displayBuffer.setDefaultCharWidth(10) + + displayBuffer.setHeight(50) + displayBuffer.setWidth(50) + maxScrollTop = displayBuffer.getScrollHeight() - displayBuffer.getHeight() + + displayBuffer.scrollToScreenPosition([8, 20]) + expect(displayBuffer.getScrollBottom()).toBe (9 + displayBuffer.getVerticalScrollMargin()) * 10 + expect(displayBuffer.getScrollRight()).toBe (20 + displayBuffer.getHorizontalScrollMargin()) * 10 diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee new file mode 100644 index 000000000..7ae9765eb --- /dev/null +++ b/spec/editor-component-spec.coffee @@ -0,0 +1,580 @@ +{extend, flatten, toArray} = require 'underscore-plus' +ReactEditorView = require '../src/react-editor-view' +nbsp = String.fromCharCode(160) + +describe "EditorComponent", -> + [editor, wrapperView, component, node, verticalScrollbarNode, horizontalScrollbarNode] = [] + [lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = [] + + beforeEach -> + waitsForPromise -> + atom.packages.activatePackage('language-javascript') + + runs -> + spyOn(window, "setInterval").andCallFake window.fakeSetInterval + spyOn(window, "clearInterval").andCallFake window.fakeClearInterval + + delayAnimationFrames = false + nextAnimationFrame = null + spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> + if delayAnimationFrames + nextAnimationFrame = fn + else + fn() + + editor = atom.project.openSync('sample.js') + wrapperView = new ReactEditorView(editor) + wrapperView.attachToDom() + {component} = wrapperView + component.setLineHeight(1.3) + component.setFontSize(20) + + lineHeightInPixels = editor.getLineHeight() + charWidth = editor.getDefaultCharWidth() + node = component.getDOMNode() + verticalScrollbarNode = node.querySelector('.vertical-scrollbar') + horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar') + + describe "line rendering", -> + it "renders only the currently-visible lines", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.measureHeightAndWidth() + + lines = node.querySelectorAll('.line') + expect(lines.length).toBe 6 + expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text + expect(lines[5].textContent).toBe editor.lineForScreenRow(5).text + + verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + + expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate3d(0px, #{-2.5 * lineHeightInPixels}px, 0)" + + lineNodes = node.querySelectorAll('.line') + expect(lineNodes.length).toBe 6 + expect(lineNodes[0].offsetTop).toBe 2 * lineHeightInPixels + expect(lineNodes[0].textContent).toBe editor.lineForScreenRow(2).text + expect(lineNodes[5].textContent).toBe editor.lineForScreenRow(7).text + + it "updates absolute positions of subsequent lines when lines are inserted or removed", -> + editor.getBuffer().deleteRows(0, 1) + lineNodes = node.querySelectorAll('.line') + expect(lineNodes[0].offsetTop).toBe 0 + expect(lineNodes[1].offsetTop).toBe 1 * lineHeightInPixels + expect(lineNodes[2].offsetTop).toBe 2 * lineHeightInPixels + + editor.getBuffer().insert([0, 0], '\n\n') + lineNodes = node.querySelectorAll('.line') + expect(lineNodes[0].offsetTop).toBe 0 + expect(lineNodes[1].offsetTop).toBe 1 * lineHeightInPixels + expect(lineNodes[2].offsetTop).toBe 2 * lineHeightInPixels + expect(lineNodes[3].offsetTop).toBe 3 * lineHeightInPixels + expect(lineNodes[4].offsetTop).toBe 4 * lineHeightInPixels + + describe "when indent guides are enabled", -> + beforeEach -> + component.setShowIndentGuide(true) + + it "adds an 'indent-guide' class to spans comprising the leading whitespace", -> + lines = node.querySelectorAll('.line') + line1LeafNodes = getLeafNodes(lines[1]) + expect(line1LeafNodes[0].textContent).toBe ' ' + expect(line1LeafNodes[0].classList.contains('indent-guide')).toBe true + expect(line1LeafNodes[1].classList.contains('indent-guide')).toBe false + + line2LeafNodes = getLeafNodes(lines[2]) + expect(line2LeafNodes[0].textContent).toBe ' ' + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[1].textContent).toBe ' ' + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe false + + it "renders leading whitespace spans with the 'indent-guide' class for empty lines", -> + editor.getBuffer().insert([1, Infinity], '\n') + + lines = node.querySelectorAll('.line') + line2LeafNodes = getLeafNodes(lines[2]) + + expect(line2LeafNodes.length).toBe 3 + expect(line2LeafNodes[0].textContent).toBe ' ' + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[1].textContent).toBe ' ' + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[2].textContent).toBe ' ' + expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe true + + it "renders indent guides correctly on lines containing only whitespace", -> + editor.getBuffer().insert([1, Infinity], '\n ') + lines = node.querySelectorAll('.line') + line2LeafNodes = getLeafNodes(lines[2]) + expect(line2LeafNodes.length).toBe 3 + expect(line2LeafNodes[0].textContent).toBe ' ' + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[1].textContent).toBe ' ' + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe true + expect(line2LeafNodes[2].textContent).toBe ' ' + expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe true + + it "does not render indent guides in trailing whitespace for lines containing non whitespace characters", -> + editor.getBuffer().setText (" hi ") + lines = node.querySelectorAll('.line') + line0LeafNodes = getLeafNodes(lines[0]) + expect(line0LeafNodes[0].textContent).toBe ' ' + expect(line0LeafNodes[0].classList.contains('indent-guide')).toBe true + expect(line0LeafNodes[1].textContent).toBe ' ' + expect(line0LeafNodes[1].classList.contains('indent-guide')).toBe false + + getLeafNodes = (node) -> + if node.children.length > 0 + flatten(toArray(node.children).map(getLeafNodes)) + else + [node] + + describe "gutter rendering", -> + it "renders the currently-visible line numbers", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.measureHeightAndWidth() + + lines = node.querySelectorAll('.line-number') + expect(lines.length).toBe 6 + expect(lines[0].textContent).toBe "#{nbsp}1" + expect(lines[5].textContent).toBe "#{nbsp}6" + + verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + + expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translate3d(0, #{-2.5 * lineHeightInPixels}px, 0)" + + lineNumberNodes = node.querySelectorAll('.line-number') + expect(lineNumberNodes.length).toBe 6 + expect(lineNumberNodes[0].offsetTop).toBe 2 * lineHeightInPixels + expect(lineNumberNodes[5].offsetTop).toBe 7 * lineHeightInPixels + expect(lineNumberNodes[0].textContent).toBe "#{nbsp}3" + expect(lineNumberNodes[5].textContent).toBe "#{nbsp}8" + + it "updates absolute positions of subsequent line numbers when lines are inserted or removed", -> + editor.getBuffer().insert([0, 0], '\n\n') + + lineNumberNodes = node.querySelectorAll('.line-number') + expect(lineNumberNodes[0].offsetTop).toBe 0 + expect(lineNumberNodes[1].offsetTop).toBe 1 * lineHeightInPixels + expect(lineNumberNodes[2].offsetTop).toBe 2 * lineHeightInPixels + expect(lineNumberNodes[3].offsetTop).toBe 3 * lineHeightInPixels + expect(lineNumberNodes[4].offsetTop).toBe 4 * lineHeightInPixels + + editor.getBuffer().insert([0, 0], '\n\n') + lineNumberNodes = node.querySelectorAll('.line-number') + expect(lineNumberNodes[0].offsetTop).toBe 0 + expect(lineNumberNodes[1].offsetTop).toBe 1 * lineHeightInPixels + expect(lineNumberNodes[2].offsetTop).toBe 2 * lineHeightInPixels + expect(lineNumberNodes[3].offsetTop).toBe 3 * lineHeightInPixels + expect(lineNumberNodes[4].offsetTop).toBe 4 * lineHeightInPixels + + it "renders • characters for soft-wrapped lines", -> + editor.setSoftWrap(true) + node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 30 * charWidth + 'px' + component.measureHeightAndWidth() + + lines = node.querySelectorAll('.line-number') + expect(lines.length).toBe 6 + expect(lines[0].textContent).toBe "#{nbsp}1" + expect(lines[1].textContent).toBe "#{nbsp}•" + expect(lines[2].textContent).toBe "#{nbsp}2" + expect(lines[3].textContent).toBe "#{nbsp}•" + expect(lines[4].textContent).toBe "#{nbsp}3" + expect(lines[5].textContent).toBe "#{nbsp}•" + + describe "cursor rendering", -> + it "renders the currently visible cursors", -> + cursor1 = editor.getCursor() + cursor1.setScreenPosition([0, 5]) + + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.measureHeightAndWidth() + + cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe 1 + expect(cursorNodes[0].offsetHeight).toBe lineHeightInPixels + expect(cursorNodes[0].offsetWidth).toBe charWidth + expect(cursorNodes[0].offsetTop).toBe 0 + expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth + + cursor2 = editor.addCursorAtScreenPosition([6, 11]) + cursor3 = editor.addCursorAtScreenPosition([4, 10]) + + cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe 2 + expect(cursorNodes[0].offsetTop).toBe 0 + expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth + expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels + expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth + + verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + + cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe 2 + expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels + expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth + expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels + expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth + + cursor3.destroy() + cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe 1 + expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels + expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth + + it "accounts for character widths when positioning cursors", -> + atom.config.set('editor.fontFamily', 'sans-serif') + editor.setCursorScreenPosition([0, 16]) + + cursor = node.querySelector('.cursor') + cursorRect = cursor.getBoundingClientRect() + + cursorLocationTextNode = node.querySelector('.storage.type.function.js').firstChild.firstChild + range = document.createRange() + range.setStart(cursorLocationTextNode, 0) + range.setEnd(cursorLocationTextNode, 1) + rangeRect = range.getBoundingClientRect() + + expect(cursorRect.left).toBe rangeRect.left + expect(cursorRect.width).toBe rangeRect.width + + it "blinks cursors when they aren't moving", -> + editor.addCursorAtScreenPosition([1, 0]) + [cursorNode1, cursorNode2] = node.querySelectorAll('.cursor') + expect(cursorNode1.classList.contains('blink-off')).toBe false + expect(cursorNode2.classList.contains('blink-off')).toBe false + + advanceClock(component.props.cursorBlinkPeriod / 2) + expect(cursorNode1.classList.contains('blink-off')).toBe true + expect(cursorNode2.classList.contains('blink-off')).toBe true + + advanceClock(component.props.cursorBlinkPeriod / 2) + expect(cursorNode1.classList.contains('blink-off')).toBe false + expect(cursorNode2.classList.contains('blink-off')).toBe false + + advanceClock(component.props.cursorBlinkPeriod / 2) + expect(cursorNode1.classList.contains('blink-off')).toBe true + expect(cursorNode2.classList.contains('blink-off')).toBe true + + # Stop blinking immediately when cursors move + advanceClock(component.props.cursorBlinkPeriod / 4) + expect(cursorNode1.classList.contains('blink-off')).toBe true + expect(cursorNode2.classList.contains('blink-off')).toBe true + + # Stop blinking for one full period after moving the cursor + editor.moveCursorRight() + expect(cursorNode1.classList.contains('blink-off')).toBe false + expect(cursorNode2.classList.contains('blink-off')).toBe false + + advanceClock(component.props.cursorBlinkResumeDelay / 2) + expect(cursorNode1.classList.contains('blink-off')).toBe false + expect(cursorNode2.classList.contains('blink-off')).toBe false + + advanceClock(component.props.cursorBlinkResumeDelay / 2) + expect(cursorNode1.classList.contains('blink-off')).toBe true + expect(cursorNode2.classList.contains('blink-off')).toBe true + + advanceClock(component.props.cursorBlinkPeriod / 2) + expect(cursorNode1.classList.contains('blink-off')).toBe false + expect(cursorNode2.classList.contains('blink-off')).toBe false + + it "renders the hidden input field at the position of the last cursor if it is on screen", -> + inputNode = node.querySelector('.hidden-input') + node.style.height = 5 * lineHeightInPixels + 'px' + node.style.width = 10 * charWidth + 'px' + component.measureHeightAndWidth() + + expect(editor.getCursorScreenPosition()).toEqual [0, 0] + editor.setScrollTop(3 * lineHeightInPixels) + editor.setScrollLeft(3 * charWidth) + expect(inputNode.offsetTop).toBe 0 + expect(inputNode.offsetLeft).toBe 0 + + editor.setCursorBufferPosition([5, 5]) + cursorRect = editor.getCursor().getPixelRect() + cursorTop = cursorRect.top + cursorLeft = cursorRect.left + expect(inputNode.offsetTop).toBe cursorTop - editor.getScrollTop() + expect(inputNode.offsetLeft).toBe cursorLeft - editor.getScrollLeft() + + it "does not render cursors that are associated with non-empty selections", -> + editor.setSelectedScreenRange([[0, 4], [4, 6]]) + editor.addCursorAtScreenPosition([6, 8]) + + cursorNodes = node.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe 1 + expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels + expect(cursorNodes[0].offsetLeft).toBe 8 * charWidth + + describe "selection rendering", -> + scrollViewClientLeft = null + + beforeEach -> + scrollViewClientLeft = node.querySelector('.scroll-view').getBoundingClientRect().left + + it "renders 1 region for 1-line selections", -> + # 1-line selection + editor.setSelectedScreenRange([[1, 6], [1, 10]]) + regions = node.querySelectorAll('.selection .region') + + expect(regions.length).toBe 1 + regionRect = regions[0].getBoundingClientRect() + expect(regionRect.top).toBe 1 * lineHeightInPixels + expect(regionRect.height).toBe 1 * lineHeightInPixels + expect(regionRect.left).toBe scrollViewClientLeft + 6 * charWidth + expect(regionRect.width).toBe 4 * charWidth + + it "renders 2 regions for 2-line selections", -> + editor.setSelectedScreenRange([[1, 6], [2, 10]]) + regions = node.querySelectorAll('.selection .region') + expect(regions.length).toBe 2 + + region1Rect = regions[0].getBoundingClientRect() + expect(region1Rect.top).toBe 1 * lineHeightInPixels + expect(region1Rect.height).toBe 1 * lineHeightInPixels + expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth + expect(Math.ceil(region1Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed + + region2Rect = regions[1].getBoundingClientRect() + expect(region2Rect.top).toBe 2 * lineHeightInPixels + expect(region2Rect.height).toBe 1 * lineHeightInPixels + expect(region2Rect.left).toBe scrollViewClientLeft + 0 + expect(region2Rect.width).toBe 10 * charWidth + + it "renders 3 regions for selections with more than 2 lines", -> + editor.setSelectedScreenRange([[1, 6], [5, 10]]) + regions = node.querySelectorAll('.selection .region') + expect(regions.length).toBe 3 + + region1Rect = regions[0].getBoundingClientRect() + expect(region1Rect.top).toBe 1 * lineHeightInPixels + expect(region1Rect.height).toBe 1 * lineHeightInPixels + expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth + expect(Math.ceil(region1Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed + + region2Rect = regions[1].getBoundingClientRect() + expect(region2Rect.top).toBe 2 * lineHeightInPixels + expect(region2Rect.height).toBe 3 * lineHeightInPixels + expect(region2Rect.left).toBe scrollViewClientLeft + 0 + expect(Math.ceil(region2Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed + + region3Rect = regions[2].getBoundingClientRect() + expect(region3Rect.top).toBe 5 * lineHeightInPixels + expect(region3Rect.height).toBe 1 * lineHeightInPixels + expect(region3Rect.left).toBe scrollViewClientLeft + 0 + expect(region3Rect.width).toBe 10 * charWidth + + it "does not render empty selections", -> + expect(editor.getSelection().isEmpty()).toBe true + expect(node.querySelectorAll('.selection').length).toBe 0 + + describe "mouse interactions", -> + linesNode = null + + beforeEach -> + delayAnimationFrames = true + linesNode = node.querySelector('.lines') + + describe "when a non-folded line is single-clicked", -> + describe "when no modifier keys are held down", -> + it "moves the cursor to the nearest screen position", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 10 * charWidth + 'px' + component.measureHeightAndWidth() + editor.setScrollTop(3.5 * lineHeightInPixels) + editor.setScrollLeft(2 * charWidth) + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]))) + expect(editor.getCursorScreenPosition()).toEqual [4, 8] + + describe "when the shift key is held down", -> + it "selects to the nearest screen position", -> + editor.setCursorScreenPosition([3, 4]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), shiftKey: true)) + expect(editor.getSelectedScreenRange()).toEqual [[3, 4], [5, 6]] + + describe "when the command key is held down", -> + it "adds a cursor at the nearest screen position", -> + editor.setCursorScreenPosition([3, 4]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), metaKey: true)) + expect(editor.getSelectedScreenRanges()).toEqual [[[3, 4], [3, 4]], [[5, 6], [5, 6]]] + + describe "when a non-folded line is double-clicked", -> + it "selects the word containing the nearest screen position", -> + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), detail: 2)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[5, 6], [5, 13]] + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), detail: 1)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[6, 6], [6, 6]] + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), detail: 1, shiftKey: true)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[6, 6], [8, 8]] + + describe "when a non-folded line is triple-clicked", -> + it "selects the line containing the nearest screen position", -> + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), detail: 3)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[5, 0], [6, 0]] + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), detail: 1, shiftKey: true)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[5, 0], [7, 0]] + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([7, 5]), detail: 1)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), detail: 1, shiftKey: true)) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual [[7, 5], [8, 8]] + + describe "when the mouse is clicked and dragged", -> + it "selects to the nearest screen position until the mouse button is released", -> + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), which: 1)) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [6, 8]] + + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [10, 0]] + + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([12, 0]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [10, 0]] + + it "stops selecting if the mouse is dragged into the dev tools", -> + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), which: 1)) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [6, 8]] + + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), which: 0)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [6, 8]] + + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), which: 1)) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 4], [6, 8]] + + clientCoordinatesForScreenPosition = (screenPosition) -> + positionOffset = editor.pixelPositionForScreenPosition(screenPosition) + scrollViewClientRect = node.querySelector('.scroll-view').getBoundingClientRect() + clientX = scrollViewClientRect.left + positionOffset.left - editor.getScrollLeft() + clientY = scrollViewClientRect.top + positionOffset.top - editor.getScrollTop() + {clientX, clientY} + + buildMouseEvent = (type, properties...) -> + properties = extend({bubbles: true, cancelable: true}, properties...) + event = new MouseEvent(type, properties) + Object.defineProperty(event, 'which', get: -> properties.which) if properties.which? + event + + describe "focus handling", -> + inputNode = null + + beforeEach -> + inputNode = node.querySelector('.hidden-input') + + it "transfers focus to the hidden input", -> + expect(document.activeElement).toBe document.body + node.focus() + expect(document.activeElement).toBe inputNode + + it "adds the 'is-focused' class to the editor when the hidden input is focused", -> + expect(document.activeElement).toBe document.body + inputNode.focus() + expect(node.classList.contains('is-focused')).toBe true + inputNode.blur() + expect(node.classList.contains('is-focused')).toBe false + + describe "scrolling", -> + it "updates the vertical scrollbar when the scrollTop is changed in the model", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.measureHeightAndWidth() + + expect(verticalScrollbarNode.scrollTop).toBe 0 + + editor.setScrollTop(10) + expect(verticalScrollbarNode.scrollTop).toBe 10 + + it "updates the horizontal scrollbar and scroll view content x transform based on the scrollLeft of the model", -> + node.style.width = 30 * charWidth + 'px' + component.measureHeightAndWidth() + + scrollViewContentNode = node.querySelector('.scroll-view-content') + expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0)" + expect(horizontalScrollbarNode.scrollLeft).toBe 0 + + editor.setScrollLeft(100) + expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(-100px, 0px, 0)" + expect(horizontalScrollbarNode.scrollLeft).toBe 100 + + it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", -> + node.style.width = 30 * charWidth + 'px' + component.measureHeightAndWidth() + + expect(editor.getScrollLeft()).toBe 0 + horizontalScrollbarNode.scrollLeft = 100 + horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + + expect(editor.getScrollLeft()).toBe 100 + + describe "when a mousewheel event occurs on the editor", -> + it "updates the horizontal or vertical scrollbar depending on which delta is greater (x or y)", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 20 * charWidth + 'px' + component.measureHeightAndWidth() + + expect(verticalScrollbarNode.scrollTop).toBe 0 + expect(horizontalScrollbarNode.scrollLeft).toBe 0 + + node.dispatchEvent(new WheelEvent('mousewheel', wheelDeltaX: -5, wheelDeltaY: -10)) + expect(verticalScrollbarNode.scrollTop).toBe 10 + expect(horizontalScrollbarNode.scrollLeft).toBe 0 + + node.dispatchEvent(new WheelEvent('mousewheel', wheelDeltaX: -15, wheelDeltaY: -5)) + expect(verticalScrollbarNode.scrollTop).toBe 10 + expect(horizontalScrollbarNode.scrollLeft).toBe 15 + + describe "input events", -> + inputNode = null + + beforeEach -> + inputNode = node.querySelector('.hidden-input') + + it "inserts the newest character in the input's value into the buffer", -> + inputNode.value = 'x' + inputNode.dispatchEvent(new Event('input')) + expect(editor.lineForBufferRow(0)).toBe 'xvar quicksort = function () {' + + inputNode.value = 'xy' + inputNode.dispatchEvent(new Event('input')) + expect(editor.lineForBufferRow(0)).toBe 'xyvar quicksort = function () {' + + it "replaces the last character if the length of the input's value doesn't increase, as occurs with the accented character menu", -> + inputNode.value = 'u' + inputNode.dispatchEvent(new Event('input')) + expect(editor.lineForBufferRow(0)).toBe 'uvar quicksort = function () {' + + inputNode.value = 'ü' + inputNode.dispatchEvent(new Event('input')) + expect(editor.lineForBufferRow(0)).toBe 'üvar quicksort = function () {' + + describe "commands", -> + describe "editor:consolidate-selections", -> + it "consolidates selections on the editor model, aborting the key binding if there is only one selection", -> + spyOn(editor, 'consolidateSelections').andCallThrough() + + event = new CustomEvent('editor:consolidate-selections', bubbles: true, cancelable: true) + event.abortKeyBinding = jasmine.createSpy("event.abortKeyBinding") + node.dispatchEvent(event) + + expect(editor.consolidateSelections).toHaveBeenCalled() + expect(event.abortKeyBinding).toHaveBeenCalled() diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index fea18381d..5ec68e6a1 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -129,6 +129,21 @@ describe "Editor", -> editor.moveCursorDown() expect(editor.getCursorBufferPosition()).toEqual [1, 1] + it "emits a single 'cursors-moved' event for all moved cursors", -> + editor.on 'cursors-moved', cursorsMovedHandler = jasmine.createSpy("cursorsMovedHandler") + + editor.moveCursorDown() + expect(cursorsMovedHandler.callCount).toBe 1 + + cursorsMovedHandler.reset() + editor.addCursorAtScreenPosition([3, 0]) + editor.moveCursorDown() + expect(cursorsMovedHandler.callCount).toBe 1 + + cursorsMovedHandler.reset() + editor.getCursor().moveDown() + expect(cursorsMovedHandler.callCount).toBe 1 + describe ".setCursorScreenPosition(screenPosition)", -> it "clears a goal column established by vertical movement", -> # set a goal column by moving down @@ -663,6 +678,67 @@ describe "Editor", -> cursor2 = editor.addCursorAtBufferPosition([1,4]) expect(cursor2.marker).toBe cursor1.marker + describe "autoscroll", -> + beforeEach -> + editor.manageScrollPosition = true + editor.setVerticalScrollMargin(2) + editor.setHorizontalScrollMargin(2) + editor.setLineHeight(10) + editor.setDefaultCharWidth(10) + editor.setHeight(5.5 * 10) + editor.setWidth(5.5 * 10) + + it "scrolls down when the last cursor gets closer than ::verticalScrollMargin to the bottom of the editor", -> + expect(editor.getScrollTop()).toBe 0 + expect(editor.getScrollBottom()).toBe 5.5 * 10 + + editor.setCursorScreenPosition([2, 0]) + expect(editor.getScrollBottom()).toBe 5.5 * 10 + + editor.moveCursorDown() + expect(editor.getScrollBottom()).toBe 6 * 10 + + editor.moveCursorDown() + expect(editor.getScrollBottom()).toBe 7 * 10 + + it "scrolls up when the last cursor gets closer than ::verticalScrollMargin to the top of the editor", -> + editor.setCursorScreenPosition([11, 0]) + editor.setScrollBottom(editor.getScrollHeight()) + + editor.moveCursorUp() + expect(editor.getScrollBottom()).toBe editor.getScrollHeight() + + editor.moveCursorUp() + expect(editor.getScrollTop()).toBe 7 * 10 + + editor.moveCursorUp() + expect(editor.getScrollTop()).toBe 6 * 10 + + it "scrolls right when the last cursor gets closer than ::horizontalScrollMargin to the right of the editor", -> + expect(editor.getScrollLeft()).toBe 0 + expect(editor.getScrollRight()).toBe 5.5 * 10 + + editor.setCursorScreenPosition([0, 2]) + expect(editor.getScrollRight()).toBe 5.5 * 10 + + editor.moveCursorRight() + expect(editor.getScrollRight()).toBe 6 * 10 + + editor.moveCursorRight() + expect(editor.getScrollRight()).toBe 7 * 10 + + it "scrolls left when the last cursor gets closer than ::horizontalScrollMargin to the left of the editor", -> + editor.setScrollRight(editor.getScrollWidth()) + editor.setCursorScreenPosition([6, 62]) + + expect(editor.getScrollRight()).toBe editor.getScrollWidth() + + editor.moveCursorLeft() + expect(editor.getScrollLeft()).toBe 59 * 10 + + editor.moveCursorLeft() + expect(editor.getScrollLeft()).toBe 58 * 10 + describe "selection", -> selection = null @@ -1018,7 +1094,7 @@ describe "Editor", -> expect(selection1).toBe selection expect(selection1.getBufferRange()).toEqual [[2, 2], [3, 3]] - describe "when the preserveFolds option is false (the default)", -> + describe "when the 'preserveFolds' option is false (the default)", -> it "removes folds that contain the selections", -> editor.setSelectedBufferRange([[0,0], [0,0]]) editor.createFold(1, 4) @@ -1032,7 +1108,7 @@ describe "Editor", -> expect(editor.lineForScreenRow(6).fold).toBeUndefined() expect(editor.lineForScreenRow(10).fold).toBeDefined() - describe "when the preserve folds option is true", -> + describe "when the 'preserveFolds' option is true", -> it "does not remove folds that contain the selections", -> editor.setSelectedBufferRange([[0,0], [0,0]]) editor.createFold(1, 4) @@ -1041,6 +1117,24 @@ describe "Editor", -> expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + describe ".setSelectedBufferRange(range)", -> + describe "when the 'autoscroll' option is true", -> + it "autoscrolls to the selection", -> + editor.manageScrollPosition = true + editor.setLineHeight(10) + editor.setDefaultCharWidth(10) + editor.setHeight(50) + editor.setWidth(50) + expect(editor.getScrollTop()).toBe 0 + + editor.setSelectedBufferRange([[5, 6], [6, 8]], autoscroll: true) + expect(editor.getScrollBottom()).toBe (7 + editor.getVerticalScrollMargin()) * 10 + expect(editor.getScrollRight()).toBe 50 + + editor.setSelectedBufferRange([[6, 6], [6, 8]], autoscroll: true) + expect(editor.getScrollBottom()).toBe (7 + editor.getVerticalScrollMargin()) * 10 + expect(editor.getScrollRight()).toBe (8 + editor.getHorizontalScrollMargin()) * 10 + describe ".selectMarker(marker)", -> describe "if the marker is valid", -> it "selects the marker's range and returns the selected range", -> @@ -2979,3 +3073,37 @@ describe "Editor", -> editor.setSoftTabs(false) editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) expect(editor.getText()).toBe ' ' + + describe ".scrollToCursorPosition()", -> + it "scrolls the last cursor into view", -> + editor.setCursorScreenPosition([8, 8]) + editor.setLineHeight(10) + editor.setDefaultCharWidth(10) + editor.setHeight(50) + editor.setWidth(50) + expect(editor.getScrollTop()).toBe 0 + expect(editor.getScrollLeft()).toBe 0 + + editor.scrollToCursorPosition() + expect(editor.getScrollBottom()).toBe (9 + editor.getVerticalScrollMargin()) * 10 + expect(editor.getScrollRight()).toBe (9 + editor.getHorizontalScrollMargin()) * 10 + + describe ".pageUp/Down()", -> + it "scrolls one screen height up or down", -> + editor.manageScrollPosition = true + + editor.setLineHeight(10) + editor.setHeight(50) + expect(editor.getScrollHeight()).toBe 130 + + editor.pageDown() + expect(editor.getScrollTop()).toBe 50 + + editor.pageDown() + expect(editor.getScrollTop()).toBe 80 + + editor.pageUp() + expect(editor.getScrollTop()).toBe 30 + + editor.pageUp() + expect(editor.getScrollTop()).toBe 0 diff --git a/spec/editor-view-spec.coffee b/spec/editor-view-spec.coffee index 0e52f1849..19e8485d0 100644 --- a/spec/editor-view-spec.coffee +++ b/spec/editor-view-spec.coffee @@ -1631,7 +1631,7 @@ describe "EditorView", -> editor.setSoftWrap(true) it "doesn't show the end of line invisible at the end of lines broken due to wrapping", -> - editor.setText "a line that wraps" + editor.setText "a line that wraps " editorView.attachToDom() editorView.setWidthInChars(6) atom.config.set "editor.showInvisibles", true @@ -1639,11 +1639,11 @@ describe "EditorView", -> expect(space).toBeTruthy() eol = editorView.invisibles?.eol expect(eol).toBeTruthy() - expect(editorView.renderedLines.find('.line:first').text()).toBe "a line#{space}" - expect(editorView.renderedLines.find('.line:last').text()).toBe "wraps#{eol}" + expect(editorView.renderedLines.find('.line:first').text()).toBe "a line " + expect(editorView.renderedLines.find('.line:last').text()).toBe "wraps#{space}#{eol}" it "displays trailing carriage return using a visible non-empty value", -> - editor.setText "a line that\r\n" + editor.setText "a line that \r\n" editorView.attachToDom() editorView.setWidthInChars(6) atom.config.set "editor.showInvisibles", true @@ -1653,8 +1653,8 @@ describe "EditorView", -> expect(cr).toBeTruthy() eol = editorView.invisibles?.eol expect(eol).toBeTruthy() - expect(editorView.renderedLines.find('.line:first').text()).toBe "a line#{space}" - expect(editorView.renderedLines.find('.line:eq(1)').text()).toBe "that#{cr}#{eol}" + expect(editorView.renderedLines.find('.line:first').text()).toBe "a line " + expect(editorView.renderedLines.find('.line:eq(1)').text()).toBe "that#{space}#{cr}#{eol}" expect(editorView.renderedLines.find('.line:last').text()).toBe "#{eol}" describe "when editor.showIndentGuide is set to true", -> diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index a5f3a776e..14f4fadad 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -86,6 +86,7 @@ beforeEach -> config.set "editor.autoIndent", false config.set "core.disabledPackages", ["package-that-throws-an-exception", "package-with-broken-package-json", "package-with-broken-keymap"] + config.set "core.useReactEditor", false config.save.reset() atom.config = config @@ -267,6 +268,15 @@ window.fakeSetTimeout = (callback, ms) -> window.fakeClearTimeout = (idToClear) -> window.timeouts = window.timeouts.filter ([id]) -> id != idToClear +window.fakeSetInterval = (callback, ms) -> + action = -> + callback() + window.fakeSetTimeout(action, ms) + window.fakeSetTimeout(action, ms) + +window.fakeClearInterval = (idToClear) -> + window.fakeClearTimeout(idToClear) + window.advanceClock = (delta=1) -> window.now += delta callbacks = [] diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index f45dfa8d5..c1f29196a 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -463,3 +463,77 @@ describe "TokenizedBuffer", -> expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' atom.config.set('editor.tabLength', 0) expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' + + describe "leading and trailing whitespace", -> + beforeEach -> + buffer = atom.project.bufferForPathSync('sample.js') + tokenizedBuffer = new TokenizedBuffer({buffer}) + fullyTokenize(tokenizedBuffer) + + it "sets ::hasLeadingWhitespace to true on tokens that have leading whitespace", -> + expect(tokenizedBuffer.lineForScreenRow(0).tokens[0].hasLeadingWhitespace).toBe false + expect(tokenizedBuffer.lineForScreenRow(1).tokens[0].hasLeadingWhitespace).toBe true + expect(tokenizedBuffer.lineForScreenRow(1).tokens[1].hasLeadingWhitespace).toBe false + expect(tokenizedBuffer.lineForScreenRow(2).tokens[0].hasLeadingWhitespace).toBe true + expect(tokenizedBuffer.lineForScreenRow(2).tokens[1].hasLeadingWhitespace).toBe true + expect(tokenizedBuffer.lineForScreenRow(2).tokens[2].hasLeadingWhitespace).toBe false + + # The 4th token *has* leading whitespace, but isn't entirely whitespace + buffer.insert([5, 0], ' ') + expect(tokenizedBuffer.lineForScreenRow(5).tokens[3].hasLeadingWhitespace).toBe true + expect(tokenizedBuffer.lineForScreenRow(5).tokens[4].hasLeadingWhitespace).toBe false + + # Lines that are *only* whitespace are not considered to have leading whitespace + buffer.insert([10, 0], ' ') + expect(tokenizedBuffer.lineForScreenRow(10).tokens[0].hasLeadingWhitespace).toBe false + + it "sets ::hasTrailingWhitespace to true on tokens that have trailing whitespace", -> + buffer.insert([0, Infinity], ' ') + expect(tokenizedBuffer.lineForScreenRow(0).tokens[11].hasTrailingWhitespace).toBe false + expect(tokenizedBuffer.lineForScreenRow(0).tokens[12].hasTrailingWhitespace).toBe true + + # The last token *has* trailing whitespace, but isn't entirely whitespace + buffer.setTextInRange([[2, 39], [2, 40]], ' ') + expect(tokenizedBuffer.lineForScreenRow(2).tokens[14].hasTrailingWhitespace).toBe false + expect(tokenizedBuffer.lineForScreenRow(2).tokens[15].hasTrailingWhitespace).toBe true + + # Lines that are *only* whitespace are considered to have trailing whitespace + buffer.insert([10, 0], ' ') + expect(tokenizedBuffer.lineForScreenRow(10).tokens[0].hasTrailingWhitespace).toBe true + + it "only marks trailing whitespace on the last segment of a soft-wrapped line", -> + buffer.insert([0, Infinity], ' ') + tokenizedLine = tokenizedBuffer.lineForScreenRow(0) + [segment1, segment2] = tokenizedLine.softWrapAt(16) + expect(segment1.tokens[5].value).toBe ' ' + expect(segment1.tokens[5].hasTrailingWhitespace).toBe false + expect(segment2.tokens[6].value).toBe ' ' + expect(segment2.tokens[6].hasTrailingWhitespace).toBe true + + describe "indent level", -> + beforeEach -> + buffer = atom.project.bufferForPathSync('sample.js') + tokenizedBuffer = new TokenizedBuffer({buffer}) + fullyTokenize(tokenizedBuffer) + + describe "when the line is non-empty", -> + it "has an indent level based on the leading whitespace on the line", -> + expect(tokenizedBuffer.lineForScreenRow(0).indentLevel).toBe 0 + expect(tokenizedBuffer.lineForScreenRow(1).indentLevel).toBe 1 + expect(tokenizedBuffer.lineForScreenRow(2).indentLevel).toBe 2 + buffer.insert([2, 0], ' ') + expect(tokenizedBuffer.lineForScreenRow(2).indentLevel).toBe 2.5 + + describe "when the line is empty", -> + it "assumes the indentation level of the first non-empty line below or above if one exists", -> + buffer.insert([12, 0], ' ') + buffer.insert([12, Infinity], '\n\n') + expect(tokenizedBuffer.lineForScreenRow(13).indentLevel).toBe 2 + expect(tokenizedBuffer.lineForScreenRow(14).indentLevel).toBe 2 + + buffer.insert([1, Infinity], '\n\n') + expect(tokenizedBuffer.lineForScreenRow(2).indentLevel).toBe 2 + expect(tokenizedBuffer.lineForScreenRow(3).indentLevel).toBe 2 + + buffer.setText('\n\n\n') + expect(tokenizedBuffer.lineForScreenRow(1).indentLevel).toBe 0 diff --git a/spec/tokenized-line-spec.coffee b/spec/tokenized-line-spec.coffee new file mode 100644 index 000000000..9e017c9ec --- /dev/null +++ b/spec/tokenized-line-spec.coffee @@ -0,0 +1,22 @@ +describe "TokenizedLine", -> + editor = null + + beforeEach -> + waitsForPromise -> atom.packages.activatePackage('language-coffee-script') + + describe "::getScopeTree()", -> + it "returns a tree whose inner nodes are scopes and whose leaf nodes are tokens in those scopes", -> + editor = atom.project.openSync('coffee.coffee') + + ensureValidScopeTree = (scopeTree, scopes=[]) -> + if scopeTree.children? + for child in scopeTree.children + ensureValidScopeTree(child, scopes.concat([scopeTree.scope])) + else + expect(scopeTree).toBe tokens[tokenIndex++] + expect(scopes).toEqual scopeTree.scopes + + tokenIndex = 0 + tokens = editor.lineForScreenRow(1).tokens + scopeTree = editor.lineForScreenRow(1).getScopeTree() + ensureValidScopeTree(scopeTree) diff --git a/src/atom.coffee b/src/atom.coffee index 6a096a22f..cae8eb240 100644 --- a/src/atom.coffee +++ b/src/atom.coffee @@ -157,6 +157,9 @@ class Atom extends Model # Still set NODE_PATH since tasks may need it. process.env.NODE_PATH = exportsPath + # Make react.js faster + process.env.NODE_ENV ?= 'production' + @config = new Config({configDirPath, resourcePath}) @keymaps = new KeymapManager({configDirPath, resourcePath}) @keymap = @keymaps # Deprecated diff --git a/src/browser/atom-application.coffee b/src/browser/atom-application.coffee index 01ca799b7..99b739ae5 100644 --- a/src/browser/atom-application.coffee +++ b/src/browser/atom-application.coffee @@ -311,6 +311,7 @@ class AtomApplication if existingWindow openedWindow = existingWindow openedWindow.openPath(pathToOpen, initialLine) + openedWindow.restore() else if devMode try diff --git a/src/browser/atom-window.coffee b/src/browser/atom-window.coffee index 744ae9a50..f65e66db1 100644 --- a/src/browser/atom-window.coffee +++ b/src/browser/atom-window.coffee @@ -151,6 +151,8 @@ class AtomWindow maximize: -> @browserWindow.maximize() + restore: -> @browserWindow.restore() + handlesAtomCommands: -> not @isSpecWindow() and @isWebViewFocused() diff --git a/src/coffee-cache.coffee b/src/coffee-cache.coffee index 1cb979286..0da37b1bd 100644 --- a/src/coffee-cache.coffee +++ b/src/coffee-cache.coffee @@ -5,7 +5,7 @@ CoffeeScript = require 'coffee-script' CSON = require 'season' fs = require 'fs-plus' -cacheDir = path.join(fs.getHomeDirectory(), 'compile-cache') +cacheDir = path.join(fs.absolute('~/.atom'), 'compile-cache') coffeeCacheDir = path.join(cacheDir, 'coffee') CSON.setCacheDir(path.join(cacheDir, 'cson')) diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee new file mode 100644 index 000000000..fcc6c2022 --- /dev/null +++ b/src/cursor-component.coffee @@ -0,0 +1,13 @@ +React = require 'react' +{div} = require 'reactionary' + +module.exports = +CursorComponent = React.createClass + displayName: 'CursorComponent' + + render: -> + {top, left, height, width} = @props.cursor.getPixelRect() + className = 'cursor' + className += ' blink-off' if @props.blinkOff + + div className: className, style: {top, left, height, width} diff --git a/src/cursor.coffee b/src/cursor.coffee index 23209e854..786cee2c4 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -1,5 +1,5 @@ {Point, Range} = require 'text-buffer' -{Emitter} = require 'emissary' +{Model} = require 'theorist' _ = require 'underscore-plus' # Public: The `Cursor` class represents the little blinking line identifying @@ -8,9 +8,7 @@ _ = require 'underscore-plus' # Cursors belong to {Editor}s and have some metadata attached in the form # of a {Marker}. module.exports = -class Cursor - Emitter.includeInto(this) - +class Cursor extends Model screenPosition: null bufferPosition: null goalColumn: null @@ -18,7 +16,8 @@ class Cursor needsAutoscroll: null # Instantiated by an {Editor} - constructor: ({@editor, @marker}) -> + constructor: ({@editor, @marker, id}) -> + @assignId(id) @updateVisibility() @marker.on 'changed', (e) => @updateVisibility() @@ -27,7 +26,12 @@ class Cursor {textChanged} = e return if oldHeadScreenPosition.isEqual(newHeadScreenPosition) + # Supports old editor view @needsAutoscroll ?= @isLastCursor() and !textChanged + + # Supports react editor view + @autoscroll() if @needsAutoscroll and @editor.manageScrollPosition + @goalColumn = null movedEvent = @@ -38,7 +42,7 @@ class Cursor textChanged: textChanged @emit 'moved', movedEvent - @editor.emit 'cursor-moved', movedEvent + @editor.cursorMoved(movedEvent) @marker.on 'destroyed', => @destroyed = true @editor.removeCursor(this) @@ -54,6 +58,9 @@ class Cursor unless fn() @emit 'autoscrolled' if @needsAutoscroll + getPixelRect: -> + @editor.pixelRectForScreenRange(@getScreenRange()) + # Public: Moves a cursor to a given screen position. # # screenPosition - An {Array} of two numbers: the screen row, and the screen @@ -69,6 +76,10 @@ class Cursor getScreenPosition: -> @marker.getHeadScreenPosition() + getScreenRange: -> + {row, column} = @getScreenPosition() + new Range(new Point(row, column), new Point(row, column + 1)) + # Public: Moves a cursor to a given buffer position. # # bufferPosition - An {Array} of two numbers: the buffer row, and the buffer @@ -84,6 +95,9 @@ class Cursor getBufferPosition: -> @marker.getHeadBufferPosition() + autoscroll: -> + @editor.scrollToScreenRange(@getScreenRange()) + # Public: If the marker range is empty, the cursor is marked as being visible. updateVisibility: -> @setVisible(@marker.getBufferRange().isEmpty()) diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee new file mode 100644 index 000000000..e3143f1f1 --- /dev/null +++ b/src/cursors-component.coffee @@ -0,0 +1,50 @@ +React = require 'react' +{div} = require 'reactionary' +{debounce} = require 'underscore-plus' +SubscriberMixin = require './subscriber-mixin' +CursorComponent = require './cursor-component' + + +module.exports = +CursorsComponent = React.createClass + displayName: 'CursorsComponent' + mixins: [SubscriberMixin] + + cursorBlinkIntervalHandle: null + + render: -> + {editor} = @props + blinkOff = @state.blinkCursorsOff + + div className: 'cursors', + if @isMounted() + for selection in editor.getSelections() + if selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) + {cursor} = selection + CursorComponent({key: cursor.id, cursor, blinkOff}) + + getInitialState: -> + blinkCursorsOff: false + + componentDidMount: -> + {editor} = @props + @startBlinkingCursors() + + componentWillUnmount: -> + clearInterval(@cursorBlinkIntervalHandle) + + componentWillUpdate: ({cursorsMoved}) -> + @pauseCursorBlinking() if cursorsMoved + + startBlinkingCursors: -> + @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) + + startBlinkingCursorsAfterDelay: null # Created lazily + + toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) + + pauseCursorBlinking: -> + @state.blinkCursorsOff = false + clearInterval(@cursorBlinkIntervalHandle) + @startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay) + @startBlinkingCursorsAfterDelay() diff --git a/src/custom-event-mixin.coffee b/src/custom-event-mixin.coffee new file mode 100644 index 000000000..1a3bb4d88 --- /dev/null +++ b/src/custom-event-mixin.coffee @@ -0,0 +1,15 @@ +module.exports = +CustomEventMixin = + componentWillMount: -> + @customEventListeners = {} + + componentWillUnmount: -> + for name, listeners in @customEventListeners + for listener in listeners + @getDOMNode().removeEventListener(name, listener) + + addCustomEventListeners: (customEventListeners) -> + for name, listener of customEventListeners + @customEventListeners[name] ?= [] + @customEventListeners[name].push(listener) + @getDOMNode().addEventListener(name, listener) diff --git a/src/display-buffer-marker.coffee b/src/display-buffer-marker.coffee index e01b571a9..c3a902b99 100644 --- a/src/display-buffer-marker.coffee +++ b/src/display-buffer-marker.coffee @@ -54,6 +54,9 @@ class DisplayBufferMarker setBufferRange: (bufferRange, options) -> @bufferMarker.setRange(bufferRange, options) + getPixelRange: -> + @displayBuffer.pixelRangeForScreenRange(@getScreenRange(), false) + # Retrieves the screen position of the marker's head. # # Returns a {Point}. diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 19bbb334f..f590920c6 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -20,14 +20,25 @@ class DisplayBuffer extends Model Serializable.includeInto(this) @properties + manageScrollPosition: false softWrap: null editorWidthInChars: null + lineHeight: null + defaultCharWidth: null + height: null + width: null + scrollTop: 0 + scrollLeft: 0 + + verticalScrollMargin: 2 + horizontalScrollMargin: 6 constructor: ({tabLength, @editorWidthInChars, @tokenizedBuffer, buffer}={}) -> super @softWrap ?= atom.config.get('editor.softWrap') ? false @tokenizedBuffer ?= new TokenizedBuffer({tabLength, buffer}) @buffer = @tokenizedBuffer.buffer + @charWidthsByScope = {} @markers = {} @foldsByMarkerId = {} @updateAllScreenLines() @@ -51,6 +62,8 @@ class DisplayBuffer extends Model id: @id softWrap: @softWrap editorWidthInChars: @editorWidthInChars + scrollTop: @scrollTop + scrollLeft: @scrollLeft tokenizedBuffer: @tokenizedBuffer.serialize() deserializeParams: (params) -> @@ -59,6 +72,9 @@ class DisplayBuffer extends Model copy: -> newDisplayBuffer = new DisplayBuffer({@buffer, tabLength: @getTabLength()}) + newDisplayBuffer.setScrollTop(@getScrollTop()) + newDisplayBuffer.setScrollLeft(@getScrollLeft()) + for marker in @findMarkers(displayBufferId: @id) marker.copy(displayBufferId: newDisplayBuffer.id) newDisplayBuffer @@ -89,6 +105,151 @@ class DisplayBuffer extends Model # visible - A {Boolean} indicating of the tokenized buffer is shown setVisible: (visible) -> @tokenizedBuffer.setVisible(visible) + getVerticalScrollMargin: -> @verticalScrollMargin + setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin + + getHorizontalScrollMargin: -> @horizontalScrollMargin + setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin + + getHeight: -> @height ? @getScrollHeight() + setHeight: (@height) -> @height + + getWidth: -> @width ? @getScrollWidth() + setWidth: (newWidth) -> + oldWidth = @width + @width = newWidth + @updateWrappedScreenLines() if newWidth isnt oldWidth and @softWrap + @width + + getScrollTop: -> @scrollTop + setScrollTop: (scrollTop) -> + if @manageScrollPosition + @scrollTop = Math.max(0, Math.min(@getScrollHeight() - @getHeight(), scrollTop)) + else + @scrollTop = scrollTop + + getScrollBottom: -> @scrollTop + @height + setScrollBottom: (scrollBottom) -> + @setScrollTop(scrollBottom - @height) + @getScrollBottom() + + getScrollLeft: -> @scrollLeft + setScrollLeft: (scrollLeft) -> + if @manageScrollPosition + @scrollLeft = Math.max(0, Math.min(@getScrollWidth() - @getWidth(), scrollLeft)) + else + @scrollLeft = scrollLeft + + getScrollRight: -> @scrollLeft + @width + setScrollRight: (scrollRight) -> + @setScrollLeft(scrollRight - @width) + @getScrollRight() + + getLineHeight: -> @lineHeight + setLineHeight: (@lineHeight) -> @lineHeight + + getDefaultCharWidth: -> @defaultCharWidth + setDefaultCharWidth: (@defaultCharWidth) -> @defaultCharWidth + + getScopedCharWidth: (scopeNames, char) -> + @getScopedCharWidths(scopeNames)[char] + + getScopedCharWidths: (scopeNames) -> + scope = @charWidthsByScope + for scopeName in scopeNames + scope[scopeName] ?= {} + scope = scope[scopeName] + scope.charWidths ?= {} + scope.charWidths + + setScopedCharWidth: (scopeNames, char, width) -> + @getScopedCharWidths(scopeNames)[char] = width + + setScopedCharWidths: (scopeNames, charWidths) -> + _.extend(@getScopedCharWidths(scopeNames), charWidths) + + clearScopedCharWidths: -> + @charWidthsByScope = {} + + getScrollHeight: -> + unless @getLineHeight() > 0 + throw new Error("You must assign lineHeight before calling ::getScrollHeight()") + + @getLineCount() * @getLineHeight() + + getScrollWidth: -> + @getMaxLineLength() * @getDefaultCharWidth() + + getVisibleRowRange: -> + unless @getLineHeight() > 0 + throw new Error("You must assign a non-zero lineHeight before calling ::getVisibleRowRange()") + + heightInLines = Math.ceil(@getHeight() / @getLineHeight()) + 1 + startRow = Math.floor(@getScrollTop() / @getLineHeight()) + endRow = Math.min(@getLineCount(), Math.ceil(startRow + heightInLines)) + [startRow, endRow] + + intersectsVisibleRowRange: (startRow, endRow) -> + [visibleStart, visibleEnd] = @getVisibleRowRange() + not (endRow <= visibleStart or visibleEnd <= startRow) + + selectionIntersectsVisibleRowRange: (selection) -> + {start, end} = selection.getScreenRange() + @intersectsVisibleRowRange(start.row, end.row + 1) + + scrollToScreenRange: (screenRange) -> + verticalScrollMarginInPixels = @getVerticalScrollMargin() * @getLineHeight() + horizontalScrollMarginInPixels = @getHorizontalScrollMargin() * @getDefaultCharWidth() + + {top, left, height, width} = @pixelRectForScreenRange(screenRange) + bottom = top + height + right = left + width + desiredScrollTop = top - verticalScrollMarginInPixels + desiredScrollBottom = bottom + verticalScrollMarginInPixels + desiredScrollLeft = left - horizontalScrollMarginInPixels + desiredScrollRight = right + horizontalScrollMarginInPixels + + if desiredScrollTop < @getScrollTop() + @setScrollTop(desiredScrollTop) + else if desiredScrollBottom > @getScrollBottom() + @setScrollBottom(desiredScrollBottom) + + if desiredScrollLeft < @getScrollLeft() + @setScrollLeft(desiredScrollLeft) + else if desiredScrollRight > @getScrollRight() + @setScrollRight(desiredScrollRight) + + scrollToScreenPosition: (screenPosition) -> + @scrollToScreenRange(new Range(screenPosition, screenPosition)) + + scrollToBufferPosition: (bufferPosition) -> + @scrollToScreenPosition(@screenPositionForBufferPosition(bufferPosition)) + + pixelRectForScreenRange: (screenRange) -> + if screenRange.end.row > screenRange.start.row + top = @pixelPositionForScreenPosition(screenRange.start).top + left = 0 + height = (screenRange.end.row - screenRange.start.row + 1) * @getLineHeight() + width = @getScrollWidth() + else + {top, left} = @pixelPositionForScreenPosition(screenRange.start) + height = @getLineHeight() + width = @pixelPositionForScreenPosition(screenRange.end).left - left + + {top, left, width, height} + + # Retrieves the current tab length. + # + # Returns a {Number}. + getTabLength: -> + @tokenizedBuffer.getTabLength() + + # Specifies the tab length. + # + # tabLength - A {Number} that defines the new tab length. + setTabLength: (tabLength) -> + @tokenizedBuffer.setTabLength(tabLength) + # Deprecated: Use the softWrap property directly setSoftWrap: (@softWrap) -> @softWrap @@ -105,12 +266,19 @@ class DisplayBuffer extends Model if editorWidthInChars isnt previousWidthInChars and @softWrap @updateWrappedScreenLines() - getSoftWrapColumn: -> - if atom.config.get('editor.softWrapAtPreferredLineLength') - Math.min(@editorWidthInChars, atom.config.getPositiveInt('editor.preferredLineLength', @editorWidthInChars)) + getEditorWidthInChars: -> + width = @getWidth() + if width? and @defaultCharWidth > 0 + Math.floor(width / @defaultCharWidth) else @editorWidthInChars + getSoftWrapColumn: -> + if atom.config.get('editor.softWrapAtPreferredLineLength') + Math.min(@getEditorWidthInChars(), atom.config.getPositiveInt('editor.preferredLineLength', @getEditorWidthInChars())) + else + @getEditorWidthInChars() + # Gets the screen line for the given screen row. # # screenRow - A {Number} indicating the screen row. @@ -134,6 +302,9 @@ class DisplayBuffer extends Model getLines: -> new Array(@screenLines...) + indentLevelForLine: (line) -> + @tokenizedBuffer.indentLevelForLine(line) + # Given starting and ending screen rows, this returns an array of the # buffer rows corresponding to every screen row in the range # @@ -273,6 +444,52 @@ class DisplayBuffer extends Model end = @bufferPositionForScreenPosition(screenRange.end) new Range(start, end) + pixelRangeForScreenRange: (screenRange, clip=true) -> + {start, end} = Range.fromObject(screenRange) + {start: @pixelPositionForScreenPosition(start, clip), end: @pixelPositionForScreenPosition(end, clip)} + + pixelPositionForScreenPosition: (screenPosition, clip=true) -> + screenPosition = Point.fromObject(screenPosition) + screenPosition = @clipScreenPosition(screenPosition) if clip + + targetRow = screenPosition.row + targetColumn = screenPosition.column + defaultCharWidth = @defaultCharWidth + + top = targetRow * @lineHeight + left = 0 + column = 0 + for token in @lineForRow(targetRow).tokens + charWidths = @getScopedCharWidths(token.scopes) + for char in token.value + return {top, left} if column is targetColumn + left += charWidths[char] ? defaultCharWidth + column++ + {top, left} + + screenPositionForPixelPosition: (pixelPosition) -> + targetTop = pixelPosition.top + targetLeft = pixelPosition.left + defaultCharWidth = @defaultCharWidth + row = Math.floor(targetTop / @getLineHeight()) + row = Math.min(row, @getLastRow()) + row = Math.max(0, row) + + left = 0 + column = 0 + for token in @lineForRow(row).tokens + charWidths = @getScopedCharWidths(token.scopes) + for char in token.value + charWidth = charWidths[char] ? defaultCharWidth + break if targetLeft <= left + (charWidth / 2) + left += charWidth + column++ + + new Point(row, column) + + pixelPositionForBufferPosition: (bufferPosition) -> + @pixelPositionForScreenPosition(@screenPositionForBufferPosition(bufferPosition)) + # Gets the number of screen lines. # # Returns a {Number}. @@ -358,18 +575,6 @@ class DisplayBuffer extends Model tokenForBufferPosition: (bufferPosition) -> @tokenizedBuffer.tokenForPosition(bufferPosition) - # Retrieves the current tab length. - # - # Returns a {Number}. - getTabLength: -> - @tokenizedBuffer.getTabLength() - - # Specifies the tab length. - # - # tabLength - A {Number} that defines the new tab length. - setTabLength: (tabLength) -> - @tokenizedBuffer.setTabLength(tabLength) - # Get the grammar for this buffer. # # Returns the current {Grammar} or the {NullGrammar}. diff --git a/src/editor-component.coffee b/src/editor-component.coffee new file mode 100644 index 000000000..c3bde3d71 --- /dev/null +++ b/src/editor-component.coffee @@ -0,0 +1,338 @@ +React = require 'react' +{div, span} = require 'reactionary' +{debounce} = require 'underscore-plus' + +GutterComponent = require './gutter-component' +EditorScrollViewComponent = require './editor-scroll-view-component' +ScrollbarComponent = require './scrollbar-component' +SubscriberMixin = require './subscriber-mixin' + +module.exports = +EditorComponent = React.createClass + displayName: 'EditorComponent' + mixins: [SubscriberMixin] + + pendingScrollTop: null + pendingScrollLeft: null + selectOnMouseMove: false + batchingUpdates: false + updateRequested: false + cursorsMoved: false + preservedRowRange: null + scrollingVertically: false + + render: -> + {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state + {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props + if @isMounted() + renderedRowRange = @getRenderedRowRange() + scrollHeight = editor.getScrollHeight() + scrollWidth = editor.getScrollWidth() + scrollTop = editor.getScrollTop() + scrollLeft = editor.getScrollLeft() + lineHeightInPixels = editor.getLineHeight() + + className = 'editor editor-colors react' + className += ' is-focused' if focused + + div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, + GutterComponent { + editor, renderedRowRange, scrollTop, scrollHeight, + lineHeight: lineHeightInPixels, @pendingChanges + } + + EditorScrollViewComponent { + ref: 'scrollView', editor, fontSize, fontFamily, showIndentGuide + scrollHeight, scrollWidth, lineHeight: lineHeightInPixels, + renderedRowRange, @pendingChanges, @scrollingVertically, @cursorsMoved, + cursorBlinkPeriod, cursorBlinkResumeDelay, @onInputFocused, @onInputBlurred + } + + ScrollbarComponent + ref: 'verticalScrollbar' + className: 'vertical-scrollbar' + orientation: 'vertical' + onScroll: @onVerticalScroll + scrollTop: scrollTop + scrollHeight: scrollHeight + + ScrollbarComponent + ref: 'horizontalScrollbar' + className: 'horizontal-scrollbar' + orientation: 'horizontal' + onScroll: @onHorizontalScroll + scrollLeft: scrollLeft + scrollWidth: scrollWidth + + getRenderedRowRange: -> + renderedRowRange = @props.editor.getVisibleRowRange() + if @preservedRowRange? + renderedRowRange[0] = Math.min(@preservedRowRange[0], renderedRowRange[0]) + renderedRowRange[1] = Math.max(@preservedRowRange[1], renderedRowRange[1]) + renderedRowRange + + getInitialState: -> {} + + getDefaultProps: -> + cursorBlinkPeriod: 800 + cursorBlinkResumeDelay: 200 + + componentWillMount: -> + @pendingChanges = [] + @props.editor.manageScrollPosition = true + @observeConfig() + + componentDidMount: -> + @observeEditor() + @listenForDOMEvents() + @listenForCommands() + @props.editor.setVisible(true) + @requestUpdate() + + componentWillUnmount: -> + @unsubscribe() + @getDOMNode().removeEventListener 'mousewheel', @onMouseWheel + + componentWillUpdate: -> + @props.parentView.trigger 'cursor:moved' if @cursorsMoved + + componentDidUpdate: -> + @pendingChanges.length = 0 + @cursorsMoved = false + @props.parentView.trigger 'editor:display-updated' + + observeEditor: -> + {editor} = @props + @subscribe editor, 'batched-updates-started', @onBatchedUpdatesStarted + @subscribe editor, 'batched-updates-ended', @onBatchedUpdatesEnded + @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged + @subscribe editor, 'cursors-moved', @onCursorsMoved + @subscribe editor, 'selection-screen-range-changed', @requestUpdate + @subscribe editor, 'selection-added', @onSelectionAdded + @subscribe editor, 'selection-removed', @onSelectionAdded + @subscribe editor.$scrollTop.changes, @onScrollTopChanged + @subscribe editor.$scrollLeft.changes, @requestUpdate + @subscribe editor.$height.changes, @requestUpdate + @subscribe editor.$width.changes, @requestUpdate + @subscribe editor.$defaultCharWidth.changes, @requestUpdate + @subscribe editor.$lineHeight.changes, @requestUpdate + + listenForDOMEvents: -> + node = @getDOMNode() + node.addEventListener 'mousewheel', @onMouseWheel + node.addEventListener 'focus', @onFocus # For some reason, React's built in focus events seem to bubble + + listenForCommands: -> + {parentView, editor, mini} = @props + + @addCommandListeners + 'core:move-left': => editor.moveCursorLeft() + 'core:move-right': => editor.moveCursorRight() + 'core:select-left': => editor.selectLeft() + 'core:select-right': => editor.selectRight() + 'core:select-all': => editor.selectAll() + 'core:backspace': => editor.backspace() + 'core:delete': => editor.delete() + 'core:undo': => editor.undo() + 'core:redo': => editor.redo() + 'core:cut': => editor.cutSelectedText() + 'core:copy': => editor.copySelectedText() + 'core:paste': => editor.pasteText() + 'editor:move-to-previous-word': => editor.moveCursorToPreviousWord() + 'editor:select-word': => editor.selectWord() + 'editor:consolidate-selections': @consolidateSelections + 'editor:backspace-to-beginning-of-word': => editor.backspaceToBeginningOfWord() + 'editor:backspace-to-beginning-of-line': => editor.backspaceToBeginningOfLine() + 'editor:delete-to-end-of-word': => editor.deleteToEndOfWord() + 'editor:delete-line': => editor.deleteLine() + 'editor:cut-to-end-of-line': => editor.cutToEndOfLine() + 'editor:move-to-beginning-of-screen-line': => editor.moveCursorToBeginningOfScreenLine() + 'editor:move-to-beginning-of-line': => editor.moveCursorToBeginningOfLine() + 'editor:move-to-end-of-screen-line': => editor.moveCursorToEndOfScreenLine() + 'editor:move-to-end-of-line': => editor.moveCursorToEndOfLine() + 'editor:move-to-first-character-of-line': => editor.moveCursorToFirstCharacterOfLine() + 'editor:move-to-beginning-of-word': => editor.moveCursorToBeginningOfWord() + 'editor:move-to-end-of-word': => editor.moveCursorToEndOfWord() + 'editor:move-to-beginning-of-next-word': => editor.moveCursorToBeginningOfNextWord() + 'editor:move-to-previous-word-boundary': => editor.moveCursorToPreviousWordBoundary() + 'editor:move-to-next-word-boundary': => editor.moveCursorToNextWordBoundary() + 'editor:select-to-end-of-line': => editor.selectToEndOfLine() + 'editor:select-to-beginning-of-line': => editor.selectToBeginningOfLine() + 'editor:select-to-end-of-word': => editor.selectToEndOfWord() + 'editor:select-to-beginning-of-word': => editor.selectToBeginningOfWord() + 'editor:select-to-beginning-of-next-word': => editor.selectToBeginningOfNextWord() + 'editor:select-to-next-word-boundary': => editor.selectToNextWordBoundary() + 'editor:select-to-previous-word-boundary': => editor.selectToPreviousWordBoundary() + 'editor:select-to-first-character-of-line': => editor.selectToFirstCharacterOfLine() + 'editor:select-line': => editor.selectLine() + 'editor:transpose': => editor.transpose() + 'editor:upper-case': => editor.upperCase() + 'editor:lower-case': => editor.lowerCase() + + unless mini + @addCommandListeners + 'core:move-up': => editor.moveCursorUp() + 'core:move-down': => editor.moveCursorDown() + 'core:move-to-top': => editor.moveCursorToTop() + 'core:move-to-bottom': => editor.moveCursorToBottom() + 'core:select-up': => editor.selectUp() + 'core:select-down': => editor.selectDown() + 'core:select-to-top': => editor.selectToTop() + 'core:select-to-bottom': => editor.selectToBottom() + 'editor:indent': => editor.indent() + 'editor:auto-indent': => editor.autoIndentSelectedRows() + 'editor:indent-selected-rows': => editor.indentSelectedRows() + 'editor:outdent-selected-rows': => editor.outdentSelectedRows() + 'editor:newline': => editor.insertNewline() + 'editor:newline-below': => editor.insertNewlineBelow() + 'editor:newline-above': => editor.insertNewlineAbove() + 'editor:add-selection-below': => editor.addSelectionBelow() + 'editor:add-selection-above': => editor.addSelectionAbove() + 'editor:split-selections-into-lines': => editor.splitSelectionsIntoLines() + 'editor:toggle-soft-tabs': => editor.toggleSoftTabs() + 'editor:toggle-soft-wrap': => editor.toggleSoftWrap() + 'editor:fold-all': => editor.foldAll() + 'editor:unfold-all': => editor.unfoldAll() + 'editor:fold-current-row': => editor.foldCurrentRow() + 'editor:unfold-current-row': => editor.unfoldCurrentRow() + 'editor:fold-selection': => neditor.foldSelectedLines() + 'editor:fold-at-indent-level-1': => editor.foldAllAtIndentLevel(0) + 'editor:fold-at-indent-level-2': => editor.foldAllAtIndentLevel(1) + 'editor:fold-at-indent-level-3': => editor.foldAllAtIndentLevel(2) + 'editor:fold-at-indent-level-4': => editor.foldAllAtIndentLevel(3) + 'editor:fold-at-indent-level-5': => editor.foldAllAtIndentLevel(4) + 'editor:fold-at-indent-level-6': => editor.foldAllAtIndentLevel(5) + 'editor:fold-at-indent-level-7': => editor.foldAllAtIndentLevel(6) + 'editor:fold-at-indent-level-8': => editor.foldAllAtIndentLevel(7) + 'editor:fold-at-indent-level-9': => editor.foldAllAtIndentLevel(8) + 'editor:toggle-line-comments': => editor.toggleLineCommentsInSelection() + 'editor:log-cursor-scope': => editor.logCursorScope() + 'editor:checkout-head-revision': => editor.checkoutHead() + 'editor:copy-path': => editor.copyPathToClipboard() + 'editor:move-line-up': => editor.moveLineUp() + 'editor:move-line-down': => editor.moveLineDown() + 'editor:duplicate-lines': => editor.duplicateLines() + 'editor:join-lines': => editor.joinLines() + 'editor:toggle-indent-guide': => atom.config.toggle('editor.showIndentGuide') + 'editor:toggle-line-numbers': => atom.config.toggle('editor.showLineNumbers') + 'editor:scroll-to-cursor': => editor.scrollToCursorPosition() + 'core:page-up': => editor.pageUp() + 'core:page-down': => editor.pageDown() + + addCommandListeners: (listenersByCommandName) -> + {parentView} = @props + + for command, listener of listenersByCommandName + parentView.command command, listener + + observeConfig: -> + @subscribe atom.config.observe 'editor.fontFamily', @setFontFamily + @subscribe atom.config.observe 'editor.fontSize', @setFontSize + @subscribe atom.config.observe 'editor.showIndentGuide', @setShowIndentGuide + + setFontSize: (fontSize) -> + @setState({fontSize}) + + setLineHeight: (lineHeight) -> + @setState({lineHeight}) + + setFontFamily: (fontFamily) -> + @setState({fontFamily}) + + setShowIndentGuide: (showIndentGuide) -> + @setState({showIndentGuide}) + + onFocus: -> + @refs.scrollView.focus() + + onInputFocused: -> + @setState(focused: true) + + onInputBlurred: -> + @setState(focused: false) + + onVerticalScroll: (scrollTop) -> + {editor} = @props + + return if scrollTop is editor.getScrollTop() + + animationFramePending = @pendingScrollTop? + @pendingScrollTop = scrollTop + unless animationFramePending + requestAnimationFrame => + @props.editor.setScrollTop(@pendingScrollTop) + @pendingScrollTop = null + + onHorizontalScroll: (scrollLeft) -> + {editor} = @props + + return if scrollLeft is editor.getScrollLeft() + + animationFramePending = @pendingScrollLeft? + @pendingScrollLeft = scrollLeft + unless animationFramePending + requestAnimationFrame => + @props.editor.setScrollLeft(@pendingScrollLeft) + @pendingScrollLeft = null + + onMouseWheel: (event) -> + # Only scroll in one direction at a time + {wheelDeltaX, wheelDeltaY} = event + if Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY) + @refs.horizontalScrollbar.getDOMNode().scrollLeft -= wheelDeltaX + else + @refs.verticalScrollbar.getDOMNode().scrollTop -= wheelDeltaY + + event.preventDefault() + + clearPreservedRowRange: -> + @preservedRowRange = null + @scrollingVertically = false + @requestUpdate() + + clearPreservedRowRangeAfterDelay: null # Created lazily + + onBatchedUpdatesStarted: -> + @batchingUpdates = true + + onBatchedUpdatesEnded: -> + updateRequested = @updateRequested + @updateRequested = false + @batchingUpdates = false + if updateRequested + @requestUpdate() + + onScreenLinesChanged: (change) -> + {editor} = @props + @pendingChanges.push(change) + @requestUpdate() if editor.intersectsVisibleRowRange(change.start, change.end + 1) # TODO: Use closed-open intervals for change events + + onSelectionAdded: (selection) -> + {editor} = @props + @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) + + onScrollTopChanged: -> + @preservedRowRange = @getRenderedRowRange() + @scrollingVertically = true + @clearPreservedRowRangeAfterDelay ?= debounce(@clearPreservedRowRange, 200) + @clearPreservedRowRangeAfterDelay() + @requestUpdate() + + onSelectionRemoved: (selection) -> + {editor} = @props + @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) + + onCursorsMoved: -> + @cursorsMoved = true + + requestUpdate: -> + if @batchingUpdates + @updateRequested = true + else + @forceUpdate() + + measureHeightAndWidth: -> + @refs.scrollView.measureHeightAndWidth() + + consolidateSelections: (e) -> + e.abortKeyBinding() unless @props.editor.consolidateSelections() diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee new file mode 100644 index 000000000..516798b1c --- /dev/null +++ b/src/editor-scroll-view-component.coffee @@ -0,0 +1,198 @@ +React = require 'react' +{div} = require 'reactionary' +{debounce} = require 'underscore-plus' + +InputComponent = require './input-component' +LinesComponent = require './lines-component' +CursorsComponent = require './cursors-component' +SelectionsComponent = require './selections-component' + +module.exports = +EditorScrollViewComponent = React.createClass + displayName: 'EditorScrollViewComponent' + + measurementPending: false + overflowChangedEventsPaused: false + overflowChangedWhilePaused: false + + render: -> + {editor, fontSize, fontFamily, lineHeight, showIndentGuide} = @props + {scrollHeight, scrollWidth, renderedRowRange, pendingChanges, scrollingVertically} = @props + {cursorBlinkPeriod, cursorBlinkResumeDelay, cursorsMoved, onInputFocused, onInputBlurred} = @props + + if @isMounted() + inputStyle = @getHiddenInputPosition() + inputStyle.WebkitTransform = 'translateZ(0)' + + contentStyle = + height: scrollHeight + minWidth: scrollWidth + WebkitTransform: "translate3d(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px, 0)" + + div className: 'scroll-view', + InputComponent + ref: 'input' + className: 'hidden-input' + style: inputStyle + onInput: @onInput + onFocus: onInputFocused + onBlur: onInputBlurred + + div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown, + CursorsComponent({editor, cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay}) + LinesComponent { + ref: 'lines', editor, fontSize, fontFamily, lineHeight, showIndentGuide, + renderedRowRange, pendingChanges, scrollingVertically + } + div className: 'underlayer', + SelectionsComponent({editor}) + + componentDidMount: -> + @getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged + window.addEventListener('resize', @onWindowResize) + + @measureHeightAndWidth() + + componentDidUnmount: -> + window.removeEventListener('resize', @onWindowResize) + + componentDidUpdate: -> + @pauseOverflowChangedEvents() + + onOverflowChanged: -> + if @overflowChangedEventsPaused + @overflowChangedWhilePaused = true + else + @requestMeasurement() + + onWindowResize: -> + @requestMeasurement() + + pauseOverflowChangedEvents: -> + @overflowChangedEventsPaused = true + @resumeOverflowChangedEventsAfterDelay ?= debounce(@resumeOverflowChangedEvents, 500) + @resumeOverflowChangedEventsAfterDelay() + + resumeOverflowChangedEvents: -> + if @overflowChangedWhilePaused + @overflowChangedWhilePaused = false + @requestMeasurement() + + resumeOverflowChangedEventsAfterDelay: null + + requestMeasurement: -> + return if @measurementPending + + @measurementPending = true + requestAnimationFrame => + @measurementPending = false + @measureHeightAndWidth() + + onInput: (char, replaceLastCharacter) -> + {editor} = @props + + if replaceLastCharacter + editor.transact -> + editor.selectLeft() + editor.insertText(char) + else + editor.insertText(char) + + onMouseDown: (event) -> + {editor} = @props + {detail, shiftKey, metaKey} = event + screenPosition = @screenPositionForMouseEvent(event) + + if shiftKey + editor.selectToScreenPosition(screenPosition) + else if metaKey + editor.addCursorAtScreenPosition(screenPosition) + else + editor.setCursorScreenPosition(screenPosition) + switch detail + when 2 then editor.selectWord() + when 3 then editor.selectLine() + + @selectToMousePositionUntilMouseUp(event) + + selectToMousePositionUntilMouseUp: (event) -> + {editor} = @props + dragging = false + lastMousePosition = {} + + animationLoop = => + requestAnimationFrame => + if dragging + @selectToMousePosition(lastMousePosition) + animationLoop() + + onMouseMove = (event) -> + lastMousePosition.clientX = event.clientX + lastMousePosition.clientY = event.clientY + + # Start the animation loop when the mouse moves prior to a mouseup event + unless dragging + dragging = true + animationLoop() + + # Stop dragging when cursor enters dev tools because we can't detect mouseup + onMouseUp() if event.which is 0 + + onMouseUp = -> + dragging = false + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + editor.finalizeSelections() + + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + + selectToMousePosition: (event) -> + @props.editor.selectToScreenPosition(@screenPositionForMouseEvent(event)) + + screenPositionForMouseEvent: (event) -> + pixelPosition = @pixelPositionForMouseEvent(event) + @props.editor.screenPositionForPixelPosition(pixelPosition) + + pixelPositionForMouseEvent: (event) -> + {editor} = @props + {clientX, clientY} = event + + editorClientRect = @getDOMNode().getBoundingClientRect() + top = clientY - editorClientRect.top + editor.getScrollTop() + left = clientX - editorClientRect.left + editor.getScrollLeft() + {top, left} + + getHiddenInputPosition: -> + {editor} = @props + return {top: 0, left: 0} unless @isMounted() and editor.getCursor()? + + {top, left, height, width} = editor.getCursor().getPixelRect() + top = top - editor.getScrollTop() + top = Math.max(0, Math.min(editor.getHeight() - height, top)) + left = left - editor.getScrollLeft() + left = Math.max(0, Math.min(editor.getWidth() - width, left)) + + {top, left} + + # Measure explicitly-styled height and width and relay them to the model. If + # these values aren't explicitly styled, we assume the editor is unconstrained + # and use the scrollHeight / scrollWidth as its height and width in + # calculations. + measureHeightAndWidth: -> + return unless @isMounted() + + node = @getDOMNode() + computedStyle = getComputedStyle(node) + {editor} = @props + + unless computedStyle.height is '0px' + clientHeight = node.clientHeight + editor.setHeight(clientHeight) if clientHeight > 0 + + unless computedStyle.width is '0px' + clientWidth = node.clientWidth + editor.setWidth(clientWidth) if clientHeight > 0 + + focus: -> + @refs.input.focus() diff --git a/src/editor-view.coffee b/src/editor-view.coffee index 4e3f0bbde..e146ca1c3 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -1482,16 +1482,13 @@ class EditorView extends View html = @buildEmptyLineHtml(showIndentGuide, eolInvisibles, htmlEolInvisibles, indentation, editor, mini) line.push(html) if html else - firstNonWhitespacePosition = text.search(/\S/) firstTrailingWhitespacePosition = text.search(/\s*$/) lineIsWhitespaceOnly = firstTrailingWhitespacePosition is 0 position = 0 for token in tokens @updateScopeStack(line, scopeStack, token.scopes) - hasLeadingWhitespace = position < firstNonWhitespacePosition - hasTrailingWhitespace = position + token.value.length > firstTrailingWhitespacePosition - hasIndentGuide = not mini and showIndentGuide and (hasLeadingWhitespace or lineIsWhitespaceOnly) - line.push(token.getValueAsHtml({invisibles, hasLeadingWhitespace, hasTrailingWhitespace, hasIndentGuide})) + hasIndentGuide = not mini and showIndentGuide and token.hasLeadingWhitespace or (token.hasTrailingWhitespace and lineIsWhitespaceOnly) + line.push(token.getValueAsHtml({invisibles, hasIndentGuide})) position += token.value.length @popScope(line, scopeStack) while scopeStack.length > 0 diff --git a/src/editor.coffee b/src/editor.coffee index 830cfe270..701016b84 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -136,10 +136,6 @@ class Editor extends Model atom.deserializers.add(this) Delegator.includeInto(this) - @properties - scrollTop: 0 - scrollLeft: 0 - deserializing: false callDisplayBufferCreatedHook: false registerEditor: false @@ -153,6 +149,9 @@ class Editor extends Model 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', toProperty: 'languageMode' + @delegatesProperties '$lineHeight', '$defaultCharWidth', '$height', '$width', + '$scrollTop', '$scrollLeft', 'manageScrollPosition', toProperty: 'displayBuffer' + constructor: ({@softTabs, initialLine, tabLength, softWrap, @displayBuffer, buffer, registerEditor, suppressCursorCreation}) -> super @@ -217,7 +216,10 @@ class Editor extends Model @subscribe @displayBuffer, 'soft-wrap-changed', (args...) => @emit 'soft-wrap-changed', args... getViewClass: -> - require './editor-view' + if atom.config.get('core.useReactEditor') + require './react-editor-view' + else + require './editor-view' destroyed: -> @unsubscribe() @@ -232,8 +234,6 @@ class Editor extends Model displayBuffer = @displayBuffer.copy() softTabs = @getSoftTabs() newEditor = new Editor({@buffer, displayBuffer, tabLength, softTabs, suppressCursorCreation: true, registerEditor: true}) - newEditor.setScrollTop(@getScrollTop()) - newEditor.setScrollLeft(@getScrollLeft()) for marker in @findMarkers(editorId: @id) marker.copy(editorId: newEditor.id, preserveFolds: true) newEditor @@ -269,18 +269,6 @@ class Editor extends Model # Controls visiblity based on the given {Boolean}. setVisible: (visible) -> @displayBuffer.setVisible(visible) - # Called by {EditorView} when the scroll position changes so it can be - # persisted across reloads. - setScrollTop: (@scrollTop) -> @scrollTop - - getScrollTop: -> @scrollTop - - # Called by {EditorView} when the scroll position changes so it can be - # persisted across reloads. - setScrollLeft: (@scrollLeft) -> @scrollLeft - - getScrollLeft: -> @scrollLeft - # Set the number of characters that can be displayed horizontally in the # editor. # @@ -301,6 +289,9 @@ class Editor extends Model # softTabs - A {Boolean} setSoftTabs: (@softTabs) -> @softTabs + # Public: Toggle soft tabs for this editor + toggleSoftTabs: -> @setSoftTabs(not @getSoftTabs()) + # Public: Get whether soft wrap is enabled for this editor. getSoftWrap: -> @displayBuffer.getSoftWrap() @@ -309,6 +300,9 @@ class Editor extends Model # softWrap - A {Boolean} setSoftWrap: (softWrap) -> @displayBuffer.setSoftWrap(softWrap) + # Public: Toggle soft wrap for this editor + toggleSoftWrap: -> @setSoftWrap(not @getSoftWrap()) + # Public: Get the text representing a single level of indent. # # If soft tabs are enabled, the text is composed of N spaces, where N is the @@ -394,13 +388,7 @@ class Editor extends Model # # Returns a {Number}. indentLevelForLine: (line) -> - if match = line.match(/^[\t ]+/) - leadingWhitespace = match[0] - tabCount = leadingWhitespace.match(/\t/g)?.length ? 0 - spaceCount = leadingWhitespace.match(/[ ]/g)?.length ? 0 - tabCount + (spaceCount / @getTabLength()) - else - 0 + @displayBuffer.indentLevelForLine(line) # Constructs the string used for tabs. buildIndentString: (number) -> @@ -421,6 +409,15 @@ class Editor extends Model # filePath - A {String} path. saveAs: (filePath) -> @buffer.saveAs(filePath) + checkoutHead: -> + if path = @getPath() + atom.project.getRepo()?.checkoutHead(path) + + # Copies the current file path to the native clipboard. + copyPathToClipboard: -> + path = @getPath() + atom.clipboard.write(path) if path? + # Public: Returns the {String} path of this editor's text buffer. getPath: -> @buffer.getPath() @@ -599,6 +596,9 @@ class Editor extends Model # Returns an {Array} of {String}s. getCursorScopes: -> @getCursor().getScopes() + logCursorScope: -> + console.log @getCursorScopes() + # Public: For each selection, replace the selected text with the given text. # # text - A {String} representing the text to insert. @@ -1178,6 +1178,16 @@ class Editor extends Model setSelectedBufferRange: (bufferRange, options) -> @setSelectedBufferRanges([bufferRange], options) + # Public: Set the selected range in screen coordinates. If there are multiple + # selections, they are reduced to a single selection with the given range. + # + # screenRange - A {Range} or range-compatible {Array}. + # options - An options {Object}: + # :reversed - A {Boolean} indicating whether to create the selection in a + # reversed orientation. + setSelectedScreenRange: (screenRange, options) -> + @setSelectedBufferRange(@bufferRangeForScreenRange(screenRange, options), options) + # Public: Set the selected ranges in buffer coordinates. If there are multiple # selections, they are replaced by new selections with the given ranges. # @@ -1202,6 +1212,7 @@ class Editor extends Model # Remove the given selection. removeSelection: (selection) -> _.remove(@selections, selection) + @emit 'selection-removed', selection # Reduce one or more selections to a single empty selection based on the most # recently added cursor. @@ -1218,6 +1229,9 @@ class Editor extends Model else false + selectionScreenRangeChanged: (selection) -> + @emit 'selection-screen-range-changed', selection + # Public: Get current {Selection}s. # # Returns: An {Array} of {Selection}s. @@ -1328,6 +1342,14 @@ class Editor extends Model getSelectedBufferRanges: -> selection.getBufferRange() for selection in @getSelectionsOrderedByBufferPosition() + # Public: Get the {Range}s of all selections in screen coordinates. + # + # The ranges are sorted by their position in the buffer. + # + # Returns an {Array} of {Range}s. + getSelectedScreenRanges: -> + selection.getScreenRange() for selection in @getSelectionsOrderedByBufferPosition() + # Public: Get the selected text of the most recently added selection. # # Returns a {String}. @@ -1431,9 +1453,26 @@ class Editor extends Model moveCursorToNextWordBoundary: -> @moveCursors (cursor) -> cursor.moveToNextWordBoundary() + scrollToCursorPosition: -> + @getCursor().autoscroll() + + pageUp: -> + @setScrollTop(@getScrollTop() - @getHeight()) + + pageDown: -> + @setScrollTop(@getScrollTop() + @getHeight()) + moveCursors: (fn) -> - fn(cursor) for cursor in @getCursors() - @mergeCursors() + @movingCursors = true + @batchUpdates => + fn(cursor) for cursor in @getCursors() + @mergeCursors() + @movingCursors = false + @emit 'cursors-moved' + + cursorMoved: (event) -> + @emit 'cursor-moved', event + @emit 'cursors-moved' unless @movingCursors # Public: Select from the current cursor position to the given position in # screen coordinates. @@ -1735,7 +1774,9 @@ class Editor extends Model # execution and revert any changes performed up to the abortion. # # fn - A {Function} to call inside the transaction. - transact: (fn) -> @buffer.transact(fn) + transact: (fn) -> + @batchUpdates => + @buffer.transact(fn) # Public: Start an open-ended transaction. # @@ -1755,6 +1796,12 @@ class Editor extends Model # within the transaction. abortTransaction: -> @buffer.abortTransaction() + batchUpdates: (fn) -> + @emit 'batched-updates-started' + result = fn() + @emit 'batched-updates-ended' + result + inspect: -> "" @@ -1771,6 +1818,66 @@ class Editor extends Model getSelectionMarkerAttributes: -> type: 'selection', editorId: @id, invalidate: 'never' + getVerticalScrollMargin: -> @displayBuffer.getVerticalScrollMargin() + setVerticalScrollMargin: (verticalScrollMargin) -> @displayBuffer.setVerticalScrollMargin(verticalScrollMargin) + + getHorizontalScrollMargin: -> @displayBuffer.getHorizontalScrollMargin() + setHorizontalScrollMargin: (horizontalScrollMargin) -> @displayBuffer.setHorizontalScrollMargin(horizontalScrollMargin) + + getLineHeight: -> @displayBuffer.getLineHeight() + setLineHeight: (lineHeight) -> @displayBuffer.setLineHeight(lineHeight) + + getScopedCharWidth: (scopeNames, char) -> @displayBuffer.getScopedCharWidth(scopeNames, char) + setScopedCharWidth: (scopeNames, char, width) -> @displayBuffer.setScopedCharWidth(scopeNames, char, width) + + getScopedCharWidths: (scopeNames) -> @displayBuffer.getScopedCharWidths(scopeNames) + + clearScopedCharWidths: -> @displayBuffer.clearScopedCharWidths() + + getDefaultCharWidth: -> @displayBuffer.getDefaultCharWidth() + setDefaultCharWidth: (defaultCharWidth) -> @displayBuffer.setDefaultCharWidth(defaultCharWidth) + + setHeight: (height) -> @displayBuffer.setHeight(height) + getHeight: -> @displayBuffer.getHeight() + + setWidth: (width) -> @displayBuffer.setWidth(width) + getWidth: -> @displayBuffer.getWidth() + + getScrollTop: -> @displayBuffer.getScrollTop() + setScrollTop: (scrollTop) -> @displayBuffer.setScrollTop(scrollTop) + + getScrollBottom: -> @displayBuffer.getScrollBottom() + setScrollBottom: (scrollBottom) -> @displayBuffer.setScrollBottom(scrollBottom) + + getScrollLeft: -> @displayBuffer.getScrollLeft() + setScrollLeft: (scrollLeft) -> @displayBuffer.setScrollLeft(scrollLeft) + + getScrollRight: -> @displayBuffer.getScrollRight() + setScrollRight: (scrollRight) -> @displayBuffer.setScrollRight(scrollRight) + + getScrollHeight: -> @displayBuffer.getScrollHeight() + getScrollWidth: (scrollWidth) -> @displayBuffer.getScrollWidth(scrollWidth) + + getVisibleRowRange: -> @displayBuffer.getVisibleRowRange() + + intersectsVisibleRowRange: (startRow, endRow) -> @displayBuffer.intersectsVisibleRowRange(startRow, endRow) + + selectionIntersectsVisibleRowRange: (selection) -> @displayBuffer.selectionIntersectsVisibleRowRange(selection) + + pixelPositionForScreenPosition: (screenPosition) -> @displayBuffer.pixelPositionForScreenPosition(screenPosition) + + pixelPositionForBufferPosition: (bufferPosition) -> @displayBuffer.pixelPositionForBufferPosition(bufferPosition) + + screenPositionForPixelPosition: (pixelPosition) -> @displayBuffer.screenPositionForPixelPosition(pixelPosition) + + pixelRectForScreenRange: (screenRange) -> @displayBuffer.pixelRectForScreenRange(screenRange) + + scrollToScreenRange: (screenRange) -> @displayBuffer.scrollToScreenRange(screenRange) + + scrollToScreenPosition: (screenPosition) -> @displayBuffer.scrollToScreenPosition(screenPosition) + + scrollToBufferPosition: (bufferPosition) -> @displayBuffer.scrollToBufferPosition(bufferPosition) + # Deprecated: Call {::joinLines} instead. joinLine: -> deprecate("Use Editor::joinLines() instead") diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee new file mode 100644 index 000000000..948860180 --- /dev/null +++ b/src/gutter-component.coffee @@ -0,0 +1,79 @@ +React = require 'react' +{div} = require 'reactionary' +{isEqual, isEqualForProperties, multiplyString} = require 'underscore-plus' +SubscriberMixin = require './subscriber-mixin' + +module.exports = +GutterComponent = React.createClass + displayName: 'GutterComponent' + mixins: [SubscriberMixin] + + render: -> + div className: 'gutter', + @renderLineNumbers() if @isMounted() + + renderLineNumbers: -> + {editor, renderedRowRange, scrollTop, scrollHeight} = @props + [startRow, endRow] = renderedRowRange + charWidth = editor.getDefaultCharWidth() + lineHeight = editor.getLineHeight() + maxDigits = editor.getLastBufferRow().toString().length + style = + width: charWidth * (maxDigits + 1.5) + height: scrollHeight + WebkitTransform: "translate3d(0, #{-scrollTop}px, 0)" + + lineNumbers = [] + tokenizedLines = editor.linesForScreenRows(startRow, endRow - 1) + tokenizedLines.push({id: 0}) if tokenizedLines.length is 0 + for bufferRow, i in editor.bufferRowsForScreenRows(startRow, endRow - 1) + if bufferRow is lastBufferRow + lineNumber = '•' + else + lastBufferRow = bufferRow + lineNumber = (bufferRow + 1).toString() + + key = tokenizedLines[i].id + screenRow = startRow + i + lineNumbers.push(LineNumberComponent({key, lineNumber, maxDigits, bufferRow, screenRow, lineHeight})) + lastBufferRow = bufferRow + + div className: 'line-numbers', style: style, + lineNumbers + + # Only update the gutter if the visible row range has changed or if a + # non-zero-delta change to the screen lines has occurred within the current + # visible row range. + shouldComponentUpdate: (newProps) -> + return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'scrollTop', 'lineHeight') + + {renderedRowRange, pendingChanges} = newProps + for change in pendingChanges when change.screenDelta > 0 or change.bufferDelta > 0 + return true unless change.end <= renderedRowRange.start or renderedRowRange.end <= change.start + + false + +LineNumberComponent = React.createClass + displayName: 'LineNumberComponent' + + render: -> + {bufferRow, screenRow, lineHeight} = @props + div + className: "line-number line-number-#{bufferRow}" + style: {top: screenRow * lineHeight} + 'data-buffer-row': bufferRow + 'data-screen-row': screenRow + dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + + buildInnerHTML: -> + {lineNumber, maxDigits} = @props + if lineNumber.length < maxDigits + padding = multiplyString(' ', maxDigits - lineNumber.length) + padding + lineNumber + @iconDivHTML + else + lineNumber + @iconDivHTML + + iconDivHTML: '
' + + shouldComponentUpdate: (newProps) -> + not isEqualForProperties(newProps, @props, 'lineHeight', 'screenRow') diff --git a/src/input-component.coffee b/src/input-component.coffee new file mode 100644 index 000000000..d441c2bce --- /dev/null +++ b/src/input-component.coffee @@ -0,0 +1,51 @@ +punycode = require 'punycode' +{last, isEqual} = require 'underscore-plus' +React = require 'react' +{input} = require 'reactionary' + +module.exports = +InputComponent = React.createClass + displayName: 'InputComponent' + + render: -> + {className, style, onFocus, onBlur} = @props + + input {className, style, onFocus, onBlur} + + getInitialState: -> + {lastChar: ''} + + componentDidMount: -> + @getDOMNode().addEventListener 'input', @onInput + @getDOMNode().addEventListener 'compositionupdate', @onCompositionUpdate + + # Don't let text accumulate in the input forever, but avoid excessive reflows + componentDidUpdate: -> + if @lastValueLength > 500 and not @isPressAndHoldCharacter(@state.lastChar) + @getDOMNode().value = '' + @lastValueLength = 0 + + # This should actually consult the property lists in /System/Library/Input Methods/PressAndHold.app + isPressAndHoldCharacter: (char) -> + @state.lastChar.match /[aeiouAEIOU]/ + + shouldComponentUpdate: (newProps) -> + not isEqual(newProps.style, @props.style) + + onInput: (e) -> + e.stopPropagation() + valueCharCodes = punycode.ucs2.decode(@getDOMNode().value) + valueLength = valueCharCodes.length + replaceLastChar = valueLength is @lastValueLength + @lastValueLength = valueLength + lastChar = String.fromCharCode(last(valueCharCodes)) + @props.onInput?(lastChar, replaceLastChar) + + onFocus: -> + @props.onFocus?() + + onBlur: -> + @props.onBlur?() + + focus: -> + @getDOMNode().focus() diff --git a/src/less-compile-cache.coffee b/src/less-compile-cache.coffee index 315a0a165..442109e21 100644 --- a/src/less-compile-cache.coffee +++ b/src/less-compile-cache.coffee @@ -8,7 +8,7 @@ module.exports = class LessCompileCache Subscriber.includeInto(this) - @cacheDir: path.join(fs.getHomeDirectory(), 'compile-cache', 'less') + @cacheDir: path.join(atom.getConfigDirPath(), 'compile-cache', 'less') constructor: ({resourcePath, importPaths}) -> @lessSearchPaths = [ diff --git a/src/lines-component.coffee b/src/lines-component.coffee new file mode 100644 index 000000000..844670f32 --- /dev/null +++ b/src/lines-component.coffee @@ -0,0 +1,140 @@ +React = require 'react' +{div, span} = require 'reactionary' +{debounce, isEqual, isEqualForProperties, multiplyString} = require 'underscore-plus' +{$$} = require 'space-pen' + +DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] +AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} + +module.exports = +LinesComponent = React.createClass + displayName: 'LinesComponent' + + render: -> + if @isMounted() + {editor, renderedRowRange, lineHeight, showIndentGuide} = @props + [startRow, endRow] = renderedRowRange + + lines = + for tokenizedLine, i in editor.linesForScreenRows(startRow, endRow - 1) + LineComponent({key: tokenizedLine.id, tokenizedLine, showIndentGuide, lineHeight, screenRow: startRow + i}) + + div {className: 'lines'}, lines + + componentWillMount: -> + @measuredLines = new WeakSet + + componentDidMount: -> + @measureLineHeightAndCharWidth() + + shouldComponentUpdate: (newProps) -> + return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'fontSize', 'fontFamily', 'lineHeight', 'showIndentGuide') + + {renderedRowRange, pendingChanges} = newProps + for change in pendingChanges + return true unless change.end <= renderedRowRange.start or renderedRowRange.end <= change.start + + false + + componentDidUpdate: (prevProps) -> + @measureLineHeightAndCharWidth() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily', 'lineHeight') + @clearScopedCharWidths() unless isEqualForProperties(prevProps, @props, 'fontSize', 'fontFamily') + @measureCharactersInNewLines() unless @props.scrollingVertically + + measureLineHeightAndCharWidth: -> + node = @getDOMNode() + node.appendChild(DummyLineNode) + lineHeight = DummyLineNode.getBoundingClientRect().height + charWidth = DummyLineNode.firstChild.getBoundingClientRect().width + node.removeChild(DummyLineNode) + + {editor} = @props + editor.setLineHeight(lineHeight) + editor.setDefaultCharWidth(charWidth) + + measureCharactersInNewLines: -> + [visibleStartRow, visibleEndRow] = @props.renderedRowRange + node = @getDOMNode() + + for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1) + unless @measuredLines.has(tokenizedLine) + lineNode = node.children[i] + @measureCharactersInLine(tokenizedLine, lineNode) + + measureCharactersInLine: (tokenizedLine, lineNode) -> + {editor} = @props + rangeForMeasurement = null + iterator = null + charIndex = 0 + + for {value, scopes}, tokenIndex in tokenizedLine.tokens + charWidths = editor.getScopedCharWidths(scopes) + + for char in value + unless charWidths[char]? + unless textNode? + rangeForMeasurement ?= document.createRange() + iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) + textNode = iterator.nextNode() + textNodeIndex = 0 + nextTextNodeIndex = textNode.textContent.length + + while nextTextNodeIndex <= charIndex + textNode = iterator.nextNode() + textNodeIndex = nextTextNodeIndex + nextTextNodeIndex = textNodeIndex + textNode.textContent.length + + i = charIndex - textNodeIndex + rangeForMeasurement.setStart(textNode, i) + rangeForMeasurement.setEnd(textNode, i + 1) + charWidth = rangeForMeasurement.getBoundingClientRect().width + editor.setScopedCharWidth(scopes, char, charWidth) + + charIndex++ + + @measuredLines.add(tokenizedLine) + + clearScopedCharWidths: -> + @measuredLines.clear() + @props.editor.clearScopedCharWidths() + + +LineComponent = React.createClass + displayName: 'LineComponent' + + render: -> + {screenRow, lineHeight} = @props + + style = + top: screenRow * lineHeight + position: 'absolute' + + div className: 'line', style: style, 'data-screen-row': screenRow, dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + + buildInnerHTML: -> + if @props.tokenizedLine.text.length is 0 + @buildEmptyLineHTML() + else + @buildScopeTreeHTML(@props.tokenizedLine.getScopeTree()) + + buildEmptyLineHTML: -> + {showIndentGuide, tokenizedLine} = @props + {indentLevel, tabLength} = tokenizedLine + + if showIndentGuide and indentLevel > 0 + indentSpan = "#{multiplyString(' ', tabLength)}" + multiplyString(indentSpan, indentLevel + 1) + else + " " + + buildScopeTreeHTML: (scopeTree) -> + if scopeTree.children? + html = "" + html += @buildScopeTreeHTML(child) for child in scopeTree.children + html += "" + html + else + "#{scopeTree.getValueAsHtml({hasIndentGuide: @props.showIndentGuide})}" + + shouldComponentUpdate: (newProps) -> + not isEqualForProperties(newProps, @props, 'showIndentGuide', 'lineHeight', 'screenRow') diff --git a/src/package.coffee b/src/package.coffee index 6ef7bc3b4..f04294ea3 100644 --- a/src/package.coffee +++ b/src/package.coffee @@ -290,6 +290,7 @@ class Package $(event.target).trigger(event) @restoreEventHandlersOnBubblePath(bubblePathEventHandlers) @unsubscribeFromActivationEvents() + false unsubscribeFromActivationEvents: -> return unless atom.workspaceView? diff --git a/src/react-editor-view.coffee b/src/react-editor-view.coffee new file mode 100644 index 000000000..9b91eb384 --- /dev/null +++ b/src/react-editor-view.coffee @@ -0,0 +1,81 @@ +{View, $} = require 'space-pen' +React = require 'react' +EditorComponent = require './editor-component' + +module.exports = +class ReactEditorView extends View + @content: -> @div class: 'editor react-wrapper' + + focusOnAttach: false + + constructor: (@editor) -> + super + + getEditor: -> @editor + + Object.defineProperty @::, 'lineHeight', get: -> @editor.getLineHeight() + Object.defineProperty @::, 'charWidth', get: -> @editor.getDefaultCharWidth() + + scrollTop: (scrollTop) -> + if scrollTop? + @editor.setScrollTop(scrollTop) + else + @editor.getScrollTop() + + scrollLeft: (scrollLeft) -> + if scrollLeft? + @editor.setScrollLeft(scrollLeft) + else + @editor.getScrollLeft() + + scrollToScreenPosition: (screenPosition) -> + @editor.scrollToScreenPosition(screenPosition) + + scrollToBufferPosition: (bufferPosition) -> + @editor.scrollToBufferPosition(bufferPosition) + + afterAttach: (onDom) -> + return unless onDom + @attached = true + @component = React.renderComponent(EditorComponent({@editor, parentView: this}), @element) + + node = @component.getDOMNode() + + @underlayer = $(node).find('.underlayer') + + @gutter = $(node).find('.gutter') + @gutter.removeClassFromAllLines = (klass) => + @gutter.find('.line-number').removeClass(klass) + + @gutter.addClassToLine = (bufferRow, klass) => + lines = @gutter.find(".line-number-#{bufferRow}") + lines.addClass(klass) + lines.length > 0 + + @focus() if @focusOnAttach + + @trigger 'editor:attached', [this] + + pixelPositionForBufferPosition: (bufferPosition) -> + @editor.pixelPositionForBufferPosition(bufferPosition) + + pixelPositionForScreenPosition: (screenPosition) -> + @editor.pixelPositionForScreenPosition(screenPosition) + + appendToLinesView: (view) -> + view.css('position', 'absolute') + @find('.scroll-view-content').prepend(view) + + beforeRemove: -> + React.unmountComponentAtNode(@element) + @attached = false + @trigger 'editor:detached', this + + getPane: -> + @closest('.pane').view() + + focus: -> + if @component? + @component.onFocus() + else + @focusOnAttach = true diff --git a/src/scrollbar-component.coffee b/src/scrollbar-component.coffee new file mode 100644 index 000000000..760127fb0 --- /dev/null +++ b/src/scrollbar-component.coffee @@ -0,0 +1,54 @@ +React = require 'react' +{div} = require 'reactionary' +{isEqualForProperties} = require 'underscore-plus' + +module.exports = +ScrollbarComponent = React.createClass + render: -> + {orientation, className, scrollHeight, scrollWidth} = @props + + div {className, @onScroll}, + switch orientation + when 'vertical' + div className: 'scrollbar-content', style: {height: scrollHeight} + when 'horizontal' + div className: 'scrollbar-content', style: {width: scrollWidth} + + componentDidMount: -> + {orientation} = @props + + unless orientation is 'vertical' or orientation is 'horizontal' + throw new Error("Must specify an orientation property of 'vertical' or 'horizontal'") + + shouldComponentUpdate: (newProps) -> + switch @props.orientation + when 'vertical' + not isEqualForProperties(newProps, @props, 'scrollHeight', 'scrollTop') + when 'horizontal' + not isEqualForProperties(newProps, @props, 'scrollWidth', 'scrollLeft') + + componentDidUpdate: -> + {orientation, scrollTop, scrollLeft} = @props + node = @getDOMNode() + + switch orientation + when 'vertical' + node.scrollTop = scrollTop + @props.scrollTop = node.scrollTop # Ensure scrollTop reflects actual DOM without triggering another update + when 'horizontal' + node.scrollLeft = scrollLeft + @props.scrollLeft = node.scrollLeft # Ensure scrollLeft reflects actual DOM without triggering another update + + onScroll: -> + {orientation, onScroll} = @props + node = @getDOMNode() + + switch orientation + when 'vertical' + scrollTop = node.scrollTop + @props.scrollTop = scrollTop # Ensure scrollTop reflects actual DOM without triggering another update + onScroll(scrollTop) + when 'horizontal' + scrollLeft = node.scrollLeft + @props.scrollLeft = scrollLeft # Ensure scrollLeft reflects actual DOM without triggering another update + onScroll(scrollLeft) diff --git a/src/selection-component.coffee b/src/selection-component.coffee new file mode 100644 index 000000000..e3dd22e3a --- /dev/null +++ b/src/selection-component.coffee @@ -0,0 +1,11 @@ +React = require 'react' +{div} = require 'reactionary' + +module.exports = +SelectionComponent = React.createClass + displayName: 'SelectionComponent' + + render: -> + div className: 'selection', + for regionRect, i in @props.selection.getRegionRects() + div className: 'region', key: i, style: regionRect diff --git a/src/selection.coffee b/src/selection.coffee index 029a5bbde..efaf25d7a 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -1,12 +1,10 @@ {Point, Range} = require 'text-buffer' -{Emitter} = require 'emissary' +{Model} = require 'theorist' {pick} = require 'underscore-plus' # Public: Represents a selection in the {Editor}. module.exports = -class Selection - Emitter.includeInto(this) - +class Selection extends Model cursor: null marker: null editor: null @@ -14,7 +12,8 @@ class Selection wordwise: false needsAutoscroll: null - constructor: ({@cursor, @marker, @editor}) -> + constructor: ({@cursor, @marker, @editor, id}) -> + @assignId(id) @cursor.selection = this @marker.on 'changed', => @screenRangeChanged() @marker.on 'destroyed', => @@ -77,8 +76,9 @@ class Selection options.reversed ?= @isReversed() @editor.destroyFoldsIntersectingBufferRange(bufferRange) unless options.preserveFolds @modifySelection => - @cursor.needsAutoscroll = false if options.autoscroll? + @cursor.needsAutoscroll = false if @needsAutoscroll? @marker.setBufferRange(bufferRange, options) + @autoscroll() if @needsAutoscroll and @editor.manageScrollPosition # Public: Returns the starting and ending buffer rows the selection is # highlighting. @@ -91,6 +91,9 @@ class Selection end = Math.max(start, end - 1) if range.end.column == 0 [start, end] + autoscroll: -> + @editor.scrollToScreenRange(@getScreenRange()) + # Public: Returns the text in the selection. getText: -> @editor.buffer.getTextInRange(@getBufferRange()) @@ -570,6 +573,48 @@ class Selection compare: (otherSelection) -> @getBufferRange().compare(otherSelection.getBufferRange()) + # Get the pixel dimensions of rectangular regions that cover selection's area + # on the screen. Used by SelectionComponent for rendering. + getRegionRects: -> + lineHeight = @editor.getLineHeight() + {start, end} = @getScreenRange() + rowCount = end.row - start.row + 1 + startPixelPosition = @editor.pixelPositionForScreenPosition(start) + endPixelPosition = @editor.pixelPositionForScreenPosition(end) + + if rowCount is 1 + # Single line selection + rects = [{ + top: startPixelPosition.top + height: lineHeight + left: startPixelPosition.left + width: endPixelPosition.left - startPixelPosition.left + }] + else + # Multi-line selection + rects = [] + + # First row, extending from selection start to the right side of screen + rects.push { + top: startPixelPosition.top + left: startPixelPosition.left + height: lineHeight + right: 0 + } + if rowCount > 2 + # Middle rows, extending from left side to right side of screen + rects.push { + top: startPixelPosition.top + lineHeight + height: (rowCount - 2) * lineHeight + left: 0 + right: 0 + } + # Last row, extending from left side of screen to selection end + rects.push {top: endPixelPosition.top, height: lineHeight, left: 0, width: endPixelPosition.left } + + rects + screenRangeChanged: -> screenRange = @getScreenRange() @emit 'screen-range-changed', screenRange + @editor.selectionScreenRangeChanged(this) diff --git a/src/selections-component.coffee b/src/selections-component.coffee new file mode 100644 index 000000000..616fc62dd --- /dev/null +++ b/src/selections-component.coffee @@ -0,0 +1,16 @@ +React = require 'react' +{div} = require 'reactionary' +SelectionComponent = require './selection-component' + +module.exports = +SelectionsComponent = React.createClass + displayName: 'SelectionsComponent' + + render: -> + {editor} = @props + + div className: 'selections', + if @isMounted() + for selection in editor.getSelections() + if not selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) + SelectionComponent({key: selection.id, selection}) diff --git a/src/space-pen-extensions.coffee b/src/space-pen-extensions.coffee index 3b6948597..14bf076f1 100644 --- a/src/space-pen-extensions.coffee +++ b/src/space-pen-extensions.coffee @@ -69,4 +69,6 @@ jQuery(document.body).on 'show.bs.tooltip', ({target}) -> jQuery.fn.setTooltip.getKeystroke = getKeystroke jQuery.fn.setTooltip.humanizeKeystrokes = humanizeKeystrokes +Object.defineProperty jQuery.fn, 'element', get: -> @[0] + module.exports = spacePen diff --git a/src/subscriber-mixin.coffee b/src/subscriber-mixin.coffee new file mode 100644 index 000000000..b6817ce53 --- /dev/null +++ b/src/subscriber-mixin.coffee @@ -0,0 +1,4 @@ +{Subscriber} = require 'emissary' +SubscriberMixin = componentDidUnmount: -> @unsubscribe() +Subscriber.extend(SubscriberMixin) +module.exports = SubscriberMixin diff --git a/src/token.coffee b/src/token.coffee index 366c6a394..5f1baab1d 100644 --- a/src/token.coffee +++ b/src/token.coffee @@ -20,6 +20,8 @@ class Token scopes: null isAtomic: null isHardTab: null + hasLeadingWhitespace: false + hasTrailingWhitespace: false constructor: ({@value, @scopes, @isAtomic, @bufferDelta, @isHardTab}) -> @screenDelta = @value.length @@ -40,7 +42,7 @@ class Token whitespaceRegexForTabLength: (tabLength) -> WhitespaceRegexesByTabLength[tabLength] ?= new RegExp("([ ]{#{tabLength}})|(\t)|([^\t]+)", "g") - breakOutAtomicTokens: (tabLength, breakOutLeadingWhitespace) -> + breakOutAtomicTokens: (tabLength, breakOutLeadingSoftTabs) -> if @hasSurrogatePair outputTokens = [] @@ -48,14 +50,14 @@ class Token if token.isAtomic outputTokens.push(token) else - outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingWhitespace)...) - breakOutLeadingWhitespace = token.isOnlyWhitespace() if breakOutLeadingWhitespace + outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingSoftTabs)...) + breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs outputTokens else return [this] if @isAtomic - if breakOutLeadingWhitespace + if breakOutLeadingSoftTabs return [this] unless /^[ ]|\t/.test(@value) else return [this] unless /\t/.test(@value) @@ -64,13 +66,13 @@ class Token regex = @whitespaceRegexForTabLength(tabLength) while match = regex.exec(@value) [fullMatch, softTab, hardTab] = match - if softTab and breakOutLeadingWhitespace - outputTokens.push(@buildSoftTabToken(tabLength, false)) + if softTab and breakOutLeadingSoftTabs + outputTokens.push(@buildSoftTabToken(tabLength)) else if hardTab - breakOutLeadingWhitespace = false - outputTokens.push(@buildHardTabToken(tabLength, true)) + breakOutLeadingSoftTabs = false + outputTokens.push(@buildHardTabToken(tabLength)) else - breakOutLeadingWhitespace = false + breakOutLeadingSoftTabs = false value = match[0] outputTokens.push(new Token({value, @scopes})) @@ -127,7 +129,7 @@ class Token scopeClasses = scope.split('.') _.isSubset(targetClasses, scopeClasses) - getValueAsHtml: ({invisibles, hasLeadingWhitespace, hasTrailingWhitespace, hasIndentGuide})-> + getValueAsHtml: ({invisibles, hasIndentGuide})-> invisibles ?= {} if @isHardTab classes = 'hard-tab' @@ -142,7 +144,7 @@ class Token leadingHtml = '' trailingHtml = '' - if hasLeadingWhitespace and match = LeadingWhitespaceRegex.exec(@value) + if @hasLeadingWhitespace and match = LeadingWhitespaceRegex.exec(@value) classes = 'leading-whitespace' classes += ' indent-guide' if hasIndentGuide classes += ' invisible-character' if invisibles.space @@ -152,9 +154,10 @@ class Token startIndex = match[0].length - if hasTrailingWhitespace and match = TrailingWhitespaceRegex.exec(@value) + if @hasTrailingWhitespace and match = TrailingWhitespaceRegex.exec(@value) + tokenIsOnlyWhitespace = match[0].length is @value.length classes = 'trailing-whitespace' - classes += ' indent-guide' if hasIndentGuide and not hasLeadingWhitespace + classes += ' indent-guide' if hasIndentGuide and not @hasLeadingWhitespace and tokenIsOnlyWhitespace classes += ' invisible-character' if invisibles.space match[0] = match[0].replace(CharacterRegex, invisibles.space) if invisibles.space diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee index 115cc8d79..6fc270f7a 100644 --- a/src/tokenized-buffer.coffee +++ b/src/tokenized-buffer.coffee @@ -185,14 +185,16 @@ class TokenizedBuffer extends Model line = @buffer.lineForRow(row) tokens = [new Token(value: line, scopes: [@grammar.scopeName])] tabLength = @getTabLength() - new TokenizedLine({tokens, tabLength}) + indentLevel = @indentLevelForRow(row) + new TokenizedLine({tokens, tabLength, indentLevel}) buildTokenizedTokenizedLineForRow: (row, ruleStack) -> line = @buffer.lineForRow(row) lineEnding = @buffer.lineEndingForRow(row) tabLength = @getTabLength() + indentLevel = @indentLevelForRow(row) { tokens, ruleStack } = @grammar.tokenizeLine(line, ruleStack, row is 0) - new TokenizedLine({tokens, ruleStack, tabLength, lineEnding}) + new TokenizedLine({tokens, ruleStack, tabLength, lineEnding, indentLevel}) # FIXME: benogle says: These are actually buffer rows as all buffer rows are # accounted for in @tokenizedLines @@ -207,6 +209,36 @@ class TokenizedBuffer extends Model stackForRow: (row) -> @tokenizedLines[row]?.ruleStack + indentLevelForRow: (row) -> + line = @buffer.lineForRow(row) + + if line is '' + nextRow = row + 1 + lineCount = @getLineCount() + while nextRow < lineCount + nextLine = @buffer.lineForRow(nextRow) + return @indentLevelForLine(nextLine) unless nextLine is '' + nextRow++ + + previousRow = row - 1 + while previousRow >= 0 + previousLine = @buffer.lineForRow(previousRow) + return @indentLevelForLine(previousLine) unless previousLine is '' + previousRow-- + + 0 + else + @indentLevelForLine(line) + + indentLevelForLine: (line) -> + if match = line.match(/^[\t ]+/) + leadingWhitespace = match[0] + tabCount = leadingWhitespace.match(/\t/g)?.length ? 0 + spaceCount = leadingWhitespace.match(/[ ]/g)?.length ? 0 + tabCount + (spaceCount / @getTabLength()) + else + 0 + scopesForPosition: (position) -> @tokenForPosition(position).scopes @@ -306,6 +338,9 @@ class TokenizedBuffer extends Model getLastRow: -> @buffer.getLastRow() + getLineCount: -> + @buffer.getLineCount() + logLines: (start=0, end=@buffer.getLastRow()) -> for row in [start..end] line = @lineForScreenRow(row).text diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index 044c8dd2f..1bbec7972 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -1,12 +1,16 @@ _ = require 'underscore-plus' +idCounter = 1 + module.exports = class TokenizedLine - constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, tabLength}) -> - @tokens = @breakOutAtomicTokens(tokens, tabLength) + constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, @tabLength, @indentLevel}) -> + @tokens = @breakOutAtomicTokens(tokens) @startBufferColumn ?= 0 @text = _.pluck(@tokens, 'value').join('') @bufferDelta = _.sum(_.pluck(@tokens, 'bufferDelta')) + @id = idCounter++ + @markLeadingAndTrailingWhitespaceTokens() copy: -> new TokenizedLine({@tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold}) @@ -106,14 +110,25 @@ class TokenizedLine delta = nextDelta delta - breakOutAtomicTokens: (inputTokens, tabLength) -> + breakOutAtomicTokens: (inputTokens) -> outputTokens = [] - breakOutLeadingWhitespace = true + breakOutLeadingSoftTabs = true for token in inputTokens - outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingWhitespace)...) - breakOutLeadingWhitespace = token.isOnlyWhitespace() if breakOutLeadingWhitespace + outputTokens.push(token.breakOutAtomicTokens(@tabLength, breakOutLeadingSoftTabs)...) + breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs outputTokens + markLeadingAndTrailingWhitespaceTokens: -> + firstNonWhitespacePosition = @text.search(/\S/) + firstTrailingWhitespacePosition = @text.search(/\s*$/) + lineIsWhitespaceOnly = firstTrailingWhitespacePosition is 0 + position = 0 + for token, i in @tokens + token.hasLeadingWhitespace = position < firstNonWhitespacePosition + # Only the *last* segment of a soft-wrapped line can have trailing whitespace + token.hasTrailingWhitespace = @lineEnding? and (position + token.value.length > firstTrailingWhitespacePosition) + position += token.value.length + isComment: -> for token in @tokens continue if token.scopes.length is 1 @@ -134,3 +149,33 @@ class TokenizedLine for token in @tokens return column if token is targetToken column += token.bufferDelta + + getScopeTree: -> + return @scopeTree if @scopeTree? + + scopeStack = [] + for token in @tokens + @updateScopeStack(scopeStack, token.scopes) + _.last(scopeStack).children.push(token) + + @scopeTree = scopeStack[0] + @updateScopeStack(scopeStack, []) + @scopeTree + + updateScopeStack: (scopeStack, desiredScopes) -> + # Find a common prefix + for scope, i in desiredScopes + break unless scopeStack[i]?.scope is desiredScopes[i] + + # Pop scopes until we're at the common prefx + until scopeStack.length is i + poppedScope = scopeStack.pop() + _.last(scopeStack)?.children.push(poppedScope) + + # Push onto common prefix until scopeStack equals desiredScopes + for j in [i...desiredScopes.length] + scopeStack.push(new Scope(desiredScopes[j])) + +class Scope + constructor: (@scope) -> + @children = [] diff --git a/src/workspace-view.coffee b/src/workspace-view.coffee index b607784b0..7e80dada1 100644 --- a/src/workspace-view.coffee +++ b/src/workspace-view.coffee @@ -71,6 +71,7 @@ class WorkspaceView extends View projectHome: path.join(fs.getHomeDirectory(), 'github') audioBeep: true destroyEmptyPanes: true + useReactEditor: false @content: -> @div class: 'workspace', tabindex: -1, => diff --git a/static/editor.less b/static/editor.less index 97a17cd50..463ab4dba 100644 --- a/static/editor.less +++ b/static/editor.less @@ -2,6 +2,64 @@ @import "octicon-utf-codes"; @import "octicon-mixins"; +.editor.react { + .underlayer { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: -2; + } + + .lines { + z-index: -1; + } + + .horizontal-scrollbar { + position: absolute; + left: 0; + right: 0; + bottom: 0; + + height: 15px; + overflow-x: auto; + overflow-y: hidden; + z-index: 3; + + .scrollbar-content { + height: 15px; + } + } + + .scroll-view { + overflow: hidden; + } + + .scroll-view-content { + position: relative; + width: 100%; + } + + .gutter { + padding-left: 0.5em; + padding-right: 0.5em; + + .line-number { + position: absolute; + left: 0; + right: 0; + padding: 0; + white-space: nowrap; + + .icon-right { + padding: 0; + padding-left: .1em; + } + } + } +} + .editor { overflow: hidden; cursor: text; @@ -28,7 +86,6 @@ .editor .gutter .line-number { padding-left: .5em; opacity: 0.6; - position: relative; } .editor .gutter .line-numbers { diff --git a/static/panes.less b/static/panes.less index 492a2116e..6a29fcb26 100644 --- a/static/panes.less +++ b/static/panes.less @@ -38,7 +38,7 @@ background-color: @pane-item-background-color; } - > * { + > *, > .react-wrapper > * { position: absolute; top: 0; right: 0;