Merge pull request #3587 from atom/ns-workspace-view-providers

Add view provider API to Workspace
This commit is contained in:
Nathan Sobo 2014-09-23 13:08:00 -06:00
commit 3dbaa0679b
20 changed files with 328 additions and 61 deletions

View File

@ -7,7 +7,7 @@ describe "editorView.", ->
beforeEach ->
atom.workspaceViewParentSelector = '#jasmine-content'
atom.workspaceView = new WorkspaceView
atom.workspaceView = atom.workspace.getView(atom.workspace).__spacePenView
atom.workspaceView.attachToDom()
atom.workspaceView.width(1024)

View File

@ -75,7 +75,7 @@
"autocomplete": "0.32.0",
"autoflow": "0.18.0",
"autosave": "0.17.0",
"background-tips": "0.16.0",
"background-tips": "0.17.0",
"bookmarks": "0.28.0",
"bracket-matcher": "0.58.0",
"command-palette": "0.25.0",

View File

@ -6,7 +6,7 @@ ThemeManager = require '../src/theme-manager'
describe "the `atom` global", ->
beforeEach ->
atom.workspaceView = new WorkspaceView
atom.workspaceView = atom.workspace.getView(atom.workspace).__spacePenView
describe 'window sizing methods', ->
describe '::getPosition and ::setPosition', ->

View File

@ -99,6 +99,13 @@ describe "Pane", ->
pane.addItem(item, 1)
expect(events).toEqual [{item, index: 1}]
it "throws an exception if the item is already present on a pane", ->
item = new Item("A")
pane1 = new Pane(items: [item])
container = new PaneContainer(root: pane1)
pane2 = pane1.splitRight()
expect(-> pane2.addItem(item)).toThrow()
describe "::activateItem(item)", ->
pane = null

View File

@ -7,7 +7,7 @@ path = require 'path'
temp = require 'temp'
describe "PaneView", ->
[container, view1, view2, editor1, editor2, pane, paneModel] = []
[container, containerModel, view1, view2, editor1, editor2, pane, paneModel] = []
class TestView extends View
@deserialize: ({id, text}) -> new TestView({id, text})
@ -25,6 +25,7 @@ describe "PaneView", ->
beforeEach ->
atom.deserializers.add(TestView)
container = new PaneContainerView
containerModel = container.model
view1 = new TestView(id: 'view-1', text: 'View 1')
view2 = new TestView(id: 'view-2', text: 'View 2')
waitsForPromise ->
@ -216,7 +217,7 @@ describe "PaneView", ->
beforeEach ->
pane2Model = paneModel.splitRight() # Can't destroy the last pane, so we add another
pane2 = pane2Model._view
pane2 = containerModel.getView(pane2Model).__spacePenView
it "triggers a 'pane:removed' event with the pane", ->
removedHandler = jasmine.createSpy("removedHandler")
@ -249,7 +250,7 @@ describe "PaneView", ->
beforeEach ->
pane2Model = paneModel.splitRight(items: [pane.copyActiveItem()])
pane2 = pane2Model._view
pane2 = containerModel.getView(pane2Model).__spacePenView
expect(pane2Model.isActive()).toBe true
it "adds or removes the .active class as appropriate", ->
@ -296,7 +297,8 @@ describe "PaneView", ->
pane2Model = pane1Model.splitRight(items: [pane1Model.copyActiveItem()])
pane3Model = pane2Model.splitDown(items: [pane2Model.copyActiveItem()])
pane2 = pane2Model._view
pane3 = pane3Model._view
pane2 = containerModel.getView(pane2Model).__spacePenView
pane3 = containerModel.getView(pane3Model).__spacePenView
expect(container.find('> .pane-row > .pane').toArray()).toEqual [pane1[0]]
expect(container.find('> .pane-row > .pane-column > .pane').toArray()).toEqual [pane2[0], pane3[0]]

View File

