Base cursor x position on char widths stored in DisplayBuffer

Whenever new lines are added to the screen, we measure and store any
unseen scope/character combinations in the DisplayBuffer.
This commit is contained in:
Nathan Sobo
2014-04-02 10:59:57 -06:00
parent 148a9f0248
commit 53cc5c9856
9 changed files with 156 additions and 54 deletions

View File

@@ -2,22 +2,25 @@
EditorComponent = require '../src/editor-component'
describe "EditorComponent", ->
[editor, component, node, lineHeight, charWidth] = []
[editor, component, node, lineHeightInPixels, charWidth] = []
beforeEach ->
spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn()
waitsForPromise ->
atom.packages.activatePackage('language-javascript')
editor = atom.project.openSync('sample.js')
container = document.querySelector('#jasmine-content')
component = React.renderComponent(EditorComponent({editor}), container)
node = component.getDOMNode()
runs ->
spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn()
node.style.lineHeight = 1.3
node.style.fontSize = '20px'
{lineHeight, charWidth} = component.measureLineDimensions()
editor = atom.project.openSync('sample.js')
container = document.querySelector('#jasmine-content')
component = React.renderComponent(EditorComponent({editor}), container)
component.setLineHeight(1.3)
component.setFontSize(20)
{lineHeightInPixels, charWidth} = component.measureLineDimensions()
node = component.getDOMNode()
it "renders only the currently-visible lines", ->
node.style.height = 4.5 * lineHeight + 'px'
node.style.height = 4.5 * lineHeightInPixels + 'px'
component.updateAllDimensions()
lines = node.querySelectorAll('.line')
@@ -25,10 +28,10 @@ describe "EditorComponent", ->
expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text
expect(lines[4].textContent).toBe editor.lineForScreenRow(4).text
node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeight
node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels
component.onVerticalScroll()
expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeight}px)"
expect(node.querySelector('.scrollable-content').style['-webkit-transform']).toBe "translateY(#{-2.5 * lineHeightInPixels}px)"
lines = node.querySelectorAll('.line')
expect(lines.length).toBe 5
@@ -36,19 +39,19 @@ describe "EditorComponent", ->
expect(lines[4].textContent).toBe editor.lineForScreenRow(6).text
spacers = node.querySelectorAll('.spacer')
expect(spacers[0].offsetHeight).toBe 2 * lineHeight
expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeight
expect(spacers[0].offsetHeight).toBe 2 * lineHeightInPixels
expect(spacers[1].offsetHeight).toBe (editor.getScreenLineCount() - 7) * lineHeightInPixels
it "renders the currently visible cursors", ->
cursor1 = editor.getCursor()
cursor1.setScreenPosition([0, 5])
node.style.height = 4.5 * lineHeight + 'px'
node.style.height = 4.5 * lineHeightInPixels + 'px'
component.updateAllDimensions()
cursorNodes = node.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe 1
expect(cursorNodes[0].offsetHeight).toBe lineHeight
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
@@ -60,25 +63,41 @@ describe "EditorComponent", ->
expect(cursorNodes.length).toBe 2
expect(cursorNodes[0].offsetTop).toBe 0
expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth
expect(cursorNodes[1].offsetTop).toBe 4 * lineHeight
expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels
expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth
node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeight
node.querySelector('.vertical-scrollbar').scrollTop = 2.5 * lineHeightInPixels
component.onVerticalScroll()
cursorNodes = node.querySelectorAll('.cursor')
expect(cursorNodes.length).toBe 2
expect(cursorNodes[0].offsetTop).toBe 6 * lineHeight
expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels
expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth
expect(cursorNodes[1].offsetTop).toBe 4 * lineHeight
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 * lineHeight
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 "transfers focus to the hidden input", ->
expect(document.activeElement).toBe document.body
node.focus()

View File

