/ src / menu-manager.coffee
menu-manager.coffee
  1  path = require 'path'
  2  
  3  _ = require 'underscore-plus'
  4  {ipcRenderer} = require 'electron'
  5  CSON = require 'season'
  6  fs = require 'fs-plus'
  7  {Disposable} = require 'event-kit'
  8  
  9  MenuHelpers = require './menu-helpers'
 10  
 11  platformMenu = require('../package.json')?._atomMenu?.menu
 12  
 13  # Extended: Provides a registry for menu items that you'd like to appear in the
 14  # application menu.
 15  #
 16  # An instance of this class is always available as the `atom.menu` global.
 17  #
 18  # ## Menu CSON Format
 19  #
 20  # Here is an example from the [tree-view](https://github.com/atom/tree-view/blob/master/menus/tree-view.cson):
 21  #
 22  # ```coffee
 23  # [
 24  #   {
 25  #     'label': 'View'
 26  #     'submenu': [
 27  #       { 'label': 'Toggle Tree View', 'command': 'tree-view:toggle' }
 28  #     ]
 29  #   }
 30  #   {
 31  #     'label': 'Packages'
 32  #     'submenu': [
 33  #       'label': 'Tree View'
 34  #       'submenu': [
 35  #         { 'label': 'Focus', 'command': 'tree-view:toggle-focus' }
 36  #         { 'label': 'Toggle', 'command': 'tree-view:toggle' }
 37  #         { 'label': 'Reveal Active File', 'command': 'tree-view:reveal-active-file' }
 38  #         { 'label': 'Toggle Tree Side', 'command': 'tree-view:toggle-side' }
 39  #       ]
 40  #     ]
 41  #   }
 42  # ]
 43  # ```
 44  #
 45  # Use in your package's menu `.cson` file requires that you place your menu
 46  # structure under a `menu` key.
 47  #
 48  # ```coffee
 49  # 'menu': [
 50  #   {
 51  #     'label': 'View'
 52  #     'submenu': [
 53  #       { 'label': 'Toggle Tree View', 'command': 'tree-view:toggle' }
 54  #     ]
 55  #   }
 56  # ]
 57  # ```
 58  #
 59  # See {::add} for more info about adding menu's directly.
 60  module.exports =
 61  class MenuManager
 62    constructor: ({@resourcePath, @keymapManager, @packageManager}) ->
 63      @initialized = false
 64      @pendingUpdateOperation = null
 65      @template = []
 66      @keymapManager.onDidLoadBundledKeymaps => @loadPlatformItems()
 67      @packageManager.onDidActivateInitialPackages => @sortPackagesMenu()
 68  
 69    initialize: ({@resourcePath}) ->
 70      @keymapManager.onDidReloadKeymap => @update()
 71      @update()
 72      @initialized = true
 73  
 74    # Public: Adds the given items to the application menu.
 75    #
 76    # ## Examples
 77    # ```coffee
 78    #   atom.menu.add [
 79    #     {
 80    #       label: 'Hello'
 81    #       submenu : [{label: 'World!', command: 'hello:world'}]
 82    #     }
 83    #   ]
 84    # ```
 85    #
 86    # * `items` An {Array} of menu item {Object}s containing the keys:
 87    #   * `label` The {String} menu label.
 88    #   * `submenu` An optional {Array} of sub menu items.
 89    #   * `command` An optional {String} command to trigger when the item is
 90    #     clicked.
 91    #
 92    # Returns a {Disposable} on which `.dispose()` can be called to remove the
 93    # added menu items.
 94    add: (items) ->
 95      items = _.deepClone(items)
 96  
 97      for item in items
 98        continue unless item.label? # TODO: Should we emit a warning here?
 99        @merge(@template, item)