@ -209,7 +209,7 @@ describe "ThemeManager", ->
describe "base stylesheet loading", ->
beforeEach ->
atom.workspaceView = new WorkspaceView
atom.workspaceView = atom.workspace.getView(atom.workspace).__spacePenView
atom.workspaceView.append $$ -> @div class: 'editor'
atom.workspaceView.attachToDom()

View File

@ -0,0 +1,107 @@
ViewRegistry = require '../src/view-registry'
{View} = require '../src/space-pen-extensions'
describe "ViewRegistry", ->
registry = null
beforeEach ->
registry = new ViewRegistry
describe "::getView(object)", ->
describe "when passed a DOM node", ->
it "returns the given DOM node", ->
node = document.createElement('div')
expect(registry.getView(node)).toBe node
describe "when passed a SpacePen view", ->
it "returns the root node of the view with a __spacePenView property pointing at the SpacePen view", ->
class TestView extends View
@content: -> @div "Hello"
view = new TestView
node = registry.getView(view)
expect(node.textContent).toBe "Hello"
expect(node.__spacePenView).toBe view
describe "when passed a model object", ->
describe "when a view provider is registered matching the object's constructor", ->
describe "when the provider has a viewConstructor property", ->
it "constructs a view element and assigns the model on it", ->
class TestModel
class TestModelSubclass extends TestModel
class TestView
setModel: (@model) ->
model = new TestModel
registry.addViewProvider
modelConstructor: TestModel
viewConstructor: TestView
view = registry.getView(model)
expect(view instanceof TestView).toBe true
expect(view.model).toBe model
subclassModel = new TestModelSubclass
view2 = registry.getView(subclassModel)
expect(view2 instanceof TestView).toBe true
expect(view2.model).toBe subclassModel
describe "when the provider has a createView method", ->
it "constructs a view element by calling the createView method with the model", ->
class TestModel
class TestView
setModel: (@model) ->
registry.addViewProvider
modelConstructor: TestModel
createView: (model) ->
view = new TestView
view.setModel(model)
view
model = new TestModel
view = registry.getView(model)
expect(view instanceof TestView).toBe true
expect(view.model).toBe model
describe "when no view provider is registered for the object's constructor", ->
describe "when the object has a .getViewClass() method", ->
it "builds an instance of the view class with the model, then returns its root node with a __spacePenView property pointing at the view", ->
class TestView extends View
@content: (model) -> @div model.name
initialize: (@model) ->
class TestModel
constructor: (@name) ->
getViewClass: -> TestView
model = new TestModel("hello")
node = registry.getView(model)
expect(node.textContent).toBe "hello"
view = node.__spacePenView
expect(view instanceof TestView).toBe true
expect(view.model).toBe model
# returns the same DOM node for repeated calls
expect(registry.getView(model)).toBe node
describe "when the object has no .getViewClass() method", ->
it "throws an exception", ->
expect(-> registry.getView(new Object)).toThrow()
describe "::addViewProvider(providerSpec)", ->
it "returns a disposable that can be used to remove the provider", ->
class TestModel
class TestView
setModel: (@model) ->
disposable = registry.addViewProvider
modelConstructor: TestModel
viewConstructor: TestView
expect(registry.getView(new TestModel) instanceof TestView).toBe true
disposable.dispose()
expect(-> registry.getView(new TestModel)).toThrow()

View File

@ -1,4 +1,5 @@
Workspace = require '../src/workspace'
{View} = require '../src/space-pen-extensions'
describe "Workspace", ->
workspace = null

View File