@@ -3,12 +3,5 @@
module.exports =
CursorComponent = React.createClass
render: ->
{cursor, lineHeight, charWidth} = @props
{row, column} = cursor.getScreenPosition()
div className: 'cursor', style: {
height: lineHeight,
width: charWidth
top: row * lineHeight
left: column * charWidth
}
{top, left, height, width} = @props.cursor.getPixelRect()
div className: 'cursor', style: {top, left, height, width}

View File

@@ -54,6 +54,14 @@ class Cursor
unless fn()
@emit 'autoscrolled' if @needsAutoscroll
getPixelRect: ->
screenPosition = @getScreenPosition()
{top, left} = @editor.pixelPositionForScreenPosition(screenPosition, false)
right = @editor.pixelPositionForScreenPosition(screenPosition.add([0, 1])).left
width = right - left
height = @editor.getLineHeight()
{top, left, width, height}
# Public: Moves a cursor to a given screen position.
#
# screenPosition - An {Array} of two numbers: the screen row, and the screen

View File

@@ -420,6 +420,9 @@ class DisplayBuffer extends Model
setScopedCharWidths: (scopeNames, charWidths) ->
_.extend(@getScopedCharWidths(scopeNames), charWidths)
clearScopedCharWidths: ->
@charWidthsByScope = {}
# Get the grammar for this buffer.
#
# Returns the current {Grammar} or the {NullGrammar}.

View File