100  
101      @update()
102      new Disposable => @remove(items)
103  
104    remove: (items) ->
105      @unmerge(@template, item) for item in items
106      @update()
107  
108    clear: ->
109      @template = []
110      @update()
111  
112    # Should the binding for the given selector be included in the menu
113    # commands.
114    #
115    # * `selector` A {String} selector to check.
116    #
117    # Returns a {Boolean}, true to include the selector, false otherwise.
118    includeSelector: (selector) ->
119      try
120        return true if document.body.webkitMatchesSelector(selector)
121      catch error
122        # Selector isn't valid
123        return false
124  
125      # Simulate an atom-text-editor element attached to a atom-workspace element attached
126      # to a body element that has the same classes as the current body element.
127      unless @testEditor?
128        # Use new document so that custom elements don't actually get created
129        testDocument = document.implementation.createDocument(document.namespaceURI, 'html')
130  
131        testBody = testDocument.createElement('body')
132        testBody.classList.add(@classesForElement(document.body)...)
133  
134        testWorkspace = testDocument.createElement('atom-workspace')
135        workspaceClasses = @classesForElement(document.body.querySelector('atom-workspace'))
136        workspaceClasses = ['workspace'] if workspaceClasses.length is 0
137        testWorkspace.classList.add(workspaceClasses...)
138  
139        testBody.appendChild(testWorkspace)
140  
141        @testEditor = testDocument.createElement('atom-text-editor')
142        @testEditor.classList.add('editor')
143        testWorkspace.appendChild(@testEditor)
144  
145      element = @testEditor
146      while element
147        return true if element.webkitMatchesSelector(selector)
148        element = element.parentElement
149  
150      false
151  
152    # Public: Refreshes the currently visible menu.
153    update: ->
154      return unless @initialized
155  
156      clearTimeout(@pendingUpdateOperation) if @pendingUpdateOperation?
157  
158      @pendingUpdateOperation = setTimeout(=>
159        unsetKeystrokes = new Set
160        for binding in @keymapManager.getKeyBindings()
161          if binding.command is 'unset!'
162            unsetKeystrokes.add(binding.keystrokes)
163  
164        keystrokesByCommand = {}
165        for binding in @keymapManager.getKeyBindings()
166          continue unless @includeSelector(binding.selector)
167          continue if unsetKeystrokes.has(binding.keystrokes)
168          continue if process.platform is 'darwin' and /^alt-(shift-)?.$/.test(binding.keystrokes)
169          continue if process.platform is 'win32' and /^ctrl-alt-(shift-)?.$/.test(binding.keystrokes)
170          keystrokesByCommand[binding.command] ?= []
171          keystrokesByCommand[binding.command].unshift binding.keystrokes
172  
173        @sendToBrowserProcess(@template, keystrokesByCommand)
174      , 1)
175  
176    loadPlatformItems: ->
177      if platformMenu?
178        @add(platformMenu)
179      else
180        menusDirPath = path.join(@resourcePath, 'menus')
181        platformMenuPath = fs.resolve(menusDirPath, process.platform, ['cson', 'json'])
182        {menu} = CSON.readFileSync(platformMenuPath)
183        @add(menu)
184  
185    # Merges an item in a submenu aware way such that new items are always
186    # appended to the bottom of existing menus where possible.
187    merge: (menu, item) ->
188      MenuHelpers.merge(menu, item)
189  
190    unmerge: (menu, item) ->
191      MenuHelpers.unmerge(menu, item)
192  
193    sendToBrowserProcess: (template, keystrokesByCommand) ->
194      ipcRenderer.send 'update-application-menu', template, keystrokesByCommand
195  
196    # Get an {Array} of {String} classes for the given element.
197    classesForElement: (element) ->
198      if classList = element?.classList
199        Array::slice.apply(classList)
200      else
201        []
202  
203    sortPackagesMenu: ->
204      packagesMenu = _.find @template, ({label}) -> MenuHelpers.normalizeLabel(label) is 'Packages'
205      return unless packagesMenu?.submenu?
206  
207      packagesMenu.submenu.sort (item1, item2) ->
208        if item1.label and item2.label
209          MenuHelpers.normalizeLabel(item1.label).localeCompare(MenuHelpers.normalizeLabel(item2.label))
210        else
211          0
212      @update()