   fn(obj)
   obj
 
+class Editor
+  KEYS =
+    codesByName: { }
+    namesByCode: { }
+    namesByAlias: { }
+  
+  defineKey = (code, name, aliases...) ->
+    for alias in [name, aliases...]
+      KEYS.codesByName[alias] = code
+      KEYS.namesByAlias[alias] = name
+    
+    KEYS.namesByCode[code] = name
+  
+  for letter in 'abcdefghijklmnopqrstuvwxyz01234567890'.split('')
+    defineKey letter, letter
+  
+  defineKey   9, 'tab',    "\t"
+  defineKey  13, 'return', "\n"
+  defineKey  37, 'left'
+  defineKey  38, 'up'
+  defineKey  39, 'right'
+  defineKey  40, 'down'
+  
+  keysymFromEvent = (event) ->
+    mod = [
+      '^' if event.ctrlKey
+      '@' if event.altKey
+      '$' if event.shiftKey ].sort().join('')
+    name = KEYS.namesByCode[event.which]
+    "#{mod}#{name}"
+  
+  normalizeKeysym = (sym) ->
+    m = sym.match ///^ ([\^@\$]*) ([a-z][a-z0-9\-]*) $///i
+    name = KEYS.namesByAlias[m[2]]
+    mod = m[1].split('').sort().join('')
+    "#{mod}#{name}"
+  
+  constructor: (input) ->
+    @input = $(input)
+    
+    @bindings = { }
+    
+    @input.keydown (event) => @handleKey event
+    @input.keyup (event) => @fitContent()
+    @input.css resize: 'none'
+    @shrink()
+  
+  bindKey: (sym, options, fn) ->
+    [options, fn] = [{ }, options] if options and not fn
+    sym = normalizeKeysym sym
+    fn = @[fn] if typeof fn is 'string'
+    @bindings[sym] =
+      preventDefault: options?.preventDefault ? true
+      fn: fn
+  
+  handleKey: (event) ->
+    sym = keysymFromEvent event
+    if binding = @bindings[sym]
+      binding.fn.call this, event
+      event.preventDefault() if binding.preventDefault
+  
+  insertText: (toInsert, select = false) ->
+    {begin, end} = @getSelection false
+    
+    oldText = @input.val()
+    newText = oldText[0...begin] + toInsert + oldText[end...]
+    
+    @input.val newText
+    
+    if select
+      end = begin + toInsert.length
+    else
+      begin = end = begin + toInsert.length
+    
+    @setSelection {begin, end}
+  
+  indent: (levels = 1) ->
+    input = @input[0]
+    begin = input.selectionStart
+    end = input.selectionEnd
+    
+    line = @getLineAtPosition()
+    oldText = @input.val()
+    newText = oldText[0...line.begin]
+    
+    if levels >= 0
+      newText += '  ' for i in [0...levels]
+      newText += line.text
+    else
+      newText += line.text.replace ///^ [ ]{2} {0, #{Math.abs levels} } ///, ''
+    
+    newText += oldText[line.end...]
+    
+    @input.val newText
+    input.setSelectionRange(
+      Math.max(line.begin, begin + levels)
+      Math.max(line.begin, end   + levels) )
+    
+    newText
+  
+  insertNewLineAndIndent: ->
+    @insertText "\n"
+    # TODO indent to match previous line
+  
+  getLineAtPosition: (index) ->
+    input = @input[0]
+    
+    index ?= input.selectionStart
+    text = @input.val()
+    
+    begin = text.lastIndexOf '\n', index - 1
+    end = text.indexOf '\n', index
+    
+    if begin < 0 then begin  = 0 \
+                 else begin += 1
+    if end   < 0 then end    = text.length
+    
+    { begin, end, text: text[begin...end] }
+  
+  getSelection: (getText = true) ->
+    input = @input[0]
+    begin = input.selectionStart
+    end = input.selectionEnd
+    { begin, end, text: @input.val()[begin...end] if getText }
+  
+  setSelection: (selection) ->
+    @input[0].setSelectionRange selection.begin, selection.end
+  
+  clear: ->
+    @input.val null
+    @shrink()
+    return
+  
+  fitContent: ->
+    input = @input[0]
+    
+    outerHeight = input.clientHeight
+    innerHeight = input.scrollHeight
+    
+    adjustedHeight = Math.max outerHeight, innerHeight
+    adjustedHeight = Math.min adjustedHeight, @input.parent()[0].clientHeight / 2
+    
+    if adjustedHeight != input.clientHeight
+      @input.css height: adjustedHeight
+    
+    return
+  
+  shrink: ->
+    @input.css height: '1.5em'
+    return
+
 class Console
