diff --git a/spec/atom/buffer-spec.coffee b/spec/atom/buffer-spec.coffee index b58a37655..e22c3d43b 100644 --- a/spec/atom/buffer-spec.coffee +++ b/spec/atom/buffer-spec.coffee @@ -102,6 +102,24 @@ describe 'Buffer', -> expect(buffer.getLine(3)).toBe " var pivot = sort(Array.apply(this, arguments));" expect(buffer.getLine(4)).toBe "};" + describe ".setText(text)", -> + it "changes the entire contents of the buffer and emits a change event", -> + lastRow = buffer.lastRow() + expectedPreRange = new Range([0,0], [lastRow, buffer.getLine(lastRow).length]) + changeHandler = jasmine.createSpy('changeHandler') + buffer.on 'change', changeHandler + + newText = "I know you are.\nBut what am I?" + buffer.setText(newText) + + expect(buffer.getText()).toBe newText + expect(changeHandler).toHaveBeenCalled() + + [event] = changeHandler.argsForCall[0] + expect(event.string).toBe newText + expect(event.preRange).toEqual expectedPreRange + expect(event.postRange).toEqual(new Range([0, 0], [1, 14])) + describe ".save()", -> describe "when the buffer has a path", -> filePath = null diff --git a/spec/atom/editor-spec.coffee b/spec/atom/editor-spec.coffee index 0b345e826..d6e249365 100644 --- a/spec/atom/editor-spec.coffee +++ b/spec/atom/editor-spec.coffee @@ -25,6 +25,26 @@ describe "Editor", -> expect(buffer.getLine(10)).toBe '' expect(editor.lines.find('pre:eq(10)').html()).toBe ' ' + it "syntax highlights code based on the file type", -> + line1 = editor.lines.find('.line:first') + expect(line1.find('span:eq(0)')).toMatchSelector '.keyword.definition' + expect(line1.find('span:eq(0)').text()).toBe 'var' + expect(line1.find('span:eq(1)')).toMatchSelector '.text' + expect(line1.find('span:eq(1)').text()).toBe ' ' + expect(line1.find('span:eq(2)')).toMatchSelector '.identifier' + expect(line1.find('span:eq(2)').text()).toBe 'quicksort' + expect(line1.find('span:eq(4)')).toMatchSelector '.operator' + expect(line1.find('span:eq(4)').text()).toBe '=' + + line12 = editor.lines.find('.line:eq(11)') + expect(line12.find('span:eq(1)')).toMatchSelector '.keyword' + + describe "when lines are updated in the buffer", -> + it "syntax highlights the updated lines", -> + expect(editor.lines.find('.line:eq(0) span:eq(0)')).toMatchSelector '.keyword.definition' + buffer.insert([0, 4], "g") + expect(editor.lines.find('.line:eq(0) span:eq(0)')).toMatchSelector '.keyword.definition' + describe "cursor movement", -> describe ".setCursorPosition({row, column})", -> beforeEach -> diff --git a/spec/atom/highlighter-spec.coffee b/spec/atom/highlighter-spec.coffee new file mode 100644 index 000000000..37cdb4d26 --- /dev/null +++ b/spec/atom/highlighter-spec.coffee @@ -0,0 +1,69 @@ +Highlighter = require 'highlighter' +Buffer = require 'buffer' +Range = require 'range' + +describe "Highlighter", -> + [highlighter, buffer] = [] + + beforeEach -> + buffer = new Buffer(require.resolve('fixtures/sample.js')) + highlighter = new Highlighter(buffer) + + describe "constructor", -> + it "tokenizes all the lines in the buffer", -> + expect(highlighter.tokensForRow(0)[0]).toEqual(type: 'keyword.definition', value: 'var') + expect(highlighter.tokensForRow(11)[1]).toEqual(type: 'keyword', value: 'return') + + describe "when the buffer changes", -> + describe "when lines are updated, but none are added or removed", -> + it "updates tokens for each of the changed lines", -> + buffer.change(new Range([0, 0], [2, 0]), "foo()\nbar()\n") + + expect(highlighter.tokensForRow(0)[0]).toEqual(type: 'identifier', value: 'foo') + expect(highlighter.tokensForRow(1)[0]).toEqual(type: 'identifier', value: 'bar') + + # line 2 is unchanged + expect(highlighter.tokensForRow(2)[1]).toEqual(type: 'keyword', value: 'if') + + it "updates tokens for lines beyond the changed lines if needed", -> + buffer.insert([5, 30], '/* */') + buffer.insert([2, 0], '/*') + expect(highlighter.tokensForRow(3)[0].type).toBe 'comment' + expect(highlighter.tokensForRow(4)[0].type).toBe 'comment' + expect(highlighter.tokensForRow(5)[0].type).toBe 'comment' + + describe "when lines are both updated and removed", -> + it "updates tokens to reflect the removed lines", -> + buffer.change(new Range([1, 0], [3, 0]), "foo()") + + # previous line 0 remains + expect(highlighter.tokensForRow(0)[0]).toEqual(type: 'keyword.definition', value: 'var') + + # previous line 3 should be combined with input to form line 1 + expect(highlighter.tokensForRow(1)[0]).toEqual(type: 'identifier', value: 'foo') + expect(highlighter.tokensForRow(1)[6]).toEqual(type: 'identifier', value: 'pivot') + + # lines below deleted regions should be shifted upward + expect(highlighter.tokensForRow(2)[1]).toEqual(type: 'keyword', value: 'while') + expect(highlighter.tokensForRow(3)[1]).toEqual(type: 'identifier', value: 'current') + expect(highlighter.tokensForRow(4)[3]).toEqual(type: 'keyword.operator', value: '<') + + describe "when lines are both updated and inserted", -> + it "updates tokens to reflect the inserted lines", -> + buffer.change(new Range([1, 0], [2, 0]), "foo()\nbar()\nbaz()\nquux()") + + # previous line 0 remains + expect(highlighter.tokensForRow(0)[0]).toEqual(type: 'keyword.definition', value: 'var') + + # 3 new lines inserted + expect(highlighter.tokensForRow(1)[0]).toEqual(type: 'identifier', value: 'foo') + expect(highlighter.tokensForRow(2)[0]).toEqual(type: 'identifier', value: 'bar') + expect(highlighter.tokensForRow(3)[0]).toEqual(type: 'identifier', value: 'baz') + + # previous line 2 is joined with quux() on line 4 + expect(highlighter.tokensForRow(4)[0]).toEqual(type: 'identifier', value: 'quux') + expect(highlighter.tokensForRow(4)[4]).toEqual(type: 'keyword', value: 'if') + + # previous line 3 is pushed down to become line 5 + expect(highlighter.tokensForRow(5)[3]).toEqual(type: 'identifier', value: 'pivot') + diff --git a/src/atom/buffer.coffee b/src/atom/buffer.coffee index 5bc3ebcdc..661f82cce 100644 --- a/src/atom/buffer.coffee +++ b/src/atom/buffer.coffee @@ -1,4 +1,6 @@ +_ = require 'underscore' fs = require 'fs' +Range = require 'range' module.exports = class Buffer @@ -6,6 +8,7 @@ class Buffer constructor: (@path) -> @url = @path # we want this to be path on master, but let's not break it on a branch + @lines = [''] if @path and fs.exists(@path) @setText(fs.read(@path)) else @@ -15,7 +18,10 @@ class Buffer @lines.join('\n') setText: (text) -> - @lines = text.split('\n') + @change(@getRange(), text) + + getRange: -> + new Range([0, 0], [@lastRow(), @lastLine().length]) getTextInRange: (range) -> if range.start.row == range.end.row @@ -35,46 +41,38 @@ class Buffer getLine: (row) -> @lines[row] - change: (preRange, string) -> - @remove(preRange) - postRange = @insert(preRange.start, string) - @trigger 'change', { preRange, postRange, string } - - remove: (range) -> - prefix = @lines[range.start.row][0...range.start.column] - suffix = @lines[range.end.row][range.end.column..] - @lines[range.start.row..range.end.row] = prefix + suffix - - insert: ({row, column}, string) -> - postRange = - start: { row, column } - end: { row, column } - - prefix = @lines[row][0...column] - suffix = @lines[row][column..] - - lines = string.split('\n') - - if lines.length == 1 - @lines[row] = prefix + string + suffix - postRange.end.column += string.length - else - for line, i in lines - curRow = row + i - if i == 0 # replace first line - @lines[curRow] = prefix + line - else if i < lines.length - 1 # insert middle lines - @lines[curRow...curRow] = line - else # insert last line - @lines[curRow...curRow] = line + suffix - postRange.end.row = curRow - postRange.end.column = line.length - - postRange - numLines: -> @getLines().length + lastRow: -> + @getLines().length - 1 + + lastLine: -> + @getLine(@lastRow()) + + insert: (point, text) -> + @change(new Range(point, point), text) + + change: (preRange, newText) -> + postRange = new Range(_.clone(preRange.start), _.clone(preRange.start)) + prefix = @lines[preRange.start.row][0...preRange.start.column] + suffix = @lines[preRange.end.row][preRange.end.column..] + + newTextLines = newText.split('\n') + + if newTextLines.length == 1 + postRange.end.column += newText.length + newTextLines = [prefix + newText + suffix] + else + lastLineIndex = newTextLines.length - 1 + newTextLines[0] = prefix + newTextLines[0] + postRange.end.row += lastLineIndex + postRange.end.column = newTextLines[lastLineIndex].length + newTextLines[lastLineIndex] += suffix + + @lines[preRange.start.row..preRange.end.row] = newTextLines + @trigger 'change', { preRange, postRange, string: newText } + save: -> if not @path then throw new Error("Tried to save buffer with no url") fs.write @path, @getText() @@ -87,3 +85,14 @@ class Buffer trigger: (eventName, event) -> @eventHandlers?[eventName]?.forEach (handler) -> handler(event) + modeName: -> + extension = if @path then @path.split('/').pop().split('.').pop() else null + switch extension + when 'js' then 'javascript' + when 'coffee' then 'coffee' + when 'rb', 'ru' then 'ruby' + when 'c', 'h', 'cpp' then 'c_cpp' + when 'html', 'htm' then 'html' + when 'css' then 'css' + else 'text' + diff --git a/src/atom/editor.coffee b/src/atom/editor.coffee index c2f525c4b..cc79268ee 100644 --- a/src/atom/editor.coffee +++ b/src/atom/editor.coffee @@ -3,7 +3,9 @@ Buffer = require 'buffer' Point = require 'point' Cursor = require 'cursor' Selection = require 'selection' +Highlighter = require 'highlighter' Range = require 'range' + $ = require 'jquery' $$ = require 'template/builder' _ = require 'underscore' @@ -26,6 +28,7 @@ class Editor extends Template initialize: () -> requireStylesheet 'editor.css' + requireStylesheet 'theme/twilight.css' @bindKeys() @buildCursorAndSelection() @handleEvents() @@ -97,16 +100,23 @@ class Editor extends Template @hiddenInput.width(@charWidth) @focus() - buildLineElement: (lineText) -> - if lineText is '' - $$.pre class: "line", -> @raw(' ') - else - $$.pre class: "line", lineText + buildLineElement: (row) -> + tokens = @highlighter.tokensForRow(row) + $$.pre class: 'line', -> + if tokens.length + for token in tokens + classes = token.type.split('.').map((c) -> "ace_#{c}").join(' ') + @span { class: token.type.replace('.', ' ') }, token.value + else + @raw ' ' setBuffer: (@buffer) -> + @highlighter = new Highlighter(@buffer) + @lines.empty() - for line in @buffer.getLines() - @lines.append @buildLineElement(line) + for row in [0..@buffer.lastRow()] + line = @buildLineElement(row) + @lines.append line @setCursorPosition(row: 0, column: 0) @@ -128,18 +138,13 @@ class Editor extends Template else @updateLineElement(row) - @selection.bufferChanged(e) + @cursor.bufferChanged(e) updateLineElement: (row) -> - line = @buffer.getLine(row) - element = @getLineElement(row) - if line == '' - element.html(' ') - else - element.text(line) + @getLineElement(row).replaceWith(@buildLineElement(row)) insertLineElement: (row) -> - @getLineElement(row).before(@buildLineElement(@buffer.getLine(row))) + @getLineElement(row).before(@buildLineElement(row)) removeLineElement: (row) -> @getLineElement(row).remove() diff --git a/src/atom/highlighter.coffee b/src/atom/highlighter.coffee new file mode 100644 index 000000000..33c4a5a74 --- /dev/null +++ b/src/atom/highlighter.coffee @@ -0,0 +1,43 @@ +_ = require 'underscore' + +module.exports = +class Highlighter + buffer: null + tokenizer: null + lines: [] + + constructor: (@buffer) -> + @buildTokenizer() + @lines = @tokenizeRows('start', 0, @buffer.lastRow()) + @buffer.on 'change', (e) => @handleBufferChange(e) + + buildTokenizer: -> + Mode = require("ace/mode/#{@buffer.modeName()}").Mode + @tokenizer = (new Mode).getTokenizer() + + handleBufferChange: (e) -> + { preRange, postRange } = e + + previousState = @lines[preRange.end.row].state + newLines = @tokenizeRows('start', postRange.start.row, postRange.end.row) + @lines[preRange.start.row..preRange.end.row] = newLines + + for row in [postRange.end.row...@buffer.lastRow()] + break if @lines[row].state == previousState + nextRow = row + 1 + previousState = @lines[nextRow].state + @lines[nextRow] = @tokenizeRow(@lines[row].state, nextRow) + + tokenizeRows: (startState, startRow, endRow) -> + state = startState + for row in [startRow..endRow] + line = @tokenizeRow(state, row) + state = line.state + line + + tokenizeRow: (state, row) -> + @tokenizer.getLineTokens(@buffer.getLine(row), state) + + tokensForRow: (row) -> + @lines[row].tokens + diff --git a/src/atom/selection.coffee b/src/atom/selection.coffee index 9bebfcdf5..52c12c731 100644 --- a/src/atom/selection.coffee +++ b/src/atom/selection.coffee @@ -26,9 +26,6 @@ class Selection extends Template @anchor = null @updateAppearance() - bufferChanged: (e) -> - @cursor.setPosition(e.postRange.end) - updateAppearance: -> @clearRegions() diff --git a/static/theme/twilight.css b/static/theme/twilight.css new file mode 100644 index 000000000..1f782f6b9 --- /dev/null +++ b/static/theme/twilight.css @@ -0,0 +1,134 @@ +.editor { + border: 2px solid rgb(159, 159, 159); +} + +.editor.focus { + border: 2px solid #327fbd; +} + +.gutter { + background: #e8e8e8; + color: #333; +} + +.print_margin { + width: 1px; + background: #e8e8e8; +} + +.scroller { + background-color: #141414; +} + +.text-layer { + cursor: text; + color: #F8F8F8; +} + +.cursor { + border-left: 2px solid #A7A7A7; +} + +.cursor.overwrite { + border-left: 0px; + border-bottom: 1px solid #A7A7A7; +} + +.marker-layer .selection { + background: rgba(221, 240, 255, 0.20); +} + +.marker-layer .step { + background: rgb(198, 219, 174); +} + +.marker-layer .bracket { + margin: -1px 0 0 -1px; + border: 1px solid rgba(255, 255, 255, 0.25); +} + +.marker-layer .active_line { + background: rgba(255, 255, 255, 0.031); +} + +.marker-layer .selected_word { + border: 1px solid rgba(221, 240, 255, 0.20); +} + +.invisible { + color: rgba(255, 255, 255, 0.25); +} + +.keyword { + color:#CDA869; +} + +.constant { + color:#CF6A4C; +} + +.invalid.illegal { + color:#F8F8F8; +background-color:rgba(86, 45, 86, 0.75); +} + +.invalid.deprecated { + text-decoration:underline; +font-style:italic; +color:#D2A8A1; +} + +.support { + color:#9B859D; +} + +.fold { + background-color: #AC885B; + border-color: #F8F8F8; +} + +.support.function { + color:#DAD085; +} + +.string { + color:#8F9D6A; +} + +.string.regexp { + color:#E9C062; +} + +.comment { + font-style:italic; +color:#5F5A60; +} + +.variable { + color:#7587A6; +} + +.xml_pe { + color:#494949; +} + +.meta.tag { + color:#AC885B; +} + +.entity.name.function { + color:#AC885B; +} + +.markup.underline { + text-decoration:underline; +} + +.markup.heading { + color:#CF6A4C; +} + +.markup.list { + color:#F9EE98; +}"; +