Files
atom/src/keymap.coffee
2013-11-14 16:20:35 -08:00

208 lines
6.4 KiB
CoffeeScript

{$} = require './space-pen-extensions'
_ = require 'underscore-plus'
fs = require 'fs-plus'
path = require 'path'
CSON = require 'season'
BindingSet = require './binding-set'
{Emitter} = require 'emissary'
Modifiers = ['alt', 'control', 'ctrl', 'shift', 'meta']
# Internal: Associates keymaps with actions.
#
# Keymaps are defined in a CSON format. A typical keymap looks something like this:
#
# ```cson
# 'body':
# 'ctrl-l': 'package:do-something'
#'.someClass':
# 'enter': 'package:confirm'
# ```
#
# As a key, you define the DOM element you want to work on, using CSS notation. For that
# key, you define one or more key:value pairs, associating keystrokes with a command to execute.
module.exports =
class Keymap
Emitter.includeInto(this)
bindingSets: null
nextBindingSetIndex: 0
bindingSetsByFirstKeystroke: null
queuedKeystroke: null
constructor: ({@resourcePath, @configDirPath})->
@bindingSets = []
@bindingSetsByFirstKeystroke = {}
loadBundledKeymaps: ->
@loadDirectory(path.join(@resourcePath, 'keymaps'))
@emit('bundled-keymaps-loaded')
loadUserKeymap: ->
userKeymapPath = CSON.resolve(path.join(@configDirPath, 'keymap'))
@load(userKeymapPath) if userKeymapPath
loadDirectory: (directoryPath) ->
@load(filePath) for filePath in fs.listSync(directoryPath, ['.cson', '.json'])
load: (path) ->
@add(path, CSON.readFileSync(path))
add: (args...) ->
name = args.shift() if args.length > 1
keymap = args.shift()
for selector, bindings of keymap
@bindKeys(name, selector, bindings)
remove: (name) ->
for bindingSet in @bindingSets.filter((bindingSet) -> bindingSet.name is name)
_.remove(@bindingSets, bindingSet)
for keystroke of bindingSet.commandsByKeystroke
firstKeystroke = keystroke.split(' ')[0]
_.remove(@bindingSetsByFirstKeystroke[firstKeystroke], bindingSet)
bindKeys: (args...) ->
name = args.shift() if args.length > 2
[selector, bindings] = args
bindingSet = new BindingSet(selector, bindings, @nextBindingSetIndex++, name)
@bindingSets.unshift(bindingSet)
for keystroke of bindingSet.commandsByKeystroke
keystroke = keystroke.split(' ')[0] # only index by first keystroke
@bindingSetsByFirstKeystroke[keystroke] ?= []
@bindingSetsByFirstKeystroke[keystroke].push(bindingSet)
unbindKeys: (selector, bindings) ->
bindingSet = _.detect @bindingSets, (bindingSet) ->
bindingSet.selector is selector and bindingSet.bindings is bindings
if bindingSet
_.remove(@bindingSets, bindingSet)
handleKeyEvent: (event) ->
element = event.target
element = rootView[0] if element == document.body
keystroke = @keystrokeStringForEvent(event, @queuedKeystroke)
bindings = @bindingsForKeystrokeMatchingElement(keystroke, element)
if bindings.length == 0 and @queuedKeystroke
@queuedKeystroke = null
return false
else
@queuedKeystroke = null
for binding in bindings
partialMatch = binding.keystroke isnt keystroke
if partialMatch
@queuedKeystroke = keystroke
shouldBubble = false
else
if binding.command is 'native!'
shouldBubble = true
else if @triggerCommandEvent(element, binding.command)
shouldBubble = false
break if shouldBubble?
shouldBubble ? true
# Public: Returns an array of objects that represent every keybinding. Each
# object contains the following keys `source`, `selector`, `command`,
# `keystroke`, `index`, `specificity`.
allBindings: ->
bindings = []
for bindingSet in @bindingSets
for keystroke, command of bindingSet.getCommandsByKeystroke()
bindings.push @buildBinding(bindingSet, command, keystroke)
bindings
bindingsForKeystrokeMatchingElement: (keystroke, element) ->
bindings = @bindingsForKeystroke(keystroke)
@bindingsMatchingElement(element, bindings)
bindingsForKeystroke: (keystroke) ->
bindings = @allBindings().filter (binding) ->
multiKeystroke = /\s/.test keystroke
if multiKeystroke
keystroke == binding.keystroke
else
keystroke.split(' ')[0] == binding.keystroke.split(' ')[0]
bindingsMatchingElement: (element, bindings=@allBindings()) ->
bindings = bindings.filter ({selector}) -> $(element).closest(selector).length > 0
bindings.sort (a, b) ->
if b.specificity == a.specificity
b.index - a.index
else
b.specificity - a.specificity
buildBinding: (bindingSet, command, keystroke) ->
selector = bindingSet.selector
specificity = bindingSet.specificity
index = bindingSet.index
source = bindingSet.name
{command, keystroke, selector, specificity, source}
triggerCommandEvent: (element, commandName) ->
commandEvent = $.Event(commandName)
commandEvent.abortKeyBinding = -> commandEvent.stopImmediatePropagation()
$(element).trigger(commandEvent)
not commandEvent.isImmediatePropagationStopped()
keystrokeStringForEvent: (event, previousKeystroke) ->
if event.originalEvent.keyIdentifier.indexOf('U+') == 0
hexCharCode = event.originalEvent.keyIdentifier[2..]
charCode = parseInt(hexCharCode, 16)
charCode = event.which if !@isAscii(charCode) and @isAscii(event.which)
key = @keyFromCharCode(charCode)
else
key = event.originalEvent.keyIdentifier.toLowerCase()
modifiers = []
if event.altKey and key not in Modifiers
modifiers.push 'alt'
if event.ctrlKey and key not in Modifiers
modifiers.push 'ctrl'
if event.metaKey and key not in Modifiers
modifiers.push 'meta'
if event.shiftKey and key not in Modifiers
isNamedKey = key.length > 1
modifiers.push 'shift' if isNamedKey
else
key = key.toLowerCase()
keystroke = [modifiers..., key].join('-')
if previousKeystroke
if keystroke in Modifiers
previousKeystroke
else
"#{previousKeystroke} #{keystroke}"
else
keystroke
isAscii: (charCode) ->
0 <= charCode <= 127
keyFromCharCode: (charCode) ->
switch charCode
when 8 then 'backspace'
when 9 then 'tab'
when 13 then 'enter'
when 27 then 'escape'
when 32 then 'space'
when 127 then 'delete'
else String.fromCharCode(charCode)
#
# Deprecated
#
bindingsForElement: (element) ->
keystrokeMap = {}
mappings = @mappingsMatchingElement(@allMappings(), element)
keystrokeMap[keystroke] ?= command for {command, keystroke} in mappings
keystrokeMap