-  KEY_TAB    =  9
-  KEY_RETURN = 13
-  KEY_LEFT   = 37
-  KEY_UP     = 38
-  KEY_RIGHT  = 39
-  KEY_DOWN   = 40
   
   @instance =
     dump:  -> console?.log   arguments...

    @buffer = if buffer? then $(buffer) else $('.console-buffer', @root)
    @input  = if input?  then $(input)  else $('.console-input',  @root)
    
    @history = [ ]
    @history.limit = 100
    @history.index = -1
    
    @bufferLimit = 20
    
    if @input.is 'textarea'
      @input.keyup (event) => @autosizeInput()
      @input.css
        resize: 'none'
        height: '1.5em'
    
    @input.keydown (event) => @handleKey(event)
  
  handleKey: (event) ->
    if event.altKey
      switch event.which
        when KEY_UP   then @selectHistory +1; event.preventDefault()
        when KEY_DOWN then @selectHistory -1; event.preventDefault()
    else if event.shiftKey
      switch event.which
        when KEY_TAB then @indentInput(-1); event.preventDefault()
    else
      switch event.which
        when KEY_TAB    then @indentInput(+1); event.preventDefault()
        when KEY_RETURN then @processInput(); event.preventDefault()
  
  processInput: ->
    try
      @dump CoffeeScript.eval @input.val(), bare: true
      @pushHistory()
      @clearInput()
      @input.css height: '1.5em'
    catch ex
      @error ex
      @input.select()
    
    @scrollToBottom()
    return
  
  clearInput: ->
    @input.val null
    return
  
  getLineAtPosition: (index) ->
    input = @input[0]
    
    index ?= input.selectionStart
    text = @input.val()
    
    begin = text.lastIndexOf '\n', index - 1
    end = text.indexOf '\n', index
    
    if begin < 0 then begin  = 0 \
                 else begin += 1
    if end   < 0 then end    = text.length
    
    { begin, end, text: text[begin...end] }
  
  indentInput: (levels = 1) ->
    input = @input[0]
    begin = input.selectionStart
    end =input.selectionEnd
    
    line = @getLineAtPosition()
    oldText = @input.val()
    newText = oldText[0...line.begin]
    
    if levels >= 0
      newText += '  ' for i in [0...levels]
      newText += line.text
    else
      newText += line.text.replace ///^ [ ]{2} {0, #{Math.abs levels} } ///, ''
    
    newText += oldText[line.end...]
    
    @input.val newText
    input.setSelectionRange(
      Math.max(line.begin, begin + levels)
      Math.max(line.begin, end   + levels) )
    
    newText
  
  autosizeInput: ->
    input = @input[0]
    
    outerHeight = input.clientHeight
    innerHeight = input.scrollHeight
    
    adjustedHeight = Math.max outerHeight, innerHeight
    adjustedHeight = Math.min adjustedHeight, @root[0].clientHeight / 2
    
    if adjustedHeight != input.clientHeight
      @input.css height: adjustedHeight
  
  pushHistory: (command) ->
    command ?= @input.val()

    if index >= @history.length
      undefined # TODO play donk sound or something
    else if index < 0
      @editor.clear()
      @history.index = -1
    else
      @history.index = index