@@ -1,13 +1,14 @@
{React, div, span} = require 'reactionary'
{$$} = require 'space-pencil'
{$$} = require 'space-pen'
SelectionComponent = require './selection-component'
InputComponent = require './input-component'
CustomEventMixin = require './custom-event-mixin'
SubscriberMixin = require './subscriber-mixin'
DummyLineNode = $$ ->
@div className: 'line', style: 'position: absolute; visibility: hidden;', -> @span 'x'
DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0]
MeasureRange = document.createRange()
TextNodeFilter = { acceptNode: -> NodeFilter.FILTER_ACCEPT }
module.exports =
EditorCompont = React.createClass
@@ -18,7 +19,9 @@ EditorCompont = React.createClass
mixins: [CustomEventMixin, SubscriberMixin]
render: ->
div className: 'editor', tabIndex: -1,
{fontSize, lineHeight, fontFamily} = @state
div className: 'editor', tabIndex: -1, style: {fontSize, lineHeight, fontFamily},
div className: 'scroll-view', ref: 'scrollView',
InputComponent ref: 'hiddenInput', className: 'hidden-input', onInput: @onInput
@renderScrollableContent()
@@ -26,7 +29,7 @@ EditorCompont = React.createClass
div outlet: 'verticalScrollbarContent', style: {height: @getScrollHeight()}
renderScrollableContent: ->
height = @props.editor.getScreenLineCount() * @state.lineHeight
height = @props.editor.getScreenLineCount() * @state.lineHeightInPixels
WebkitTransform = "translateY(#{-@state.scrollTop}px)"
div className: 'scrollable-content', style: {height, WebkitTransform},
@@ -34,16 +37,16 @@ EditorCompont = React.createClass
@renderVisibleLines()
renderOverlayer: ->
{lineHeight, charWidth} = @state
{lineHeightInPixels, charWidth} = @state
div className: 'overlayer',
for selection in @props.editor.getSelections() when @selectionIntersectsVisibleRowRange(selection)
SelectionComponent({selection, lineHeight, charWidth})
SelectionComponent({selection})
renderVisibleLines: ->
[startRow, endRow] = @getVisibleRowRange()
precedingHeight = startRow * @state.lineHeight
followingHeight = (@props.editor.getScreenLineCount() - endRow) * @state.lineHeight
precedingHeight = startRow * @state.lineHeightInPixels
followingHeight = (@props.editor.getScreenLineCount() - endRow) * @state.lineHeightInPixels
div className: 'lines', ref: 'lines', [
div className: 'spacer', key: 'top-spacer', style: {height: precedingHeight}
@@ -55,11 +58,12 @@ EditorCompont = React.createClass
getInitialState: ->
height: 0
width: 0
lineHeight: 0
lineHeightInPixels: 0
scrollTop: 0
componentDidMount: ->
@listenForCustomEvents()
@measuredLines = new WeakSet
@refs.scrollView.getDOMNode().addEventListener 'mousewheel', @onMousewheel
@getDOMNode().addEventListener 'focus', @onFocus
@@ -68,12 +72,18 @@ EditorCompont = React.createClass
@subscribe editor, 'selection-added', @onSelectionAdded
@subscribe editor, 'selection-removed', @onSelectionAdded
@listenForCustomEvents()
@observeConfig()
@updateAllDimensions()
@props.editor.setVisible(true)
componentWillUnmount: ->
@getDOMNode().removeEventListener 'mousewheel', @onMousewheel
componentDidUpdate: ->
@measureNewLines()
listenForCustomEvents: ->
{editor, mini} = @props
@@ -171,6 +181,23 @@ EditorCompont = React.createClass
# 'core:page-up': => @pageUp()
# 'editor:scroll-to-cursor': => @scrollToCursorPosition()
observeConfig: ->
@subscribe atom.config.observe 'editor.fontFamily', @setFontFamily
setFontSize: (fontSize) ->
@clearScopedCharWidths()
@setState({fontSize})
@updateLineDimensions()
setLineHeight: (lineHeight) ->
@updateLineDimensions()
@setState({lineHeight})
setFontFamily: (fontFamily) ->
@clearScopedCharWidths()
@setState({fontFamily})
@updateLineDimensions()
onFocus: ->
@refs.hiddenInput.focus()
@@ -200,10 +227,11 @@ EditorCompont = React.createClass
@forceUpdate() if @selectionIntersectsVisibleRowRange(selection)
getVisibleRowRange: ->
return [0, 0] unless @state.lineHeight > 0
return [0, 0] unless @state.lineHeightInPixels > 0
return [0, @props.editor.getScreenLineCount()] if @state.height is 0
heightInLines = @state.height / @state.lineHeight
startRow = Math.floor(@state.scrollTop / @state.lineHeight)
heightInLines = @state.height / @state.lineHeightInPixels
startRow = Math.floor(@state.scrollTop / @state.lineHeightInPixels)
endRow = Math.ceil(startRow + heightInLines)
[startRow, endRow]
@@ -216,12 +244,20 @@ EditorCompont = React.createClass
@intersectsVisibleRowRange(start.row, end.row + 1)
getScrollHeight: ->
@props.editor.getLineCount() * @state.lineHeight
@props.editor.getLineCount() * @state.lineHeightInPixels
updateAllDimensions: ->
{height, width} = @measureScrollViewDimensions()
{lineHeight, charWidth} = @measureLineDimensions()
@setState({height, width, lineHeight, charWidth})
{lineHeightInPixels, charWidth} = @measureLineDimensions()
@props.editor.setLineHeight(lineHeightInPixels)
@props.editor.setDefaultCharWidth(charWidth)
@setState({height, width, lineHeightInPixels, charWidth})
updateLineDimensions: ->
{lineHeightInPixels, charWidth} = @measureLineDimensions()
@props.editor.setLineHeight(lineHeightInPixels)
@props.editor.setDefaultCharWidth(charWidth)
@setState({lineHeightInPixels, charWidth})
measureScrollViewDimensions: ->
scrollViewNode = @refs.scrollView.getDOMNode()
@@ -230,10 +266,39 @@ EditorCompont = React.createClass
measureLineDimensions: ->
linesNode = @refs.lines.getDOMNode()
linesNode.appendChild(DummyLineNode)
lineHeight = DummyLineNode.getBoundingClientRect().height
lineHeightInPixels = DummyLineNode.getBoundingClientRect().height
charWidth = DummyLineNode.firstChild.getBoundingClientRect().width
linesNode.removeChild(DummyLineNode)
{lineHeight, charWidth}
{lineHeightInPixels, charWidth}
measureNewLines: ->
[visibleStartRow, visibleEndRow] = @getVisibleRowRange()
linesNode = @refs.lines.getDOMNode()
for tokenizedLine, i in @props.editor.linesForScreenRows(visibleStartRow, visibleEndRow - 1)
unless @measuredLines.has(tokenizedLine)
lineNode = linesNode.children[i + 1]
@measureCharactersInLine(tokenizedLine, lineNode)
measureCharactersInLine: (tokenizedLine, lineNode) ->
{editor} = @props
iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, TextNodeFilter)
for {value, scopes} in tokenizedLine.tokens
textNode = iterator.nextNode()
charWidths = editor.getScopedCharWidths(scopes)
for char, i in value
unless charWidths[char]?
MeasureRange.setStart(textNode, i)
MeasureRange.setEnd(textNode, i + 1)
charWidth = MeasureRange.getBoundingClientRect().width
@props.editor.setScopedCharWidth(scopes, char, charWidth)
@measuredLines.add(tokenizedLine)
clearScopedCharWidths: ->
@measuredLines.clear()
@props.editor.clearScopedCharWidths()
LineComponent = React.createClass
render: ->
@@ -251,6 +316,6 @@ LineComponent = React.createClass
html += @buildScopeTreeHTML(child) for child in scopeTree.children
html
else
"<span>#{scopeTree.value}</span>"
"<span>#{scopeTree.getValueAsHtml({})}</span>"
shouldComponentUpdate: -> false