@ -13,7 +13,7 @@ describe "WorkspaceView", ->
atom.project.setPath(atom.project.resolve('dir'))
pathToOpen = atom.project.resolve('a')
atom.workspace = new Workspace
atom.workspaceView = new WorkspaceView(atom.workspace)
atom.workspaceView = atom.workspace.getView(atom.workspace).__spacePenView
atom.workspaceView.enableKeymap()
atom.workspaceView.focus()
@ -29,7 +29,7 @@ describe "WorkspaceView", ->
atom.workspaceView.remove()
atom.project = atom.deserializers.deserialize(projectState)
atom.workspace = Workspace.deserialize(workspaceState)
atom.workspaceView = new WorkspaceView(atom.workspace)
atom.workspaceView = atom.workspace.getView(atom.workspace).__spacePenView
atom.workspaceView.attachToDom()
describe "when the serialized WorkspaceView has an unsaved buffer", ->
@ -61,7 +61,7 @@ describe "WorkspaceView", ->
waitsForPromise ->
atom.workspace.open('b').then (editor) ->
pane2.activateItem(editor)
pane2.activateItem(editor.copy())
waitsForPromise ->
atom.workspace.open('../sample.js').then (editor) ->
@ -185,7 +185,8 @@ describe "WorkspaceView", ->
describe "when the root view is deserialized", ->
it "updates the title to contain the project's path", ->
workspaceView2 = new WorkspaceView(atom.workspace.testSerialization())
workspace2 = atom.workspace.testSerialization()
workspaceView2 = workspace2.getView(workspace2).__spacePenView
item = atom.workspace.getActivePaneItem()
expect(workspaceView2.title).toBe "#{item.getTitle()} - #{atom.project.getPath()}"
workspaceView2.remove()

View File

@ -582,7 +582,7 @@ class Atom extends Model
startTime = Date.now()
@workspace = Workspace.deserialize(@state.workspace) ? new Workspace
@workspaceView = new WorkspaceView(@workspace)
@workspaceView = @workspace.getView(@workspace).__spacePenView
@deserializeTimings.workspace = Date.now() - startTime
@keymaps.defaultTarget = @workspaceView[0]

15
src/item-registry.coffee Normal file
View File

@ -0,0 +1,15 @@
module.exports =
class ItemRegistry
constructor: ->
@items = new WeakSet
addItem: (item) ->
if @hasItem(item)
throw new Error("The workspace can only contain one instance of item #{item}")
@items.add(item)
removeItem: (item) ->
@items.delete(item)
hasItem: (item) ->
@items.has(item)

View File

@ -16,10 +16,6 @@ class PaneAxisView extends View
afterAttach: ->
@container = @closest('.panes').view()
viewForModel: (model) ->
viewClass = model.getViewClass()
model._view ?= new viewClass(model)
onChildReplaced: ({index, oldChild, newChild}) =>
focusedElement = document.activeElement if @hasFocus()
@onChildRemoved({child: oldChild, index})
@ -27,11 +23,11 @@ class PaneAxisView extends View
focusedElement?.focus() if document.activeElement is document.body
onChildAdded: ({child, index}) =>
view = @viewForModel(child)
view = @model.getView(child).__spacePenView
@insertAt(index, view)
onChildRemoved: ({child}) =>
view = @viewForModel(child)
view = @model.getView(child).__spacePenView
view.detach()
PaneView ?= require './pane-view'
if view instanceof PaneView and view.model.isDestroyed()

View File

@ -46,6 +46,9 @@ class PaneAxis extends Model
else
PaneRowView ?= require './pane-row-view'
getView: (object) ->
@container.getView(object)
getChildren: -> @children.slice()
getPanes: ->

View File

