Let's dive in by creating a new Griffon application and creating a Document MVC group:
griffon create-app MultiDocumentExample cd MultiDocumentExample griffon create-mvc Document
In our application model, MultiDocumentExampleModel.groovy, we need to add some properties to keep track of open and active documents. We also need a separate 'state' class (more on that later):
import groovy.beans.Bindable class MultiDocumentExampleModel { @Bindable Map activeDocument = null List openDocuments = [] DocumentState state = new DocumentState() } @Bindable class DocumentState { boolean isDirty = false }
Next up is the view: MultiDocumentExampleView.groovy. For this minimal example, we'll define some common actions and a tabbed pane for displaying the documents, but you can customize it to suit your application:
actions { action( id: 'newAction', name:'New', accelerator: shortcut('N'), closure: controller.newAction ) action( id: 'openAction', name:'Open', accelerator: shortcut('O'), closure: controller.openAction ) action( id: 'closeAction', name:'Close', accelerator: shortcut('W'), closure: controller.closeAction, enabled: bind { model.activeDocument != null } ) action( id: 'saveAction', name:'Save', accelerator: shortcut('S'), closure: controller.saveAction, enabled: bind { model.state.isDirty } ) } application(title: 'Multiple Document Example', size:[800,600], //pack:true, //location:[50,50], locationByPlatform:true, iconImage: imageIcon('/griffon-icon-48x48.png').image, iconImages: [imageIcon('/griffon-icon-48x48.png').image, imageIcon('/griffon-icon-32x32.png').image, imageIcon('/griffon-icon-16x16.png').image], defaultCloseOperation: 0, windowClosing: { evt -> if (controller.canClose()) { app.shutdown() } } ) { menuBar(id: 'menuBar') { menu(text: 'File', mnemonic: 'F') { menuItem(newAction) menuItem(openAction) menuItem(closeAction) menuItem(saveAction) } } tabbedPane(id: 'documents', stateChanged: { evt -> controller.activeDocumentChanged() }) }
If you look close, you'll see I'm using the window closing trick I blogged awhile ago. The last piece of the puzzle is the controller: MultiDocumentExampleController.groovy. This is where we do the heavy lifting.
class MultiDocumentExampleController { int count = 0 // not strictly required def model def view void mvcGroupInit(Map args) {} void activeDocumentChanged() { // de-activate the existing active document if (model.activeDocument) { model.activeDocument.controller.deactivate() } // figure out the new active document int index = view.documents.selectedIndex model.activeDocument = index == -1 ? null : model.openDocuments[index] if (model.activeDocument) { model.activeDocument.controller.activate(model.state) } } boolean canClose() { return model.openDocuments.inject(true) { flag, doc -> if (flag) flag &= doc.controller.close() } } def newAction = { evt = null -> // TODO: use an id that makes sense for your application, like a file name String id = "Document " + (count++) def document = buildMVCGroup('Document', id, tabs: view.documents, id: id, name: id /* TODO pass other args */) if (document.controller.open()) { model.openDocuments << document view.documents.addTab(document.model.name, document.view.root) view.documents.selectedIndex = view.documents.tabCount - 1 } else { destroyMVCGroup(id) } } def openAction = { evt = null -> // TODO: pop up a open file dialog or whatever makes sense // TODO: use an id that makes sense for your application, like a file name String id = "Document" + (count++) // check to see if the document is already open def open = model.openDocuments.find { it.model.id == id } if (open) { view.documents.selectedIndex = model.openDocuments.indexOf(open) } else { def document = buildMVCGroup('Document', id, tabs: view.documents, id: id, name: id /* TODO pass other args */) if (document.controller.open()) { model.openDocuments << document view.documents.addTab(document.model.name, document.view.root) view.documents.selectedIndex = view.documents.tabCount - 1 } else { destroyMVCGroup(id) } } } def saveAction = { evt = null -> model.activeDocument.controller.save() } def closeAction = { evt = null -> if (model.activeDocument.controller.close()) { int index = model.openDocuments.indexOf(model.activeDocument) model.openDocuments.remove(index) view.documents.removeTabAt(index) } } }
There's a fair amount of code but it's all pretty straightforward. The only thing that warrants further discussion is activeDocumentChanged(). This method is called whenever the active tab in the tabbed pane is changed. In it, we deactivate() the previously active document and then activate() the new document. When activating, we pass in the state object I mentioned earlier. The purpose of the state object is to allow us to use bindings that depend on the active document. For example, we really only want the save action to be enabled when we have a document open and the document is dirty. We can't bind directly to the active document's model because as soon as we open or switch to a new document, the binding is no longer correct. We can, however, bind to the state object and then sync the document's state in the activate() method (or other methods).
The implementation of the Document MVC group is straightforward:
DocumentModel.groovy
import groovy.beans.Bindable @Bindable class DocumentModel { String id String name = "Untitled" boolean isDirty = false DocumentState state }
DocumentView.groovy
panel(id: 'root') { // TODO: put your view implementation here button(text: 'Mark Dirty', enabled: bind { !model.isDirty }, actionPerformed: { controller.markDirty() }) button(text: 'Mark Clean', enabled: bind { model.isDirty }, actionPerformed: { controller.markClean() }) }
DocumentController.groovy
import javax.swing.JOptionPane /** * A skeleton controller for a 'document'. */ class DocumentController { def model def view def tabs void mvcGroupInit(Map args) { tabs = args.tabs } /** * Called when the tab with this document gains focus. */ void activate(state) { // save the state object so we can signal the model.state = state // TODO: sync the model and document state state.isDirty = model.isDirty } /** * Called when the tab with this document loses focus. */ void deactivate() { // forget the state object model.state = null } /** * Called when the document is initially opened. */ boolean open() { // TODO: perform any opening tasks return true } /** * Called when the document is closed. */ boolean close() { if (model.isDirty) { switch (JOptionPane.showConfirmDialog(app.appFrames[0], "Save changes to '${model.name}'?", "Example", JOptionPane.YES_NO_CANCEL_OPTION)){ case JOptionPane.YES_OPTION: return save() case JOptionPane.NO_OPTION: return true case JOptionPane.CANCEL_OPTION: return false } } return true } /** * Called when the document is saved. */ boolean save() { // TODO: perform any saving tasks markClean() return true } /** * Marks this document as 'dirty' */ void markDirty() { model.isDirty = true if (model.state) { model.state.isDirty = true } // TODO: update any other model/state properties setTitle(model.name + "*") } /** * Marks this document as 'clean' */ void markClean() { model.isDirty = false if (model.state) { model.state.isDirty = false } // TODO: update any other model/state properties setTitle(model.name) } /** * Sets this document's tab title. */ void setTitle(title) { int index = tabs.indexOfComponent(view.root) if (index != -1) { tabs.setTitleAt(index, title) } } }
So there it is. You should be able to use this as a starting place for your next Griffon-based text editor, web browser, etc. I'm personally using in PSICAT to manage the open diagrams:
I zipped up all of the code from this post and made it available for download.