View File

@@ -1472,6 +1472,7 @@ class EditorView extends View
# Copies the current file path to the native clipboard.
copyPathToClipboard: ->
@editor.copyPathToClipboard()
path = @editor.getPath()
atom.clipboard.write(path) if path?

View File

@@ -514,6 +514,8 @@ class Editor extends Model
# this editor.
shouldPromptToSave: -> @isModified() and not @buffer.hasMultipleEditors()
pixelPositionForScreenPosition: (screenPosition) -> @displayBuffer.pixelPositionForScreenPosition(screenPosition)
# Public: Convert a position in buffer-coordinates to screen-coordinates.
#
# The position is clipped via {::clipBufferPosition} prior to the conversion.
@@ -1790,6 +1792,20 @@ class Editor extends Model
getSelectionMarkerAttributes: ->
type: 'selection', editorId: @id, invalidate: 'never'
getLineHeight: -> @displayBuffer.getLineHeight()
setLineHeight: (lineHeight) -> @displayBuffer.setLineHeight(lineHeight)
setDefaultCharWidth: (defaultCharWidth) -> @displayBuffer.setDefaultCharWidth(defaultCharWidth)
getScopedCharWidth: (args...) -> @displayBuffer.getScopedCharWidth(args...)
getScopedCharWidths: (args...) -> @displayBuffer.getScopedCharWidths(args...)
setScopedCharWidth: (args...) -> @displayBuffer.setScopedCharWidth(args...)
clearScopedCharWidths: -> @displayBuffer.clearScopedCharWidths()
# Deprecated: Call {::joinLines} instead.
joinLine: ->
deprecate("Use Editor::joinLines() instead")

View File

@@ -1,5 +1,4 @@
{View} = require 'space-pen'
{$$} = require 'space-pencil'
{React} = require 'reactionary'
EditorComponent = require './editor-component'

View File

@@ -7,10 +7,8 @@ SelectionComponent = React.createClass
mixins: [SubscriberMixin]
render: ->
{selection, lineHeight, charWidth} = @props
{cursor} = selection
div className: 'selection',
CursorComponent({cursor, lineHeight, charWidth})
CursorComponent(cursor: @props.selection.cursor)
componentDidMount: ->
@subscribe @props.selection, 'screen-range-changed', => @forceUpdate()