@ -26,11 +26,6 @@ class PaneContainerView extends View
@subscriptions.add @model.observeRoot(@onRootChanged)
@subscriptions.add @model.onDidChangeActivePaneItem(@onActivePaneItemChanged)
viewForModel: (model) ->
if model?
viewClass = model.getViewClass()
model._view ?= new viewClass(model)
getRoot: ->
@children().first().view()
@ -42,7 +37,7 @@ class PaneContainerView extends View
@trigger 'pane:removed', [oldRoot]
oldRoot?.detach()
if root?
view = @viewForModel(root)
view = @model.getView(root).__spacePenView
@append(view)
focusedElement?.focus()
else
@ -88,7 +83,7 @@ class PaneContainerView extends View
@getActivePaneView()
getActivePaneView: ->
@viewForModel(@model.activePane)
@model.getView(@model.getActivePane()).__spacePenView
getActivePaneItem: ->
@model.getActivePaneItem()
@ -97,7 +92,7 @@ class PaneContainerView extends View
@getActivePaneView()?.activeView
paneForUri: (uri) ->
@viewForModel(@model.paneForUri(uri))
@model.getView(@model.paneForUri(uri)).__spacePenView
focusNextPaneView: ->
@model.activateNextPane()

View File

@ -3,6 +3,9 @@
{Emitter, CompositeDisposable} = require 'event-kit'
Serializable = require 'serializable'
Pane = require './pane'
ViewRegistry = require './view-registry'
ItemRegistry = require './item-registry'
PaneContainerView = null
module.exports =
class PaneContainer extends Model
@ -27,6 +30,8 @@ class PaneContainer extends Model
@emitter = new Emitter
@subscriptions = new CompositeDisposable
@viewRegistry = params?.viewRegistry ? new ViewRegistry
@itemRegistry = new ItemRegistry
@setRoot(params?.root ? new Pane)
@destroyEmptyPanes() if params?.destroyEmptyPanes
@ -43,6 +48,12 @@ class PaneContainer extends Model
root: @root?.serialize()
activePaneId: @activePane.id
getViewClass: ->
PaneContainerView ?= require './pane-container-view'
getView: (object) ->
@viewRegistry.getView(object)
onDidChangeRoot: (fn) ->
@emitter.on 'did-change-root', fn
@ -169,7 +180,17 @@ class PaneContainer extends Model
monitorPaneItems: ->
@subscriptions.add @observePanes (pane) =>
for item, index in pane.getItems()
@emitter.emit 'did-add-pane-item', {item, pane, index}
@addedPaneItem(item, pane, index)
pane.onDidAddItem ({item, index}) =>
@emitter.emit 'did-add-pane-item', {item, pane, index}
@addedPaneItem(item, pane, index)
pane.onDidRemoveItem ({item}) =>
@removedPaneItem(item)
addedPaneItem: (item, pane, index) ->
@itemRegistry.addItem(item)
@emitter.emit 'did-add-pane-item', {item, pane, index}
removedPaneItem: (item) ->
@itemRegistry.removeItem(item)

View File

@ -33,17 +33,9 @@ class PaneView extends View
previousActiveItem: null
initialize: (args...) ->
initialize: (@model) ->
@subscriptions = new CompositeDisposable
if args[0] instanceof Pane
@model = args[0]
else
@model = new Pane(items: args)
@model._view = this
@onItemAdded(item) for item in @items
@viewsByItem = new WeakMap()
@handleEvents()
handleEvents: ->
@ -175,7 +167,7 @@ class PaneView extends View
item.on('modified-status-changed', @activeItemModifiedChanged)
@activeItemDisposables.add(disposable) if disposable?.dispose?
view = @viewForItem(item)
view = @model.getView(item).__spacePenView
otherView.hide() for otherView in @itemViews.children().not(view).views()
@itemViews.append(view) unless view.parent().is(@itemViews)
view.show() if @attached
@ -189,8 +181,8 @@ class PaneView extends View
onItemRemoved: ({item, index, destroyed}) =>
if item instanceof $
viewToRemove = item
else if viewToRemove = @viewsByItem.get(item)
@viewsByItem.delete(item)
else
viewToRemove = @model.getView(item).__spacePenView
if viewToRemove?
if destroyed
@ -213,27 +205,15 @@ class PaneView extends View
activeItemModifiedChanged: =>
@trigger 'pane:active-item-modified-status-changed'
viewForItem: (item) ->
return unless item?
if item instanceof $
item
else if view = @viewsByItem.get(item)
view
else
viewClass = item.getViewClass()
view = new viewClass(item)
@viewsByItem.set(item, view)
view
@::accessor 'activeView', -> @model.getView(@activeItem)?.__spacePenView
@::accessor 'activeView', -> @viewForItem(@activeItem)
splitLeft: (items...) -> @model.getView(@model.splitLeft({items})).__spacePenView
splitLeft: (items...) -> @model.splitLeft({items})._view
splitRight: (items...) -> @model.getView(@model.splitRight({items})).__spacePenView
splitRight: (items...) -> @model.splitRight({items})._view
splitUp: (items...) -> @model.getView(@model.splitUp({items})).__spacePenView
splitUp: (items...) -> @model.splitUp({items})._view
splitDown: (items...) -> @model.splitDown({items})._view
splitDown: (items...) -> @model.getView(@model.splitDown({items})).__spacePenView
# Public: Get the container view housing this pane.
#

