diff --git a/spec/pane-container-model-spec.coffee b/spec/pane-container-model-spec.coffee index 027272218..7f1fff2d3 100644 --- a/spec/pane-container-model-spec.coffee +++ b/spec/pane-container-model-spec.coffee @@ -1,5 +1,5 @@ PaneContainerModel = require '../src/pane-container-model' -PaneModel = require '../src/pane-model' +Pane = require '../src/pane' describe "PaneContainerModel", -> describe "serialization", -> @@ -12,7 +12,7 @@ describe "PaneContainerModel", -> @deserialize: -> new this serialize: -> deserializer: 'Item' - pane1A = new PaneModel(items: [new Item]) + pane1A = new Pane(items: [new Item]) containerA = new PaneContainerModel(root: pane1A) pane2A = pane1A.splitRight(items: [new Item]) pane3A = pane2A.splitDown(items: [new Item]) @@ -36,7 +36,7 @@ describe "PaneContainerModel", -> [container, pane1, pane2] = [] beforeEach -> - pane1 = new PaneModel + pane1 = new Pane container = new PaneContainerModel(root: pane1) it "references the first pane if no pane has been made active", -> @@ -67,7 +67,7 @@ describe "PaneContainerModel", -> [container, pane, surrenderedFocusHandler] = [] beforeEach -> - pane = new PaneModel + pane = new Pane container = new PaneContainerModel(root: pane) container.on 'surrendered-focus', surrenderedFocusHandler = jasmine.createSpy("surrenderedFocusHandler") diff --git a/spec/pane-model-spec.coffee b/spec/pane-model-spec.coffee index 6eb897b06..bb7d6e7eb 100644 --- a/spec/pane-model-spec.coffee +++ b/spec/pane-model-spec.coffee @@ -1,14 +1,14 @@ {Model} = require 'theorist' -PaneModel = require '../src/pane-model' +Pane = require '../src/pane' PaneAxisModel = require '../src/pane-axis-model' PaneContainerModel = require '../src/pane-container-model' -describe "PaneModel", -> +describe "Pane", -> describe "split methods", -> [pane1, container] = [] beforeEach -> - pane1 = new PaneModel(items: ["A"]) + pane1 = new Pane(items: ["A"]) container = new PaneContainerModel(root: pane1) describe "::splitLeft(params)", -> @@ -87,14 +87,14 @@ describe "PaneModel", -> describe "::destroyItem(item)", -> describe "when the last item is destroyed", -> it "destroys the pane", -> - pane = new PaneModel(items: ["A", "B"]) + pane = new Pane(items: ["A", "B"]) pane.destroyItem("A") pane.destroyItem("B") expect(pane.isDestroyed()).toBe true describe "when an item emits a destroyed event", -> it "removes it from the list of items", -> - pane = new PaneModel(items: [new Model, new Model, new Model]) + pane = new Pane(items: [new Model, new Model, new Model]) [item1, item2, item3] = pane.items pane.items[1].destroy() expect(pane.items).toEqual [item1, item3] @@ -103,7 +103,7 @@ describe "PaneModel", -> [pane1, container] = [] beforeEach -> - pane1 = new PaneModel(items: [new Model, new Model]) + pane1 = new Pane(items: [new Model, new Model]) container = new PaneContainerModel(root: pane1) it "destroys the pane's destroyable items", -> diff --git a/src/pane-container-model.coffee b/src/pane-container-model.coffee index f7fa4c0c6..eb69a12f7 100644 --- a/src/pane-container-model.coffee +++ b/src/pane-container-model.coffee @@ -1,6 +1,6 @@ {Model} = require 'theorist' Serializable = require 'serializable' -PaneModel = require './pane-model' +Pane = require './pane' module.exports = class PaneContainerModel extends Model @@ -56,7 +56,7 @@ class PaneContainerModel extends Model root.parent = this root.container = this - if root instanceof PaneModel + if root instanceof Pane @activePane ?= root @subscribe root, 'destroyed', => @activePane = null diff --git a/src/pane-view.coffee b/src/pane-view.coffee index dbab830b2..fe9da3f31 100644 --- a/src/pane-view.coffee +++ b/src/pane-view.coffee @@ -2,7 +2,7 @@ Serializable = require 'serializable' Delegator = require 'delegato' -PaneModel = require './pane-model' +Pane = require './pane' # Public: A container which can contains multiple items to be switched between. # @@ -18,7 +18,7 @@ class PaneView extends View @version: 1 @deserialize: (state) -> - new this(PaneModel.deserialize(state.model)) + new this(Pane.deserialize(state.model)) @content: (wrappedView) -> @div class: 'pane', tabindex: -1, => @@ -36,10 +36,10 @@ class PaneView extends View # Private: initialize: (args...) -> - if args[0] instanceof PaneModel + if args[0] instanceof Pane @model = args[0] else - @model = new PaneModel(items: args) + @model = new Pane(items: args) @model._view = this @onItemAdded(item) for item in @items @@ -86,7 +86,7 @@ class PaneView extends View @command 'pane:close-other-items', => @destroyInactiveItems() deserializeParams: (params) -> - params.model = PaneModel.deserialize(params.model) + params.model = Pane.deserialize(params.model) params serializeParams: -> diff --git a/src/pane.coffee b/src/pane.coffee new file mode 100644 index 000000000..380c6506f --- /dev/null +++ b/src/pane.coffee @@ -0,0 +1,307 @@ +{find, compact, extend} = require 'underscore-plus' +{dirname} = require 'path' +{Model, Sequence} = require 'theorist' +Serializable = require 'serializable' +PaneAxisModel = require './pane-axis-model' +PaneView = null + +# Public: A container for multiple items, one of which is *active* at a given +# time. With the default packages, a tab is displayed for each item and the +# active item's view is displayed. +module.exports = +class Pane extends Model + atom.deserializers.add(this) + Serializable.includeInto(this) + + @properties + container: null + activeItem: null + focused: false + + # Public: Only one pane is considered *active* at a time. A pane is activated + # when it is focused, and when focus returns to the pane container after + # moving to another element such as a panel, it returns to the active pane. + @behavior 'active', -> + @$container + .switch((container) -> container?.$activePane) + .map((activePane) => activePane is this) + .distinctUntilChanged() + + # Private: + constructor: (params) -> + super + + @items = Sequence.fromArray(params?.items ? []) + @activeItem ?= @items[0] + + @subscribe @items.onEach (item) => + if typeof item.on is 'function' + @subscribe item, 'destroyed', => @removeItem(item) + + @subscribe @items.onRemoval (item, index) => + @unsubscribe item if typeof item.on is 'function' + + @activate() if params?.active + + # Private: Called by the Serializable mixin during serialization. + serializeParams: -> + items: compact(@items.map((item) -> item.serialize?())) + activeItemUri: @activeItem?.getUri?() + focused: @focused + active: @active + + # Private: Called by the Serializable mixin during deserialization. + deserializeParams: (params) -> + {items, activeItemUri} = params + params.items = compact(items.map (itemState) -> atom.deserializers.deserialize(itemState)) + params.activeItem = find params.items, (item) -> item.getUri?() is activeItemUri + params + + # Private: Called by the view layer to construct a view for this model. + getViewClass: -> PaneView ?= require './pane-view' + + isActive: -> @active + + # Private: Called by the view layer to indicate that the pane has gained focus. + focus: -> + @focused = true + @activate() unless @isActive() + + # Private: Called by the view layer to indicate that the pane has lost focus. + blur: -> + @focused = false + true # if this is called from an event handler, don't cancel it + + # Public: Makes this pane the *active* pane, causing it to gain focus + # immediately. + activate: -> + @container?.activePane = this + @emit 'activated' + + # Private: + getPanes: -> [this] + + # Public: + getItems: -> + @items.slice() + + # Public: Returns the item at the specified index. + itemAtIndex: (index) -> + @items[index] + + # Public: Makes the next item active. + activateNextItem: -> + index = @getActiveItemIndex() + if index < @items.length - 1 + @activateItemAtIndex(index + 1) + else + @activateItemAtIndex(0) + + # Public: Makes the previous item active. + activatePreviousItem: -> + index = @getActiveItemIndex() + if index > 0 + @activateItemAtIndex(index - 1) + else + @activateItemAtIndex(@items.length - 1) + + # Public: Returns the index of the current active item. + getActiveItemIndex: -> + @items.indexOf(@activeItem) + + # Public: Makes the item at the given index active. + activateItemAtIndex: (index) -> + @activateItem(@itemAtIndex(index)) + + # Public: Makes the given item active, adding the item if necessary. + activateItem: (item) -> + if item? + @addItem(item) + @activeItem = item + + # Public: Adds the item to the pane. + # + # * item: + # The item to add. It can be a model with an associated view or a view. + # * index: + # An optional index at which to add the item. If omitted, the item is + # added to the end. + # + # Returns the added item + addItem: (item, index=@getActiveItemIndex() + 1) -> + return if item in @items + + @items.splice(index, 0, item) + @emit 'item-added', item, index + item + + # Private: + removeItem: (item, destroying) -> + index = @items.indexOf(item) + return if index is -1 + @activateNextItem() if item is @activeItem and @items.length > 1 + @items.splice(index, 1) + @emit 'item-removed', item, index, destroying + @destroy() if @items.length is 0 + + # Public: Moves the given item to the specified index. + moveItem: (item, newIndex) -> + oldIndex = @items.indexOf(item) + @items.splice(oldIndex, 1) + @items.splice(newIndex, 0, item) + @emit 'item-moved', item, newIndex + + # Public: Moves the given item to the given index at another pane. + moveItemToPane: (item, pane, index) -> + pane.addItem(item, index) + @removeItem(item) + + # Public: Destroys the currently active item and make the next item active. + destroyActiveItem: -> + @destroyItem(@activeItem) + false + + # Public: Destroys the given item. If it is the active item, activate the next + # one. If this is the last item, also destroys the pane. + destroyItem: (item) -> + @emit 'before-item-destroyed', item + if @promptToSaveItem(item) + @emit 'item-destroyed', item + @removeItem(item, true) + item.destroy?() + true + else + false + + # Public: Destroys all items and destroys the pane. + destroyItems: -> + @destroyItem(item) for item in @getItems() + + # Public: Destroys all items but the active one. + destroyInactiveItems: -> + @destroyItem(item) for item in @getItems() when item isnt @activeItem + + # Private: Called by model superclass. + destroyed: -> + @container.activateNextPane() if @isActive() + item.destroy?() for item in @items.slice() + + # Public: Prompts the user to save the given item if it can be saved and is + # currently unsaved. + promptToSaveItem: (item) -> + return true unless item.shouldPromptToSave?() + + uri = item.getUri() + chosen = atom.confirm + message: "'#{item.getTitle?() ? item.getUri()}' has changes, do you want to save them?" + detailedMessage: "Your changes will be lost if you close this item without saving." + buttons: ["Save", "Cancel", "Don't Save"] + + switch chosen + when 0 then @saveItem(item, -> true) + when 1 then false + when 2 then true + + # Public: Saves the active item. + saveActiveItem: -> + @saveItem(@activeItem) + + # Public: Saves the active item at a prompted-for location. + saveActiveItemAs: -> + @saveItemAs(@activeItem) + + # Public: Saves the specified item. + # + # * item: The item to save. + # * nextAction: An optional function which will be called after the item is saved. + saveItem: (item, nextAction) -> + if item.getUri?() + item.save?() + nextAction?() + else + @saveItemAs(item, nextAction) + + # Public: Saves the given item at a prompted-for location. + # + # * item: The item to save. + # * nextAction: An optional function which will be called after the item is saved. + saveItemAs: (item, nextAction) -> + return unless item.saveAs? + + itemPath = item.getPath?() + itemPath = dirname(itemPath) if itemPath + path = atom.showSaveDialogSync(itemPath) + if path + item.saveAs(path) + nextAction?() + + # Public: Saves all items. + saveItems: -> + @saveItem(item) for item in @getItems() + + # Public: Returns the first item that matches the given URI or undefined if + # none exists. + itemForUri: (uri) -> + find @items, (item) -> item.getUri?() is uri + + # Public: Activates the first item that matches the given URI. Returns a + # boolean indicating whether a matching item was found. + activateItemForUri: (uri) -> + if item = @itemForUri(uri) + @activateItem(item) + true + else + false + + # Private: + copyActiveItem: -> + @activeItem.copy?() ? atom.deserializers.deserialize(@activeItem.serialize()) + + # Public: Creates a new pane to the left of the receiver. + # + # * params: + # + items: An optional array of items with which to construct the new pane. + # + # Returns the new {Pane}. + splitLeft: (params) -> + @split('horizontal', 'before', params) + + # Public: Creates a new pane to the right of the receiver. + # + # * params: + # + items: An optional array of items with which to construct the new pane. + # + # Returns the new {Pane}. + splitRight: (params) -> + @split('horizontal', 'after', params) + + # Public: Creates a new pane above the receiver. + # + # * params: + # + items: An optional array of items with which to construct the new pane. + # + # Returns the new {Pane}. + splitUp: (params) -> + @split('vertical', 'before', params) + + # Public: Creates a new pane below the receiver. + # + # * params: + # + items: An optional array of items with which to construct the new pane. + # + # Returns the new {Pane}. + splitDown: (params) -> + @split('vertical', 'after', params) + + # Private: + split: (orientation, side, params) -> + if @parent.orientation isnt orientation + @parent.replaceChild(this, new PaneAxisModel({@container, orientation, children: [this]})) + + newPane = new @constructor(extend({focused: true}, params)) + switch side + when 'before' then @parent.insertChildBefore(this, newPane) + when 'after' then @parent.insertChildAfter(this, newPane) + + newPane.activate() + newPane