View File

@ -56,6 +56,9 @@ class Pane extends Model
# Called by the view layer to construct a view for this model.
getViewClass: -> PaneView ?= require './pane-view'
getView: (object) ->
@container.getView(object)
getParent: -> @parent
setParent: (@parent) -> @parent
@ -377,8 +380,8 @@ class Pane extends Model
# * `index` {Number} indicating the index to which to move the item in the
# given pane.
moveItemToPane: (item, pane, index) ->
pane.addItem(item, index)
@removeItem(item)
pane.addItem(item, index)
# Public: Destroy the active item and activate the next item.
destroyActiveItem: ->

45
src/view-registry.coffee Normal file
View File

@ -0,0 +1,45 @@
{Disposable} = require 'event-kit'
{jQuery} = require './space-pen-extensions'
module.exports =
class ViewRegistry
constructor: ->
@views = new WeakMap
@providers = []
addViewProvider: (providerSpec) ->
@providers.push(providerSpec)
new Disposable =>
@providers = @providers.filter (provider) -> provider isnt providerSpec
getView: (object) ->
return unless object?
if view = @views.get(object)
view
else
view = @createView(object)
@views.set(object, view)
view
createView: (object) ->
if object instanceof HTMLElement
object
else if object instanceof jQuery
object[0].__spacePenView ?= object
object[0]
else if provider = @findProvider(object)
element = provider.createView?(object)
unless element?
element = new provider.viewConstructor
element.setModel(object)
element
else if viewConstructor = object?.getViewClass?()
view = new viewConstructor(object)
view[0].__spacePenView ?= view
view[0]
else
throw new Error("Can't create a view for #{object.constructor.name} instance. Please register a view provider.")
findProvider: (object) ->
@providers.find ({modelConstructor}) -> object instanceof modelConstructor

View File

@ -87,7 +87,7 @@ class WorkspaceView extends View
@element.getModel = -> model
atom.commands.setRootNode(@[0])
panes = new PaneContainerView(@model.paneContainer)
panes = @model.getView(@model.paneContainer).__spacePenView
@panes.replaceWith(panes)
@panes = panes

View File

@ -9,6 +9,8 @@ Delegator = require 'delegato'
Editor = require './editor'
PaneContainer = require './pane-container'
Pane = require './pane'
ViewRegistry = require './view-registry'
WorkspaceView = null
# Essential: Represents the state of the user interface for the entire window.
# An instance of this class is available via the `atom.workspace` global.
@ -27,7 +29,7 @@ class Workspace extends Model
@delegatesProperty 'activePane', 'activePaneItem', toProperty: 'paneContainer'
@properties
paneContainer: -> new PaneContainer
paneContainer: null
fullScreen: false
destroyedItemUris: -> []
@ -37,6 +39,8 @@ class Workspace extends Model
@emitter = new Emitter
@openers = []
@viewRegistry ?= new ViewRegistry
@paneContainer ?= new PaneContainer({@viewRegistry})
@paneContainer.onDidDestroyPaneItem(@onPaneItemDestroyed)
@registerOpener (filePath) =>
@ -55,6 +59,8 @@ class Workspace extends Model
for packageName in params.packagesWithActiveGrammars ? []
atom.packages.getLoadedPackage(packageName)?.loadGrammarsSync()
params.viewRegistry = new ViewRegistry
params.paneContainer.viewRegistry = params.viewRegistry
params.paneContainer = PaneContainer.deserialize(params.paneContainer)
params
@ -64,6 +70,9 @@ class Workspace extends Model
fullScreen: atom.isFullScreen()
packagesWithActiveGrammars: @getPackageNamesWithActiveGrammars()
getViewClass: ->
WorkspaceView ?= require './workspace-view'
getPackageNamesWithActiveGrammars: ->
packageNames = []
addGrammar = ({includedGrammarScopes, packageName}={}) ->
@ -488,3 +497,85 @@ class Workspace extends Model
# Called by Model superclass when destroyed
destroyed: ->
@paneContainer.destroy()
###
Section: View Management
###
# Essential: Get the view associated with an object in the workspace.
#
# If you're just *using* the workspace, you shouldn't need to access the view
# layer, but view layer access may be necessary if you want to perform DOM
# manipulation that isn't supported via the model API.
#
# ## Examples
#
# ### Getting An Editor View
# ```coffee
# textEditor = atom.workspace.getActiveTextEditor()
# textEditorView = atom.workspace.getView(textEditor)
# ```
#
# ### Getting A Pane View
# ```coffee
# pane = atom.workspace.getActivePane()
# paneView = atom.workspace.getView(pane)
# ```
#
# ### Getting The Workspace View
#
# ```coffee
# workspaceView = atom.workspace.getView(atom.workspace)
# ```
#
# * `object` The object for which you want to retrieve a view. This can be a
# pane item, a pane, or the workspace itself.
#
# Returns a DOM element.
getView: (object) ->
@viewRegistry.getView(object)
# Essential: Add a provider that will be used to construct views in the
# workspace's view layer based on model objects in its model layer.
#
# If you're adding your own kind of pane item, a good strategy for all but the
# simplest items is to separate the model and the view. The model handles
# application logic and is the primary point of API interaction. The view
# just handles presentation.
#
# Use view providers to inform the workspace how your model objects should be
# presented in the DOM. A view provider must always return a DOM node, which
# makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/)
# an ideal tool for implementing views in Atom.
#
# ## Example
#
# Text editors are divided into a model and a view layer, so when you interact
# with methods like `atom.workspace.getActiveTextEditor()` you're only going
# to get the model object. We display text editors on screen by teaching the
# workspace what view constructor it should use to represent them:
#
# ```coffee
# atom.workspace.addViewProvider
# modelConstructor: TextEditor
# viewConstructor: TextEditorElement
# ```
#
# * `providerSpec` {Object} containing the following keys:
# * `modelConstructor` Constructor {Function} for your model.
# * `viewConstructor` (Optional) Constructor {Function} for your view. It
# should be a subclass of `HTMLElement` (that is, your view should be a
# DOM node) and have a `::setModel()` method which will be called
# immediately after construction. If you don't supply this property, you
# must supply the `createView` property with a function that never returns
# `undefined`.
# * `createView` (Optional) Factory {Function} that must return a subclass
# of `HTMLElement` or `undefined`. If this property is not present or the
# function returns `undefined`, the view provider will fall back to the
# `viewConstructor` property. If you don't provide this property, you must
# provider a `viewConstructor` property.
#
# Returns a {Disposable} on which `.dispose()` can be called to remove the
# added provider.
addViewProvider: (providerSpec) ->
@viewRegistry.addViewProvider(providerSpec)