Restructered pit-swing to better follow MVC paradigm.

Adding extensibility features for pit-swing.
This commit is contained in:
Jonathan Bernard 2010-03-02 11:59:33 -06:00
parent d77f04f12e
commit a3f9f4b291
8 changed files with 329 additions and 214 deletions

View File

@ -1 +1,2 @@
Load only N project deep at a time, lazy load any more.
Load only N project deep at a time, lazy load any more.
=======================================================

View File

@ -0,0 +1,2 @@
Add a runtime configuration file.
=================================

View File

@ -0,0 +1,2 @@
Add templates for new issues.
=============================

View File

@ -9,6 +9,8 @@ public abstract class Issue {
protected Status status
protected int priority
protected String text
protected Date deliveryDate
protected Date creationDate
Issue(String id, Category c = Category.TASK, Status s = Status.NEW,
int p = 9) {
@ -16,6 +18,8 @@ public abstract class Issue {
this.category = c
this.status = s
this.priority = p
this.creationDate = new Date()
this.deliveryDate = null
}
public String getId() { return id; }
@ -48,6 +52,14 @@ public abstract class Issue {
public void setText(String t) { text = t }
public boolean hasDelivery() { return deliveryDate == null }
public Date getCreationDate() { return creationDate }
public Date getDeliveryDate() { return deliveryDate }
public void setDeliveryDate(Date dd) { deliveryDate = dd }
@Override
public String toString() { return "${id}(${priority}-${status}): ${category} ${title}" }

View File

@ -1,17 +1,166 @@
package com.jdbernard.pit.swing
import com.jdbernard.pit.Category
import com.jdbernard.pit.FileProject
import com.jdbernard.pit.Issue
import com.jdbernard.pit.Project
import com.jdbernard.pit.Status
import javax.swing.DefaultListModel
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.DefaultTreeModel
class PITController {
// these will be injected by Griffon
def model
def view
void mvcGroupInit(Map args) {
SwingUtilities.invokeAndWait {
model.issueListRenderer = new IssueListCellRenderer()
model.issueListRenderer.categoryIcons = view.categoryIcons
model.issueListRenderer.statusIcons = view.statusIcons
def config = new File(System.getProperty('user.home'), '.pit')
config = new File(config, 'pit_swing.groovy')
if (config.exists() && config.isFile()) {
def loader = new GroovyClassLoader(PITController.classLoader)
def configBinding = new Binding()
// add default values
configBinding.templates = model.templates
configBinding.issueListRenderer = model.issueListRenderer
def configScript = loader.parseClass(config)
.newInstance(configBinding)
configScript.invokeMethod("run", null)
model.templates = configBinding.templates ?: [:]
if (configBinding.issueListRenderer &&
configBinding.issueListRenderer != model.issueListRenderer)
model.issueListRenderer = configBinding.issueListRenderer
}
}
}
/*
def action = { evt = null ->
/**
* displayProject
* @param project Project to display.
*
*/
void displayProject(Project project) {
view.issueTextArea.text = ""
if (!project) return
if (!model.projectListModels[(project.name)]) {
def dlm = new DefaultListModel()
project.eachIssue(model.filter) { dlm.addElement(it) }
model.projectListModels[(project.name)] = dlm
}
view.issueList.setModel(model.projectListModels[(project.name)])
}
void displayIssue(Issue issue) {
if (!issue) return
view.issueTextArea.text = issue.text
view.issueTextArea.caretPosition = 0
}
void showProjectPopup(Project project, def x, def y) {
model.popupProject = project
view.projectPopupMenu.show(view.projectTree, x, y)
}
void showIssuePopup(Issue issue, def x, def y) {
model.popupIssue = issue
view.issuePopupMenu.show(view.issueList, x, y)
}
def makeNodes(Project project) {
def rootNode = new DefaultMutableTreeNode(project)
project.eachProject(model.filter) { rootNode.add(makeNodes(it)) }
return rootNode
}
def newProject = { evt ->
def name = JOptionPane.showInputDialog(view.frame, 'Project name:',
'New Project...', JOptionPane.QUESTION_MESSAGE)
def project
if (evt.source == view.newProjectButton)
project = model.selectedProject ?: model.rootProject
else project = model.popupProject ?: model.rootProject
def newProject = project.createNewProject(name)
project.projects[(newProject.name)] = newProject
view.projectTree.model = new DefaultTreeModel(
makeNodes(model.rootProject))
}
def deleteProject = { evt ->
def project
if (evt.source == view.deleteProjectButton)
project = model.selectedProject ?: model.rootProject
else project = model.popupProject ?: model.rootModel
project.delete()
model.rootProject = new FileProject(model.rootProject.source)
}
def newIssue = { evt = null ->
view.titleTextField.text = ""
view.categoryComboBox.selectedItem = Category.BUG
view.statusComboBox.selectedItem = Status.NEW
view.prioritySpinner.setValue(5)
view.newIssueDialog.visible = true
}
def createIssue = { evt = null ->
def issueText = ""
if (model.templates[(view.categoryComboBox.selectedItem)]) {
issueText = model.templates[(view.categoryComboBox.selectedItem)]
issueText = issueText.replaceFirst(/TITLE/,
view.titleTextField.text)
}
def issue = model.selectedProject.createNewIssue(
category: view.categoryComboBox.selectedItem,
status: view.statusComboBox.selectedItem,
priority: view.prioritySpinner.value,
text: issueText)
model.projectListModels[(model.selectedProject.name)] = null
displayProject(model.selectedProject)
view.newIssueDialog.visible = false
}
def deleteIssue = { evt ->
def issue
if (evt.source == view.deleteIssueButton)
issue = view.issueList.selectedValue
else issue = model.popupIssue
model.selectedProject.issues.remove(issue.id)
model.projectListModels[(model.selectedProject.name)]
.removeElement(issue)
issue.delete()
}
def changeCategory = { evt ->
model.popupIssue.status = status
view.issueList.invalidate()
view.issueList.repaint()
}
*/
}

View File

@ -19,6 +19,16 @@
import groovy.swing.SwingBuilder
import griffon.util.GriffonPlatformHelper
import griffon.util.GriffonApplicationHelper
GriffonPlatformHelper.tweakForNativePlatform(app)
SwingBuilder.lookAndFeel('gtk', 'mac', 'org.pushingpixels.substance.api.skin.SubstanceCremeCoffeeLookAndFeel', 'nimbus', ['metal', [boldFonts: false]])
SwingBuilder.lookAndFeel('mac', 'org.pushingpixels.substance.api.skin.SubstanceCremeCoffeeLookAndFeel', 'nimbus', ['metal', [boldFonts: false]])
// make config directory
def confDir = new File(System.getProperty('user.home'), '.pit')
if (!confDir.exists()) confDir.mkdirs()
// find or create configuration file
def swingConf = new File(confDir, 'pit-swing.groovy')
if (!swingConf.exists()) swingConf.createNewFile()
// run config
GriffonApplicationHelper.runScriptInsideEDT(swingConf.canonicalPath, app)

View File

@ -1,8 +1,34 @@
package com.jdbernard.pit.swing
import com.jdbernard.pit.Category
import com.jdbernard.pit.Filter
import com.jdbernard.pit.Issue
import com.jdbernard.pit.Project
import com.jdbernard.pit.Status
import groovy.beans.Bindable
class PITModel {
@Bindable Project rootProject
// cache the ListModels
def projectListModels = [:]
// filter for projects and issues
Filter filter = new Filter(categories: [],
status: [Status.NEW, Status.VALIDATION_REQUIRED])
@Bindable Project popupProject = null
@Bindable Project selectedProject = null
@Bindable Issue popupIssue = null
// configurable exntension points
// ==============================
@Bindable def issueListRenderer
// map of category -> issue template
def templates = [:]
}

View File

@ -27,87 +27,77 @@ import javax.swing.tree.TreeSelectionModel
import net.miginfocom.swing.MigLayout
import java.awt.Color
/* ********************
* VIEW-Specific data
* ********************/
// cache the ListModels
projectListModels = [:]
actions {
action (
id: 'newIssue',
name: 'New Issue',
icon: imageIcon("/add.png"),
accelerator: shortcut('N'),
closure: controller.newIssue,
enabled: bind { model.selectedProject != null }
)
// map of category -> list icon
action (
id: 'createIssue',
name: 'Create Issue',
closure: controller.createIssue
)
action (
id: 'newProject',
name: 'New Project...',
icon: imageIcon("/add.png"),
closure: controller.newProject
)
action (
id: 'deleteProject',
name: 'Delete Project',
closure: controller.deleteProject,
enabled: bind {model.selectedProject != null }
)
action (
id: 'deleteProjectPop',
name: 'Delete Project',
icon: imageIcon("/delete.png"),
closure: controller.deleteProject,
enabled: bind { model.popupProject != null }
)
action (
id: 'deleteIssue',
name: 'Delete Issue',
icon: imageIcon("/delete.png"),
closure: controller.deleteIssue,
)
action (
id: 'deleteIssuePop',
name: 'Delete Issue',
icon: imageIcon("/delete.png"),
closure: controller.deleteIssue,
enabled: bind { model.popupIssue != null }
)
}
/* ****************
* GUI components
* ****************/
categoryIcons = [:]
statusIcons = [:]
// filter for projects and issues
filter = new Filter(categories: [],
status: [Status.NEW, Status.VALIDATION_REQUIRED])
popupProject = null
selectedProject = model.rootProject
popupIssue = null
// initialize category-related view data
Category.values().each {
categoryIcons[(it)] = imageIcon("/${it.name().toLowerCase()}.png")
filter.categories.add(it)
model.filter.categories.add(it)
}
Status.values().each {
statusIcons[(it)] = imageIcon("/${it.name().toLowerCase()}.png")
}
/* ***************
* event methods
* ***************/
/**
* displayProject
* @param project Project to display.
*
*/
displayProject = { project = null ->
issueTextArea.text = ""
if (!project) return
if (!projectListModels[(project.name)]) {
def model = new DefaultListModel()
project.eachIssue(filter) { model.addElement(it) }
projectListModels[(project.name)] = model
}
issueList.setModel(projectListModels[(project.name)])
}
displayIssue = { issue = null ->
if (issue) issueTextArea.text = issue.text
}
showProjectPopup = { project, x, y ->
popupProject = project
projectPopupMenu[1].enabled = project != null
projectPopupMenu.show(projectTree, x, y)
}
showIssuePopup = { issue, x, y ->
popupIssue = issue
issuePopupMenu.eachWithIndex { menuItem, idx ->
if (idx != 0) menuItem.enabled = issue != null }
issuePopupMenu.show(issueList, x, y)
}
newIssue = { evt = null ->
titleTextField.text = ""
categoryComboBox.selectedItem = Category.BUG
statusComboBox.selectedItem = Status.NEW
prioritySpinner.setValue(5)
newIssueDialog.visible = true
}
/* ****************
* GUI components
* ****************/
openDialog = fileChooser(fileSelectionMode: JFileChooser.DIRECTORIES_ONLY)
newIssueDialog = dialog(title: 'New Task...', modal: true, pack: true,
@ -149,64 +139,28 @@ newIssueDialog = dialog(title: 'New Task...', modal: true, pack: true,
button('Cancel', actionPerformed: { newIssueDialog.visible = false },
constraints: gbc(gridx: 0, gridy: 5, insets: [5, 5, 5, 5],
anchor: GBC.EAST))
button('Create Issue',
button(createIssue,
constraints: gbc(gridx: 1, gridy: 5, insets: [5, 5, 5, 5],
anchor: GBC.WEST),
actionPerformed: {
def issue = selectedProject.createNewIssue(
category: categoryComboBox.selectedItem,
status: statusComboBox.selectedItem,
priority: prioritySpinner.value,
text: titleTextField.text)
projectListModels[(selectedProject.name)] = null
displayProject(selectedProject)
newIssueDialog.visible = false
})
anchor: GBC.WEST))
}
projectPopupMenu = popupMenu() {
menuItem('New Project...', icon: imageIcon("/add.png"),
actionPerformed: {
def name = JOptionPane.showInputDialog(frame, 'Project name:',
'New Project...', JOptionPane.QUESTION_MESSAGE)
if (!popupProject) popupProject = model.rootProject
def newProject = popupProject.createNewProject(name)
popupProject.projects[(newProject.name)] = newProject
projectTree.model = new DefaultTreeModel(
makeNodes(model.rootProject))
})
menuItem('Delete Project', icon: imageIcon("/delete.png"),
actionPerformed: {
if (!popupProject) return
popupProject.delete()
// do not like, tied to Project implementation
model.rootProject = new FileProject(model.rootProject.source)
})
menuItem(newProject)
menuItem(deleteProjectPop)
}
issuePopupMenu = popupMenu() {
menuItem('New Issue...', icon: imageIcon("/add.png"),
actionPerformed: newIssue)
menuItem('Delete Issue', icon: imageIcon("/delete.png"),
actionPerformed: {
if (!popupIssue) return
selectedProject.issues.remove(popupIssue.id)
projectListModels[(selectedProject.name)].removeElement(popupIssue)
popupIssue.delete()
})
menuItem(newIssue)
menuItem(deleteIssuePop)
separator()
menu('Change Category') {
Category.values().each { category ->
menuItem(category.toString(),
icon: categoryIcons[(category)],
enabled: bind { model.popupIssue != null },
actionPerformed: {
if (!popupIssue) return
popupIssue.category = category
model.popupIssue.category = category
issueList.invalidate()
issueList.repaint()
})
@ -217,9 +171,9 @@ issuePopupMenu = popupMenu() {
Status.values().each { status ->
menuItem(status.toString(),
icon: statusIcons[(status)],
enabled: bind { model.popupIssue != null },
actionPerformed: {
if (!popupIssue) return
popupIssue.status = status
model.popupIssue.status = status
issueList.invalidate()
issueList.repaint()
})
@ -227,12 +181,12 @@ issuePopupMenu = popupMenu() {
}
menuItem('Change Priority...',
enabled: bind { model.popupIssue != null },
actionPerformed: {
if (!popupIssue) return
def newPriority = JOptionPane.showInputDialog(frame,
'New priority (0-9)', 'Change Priority...',
JOptionPane.QUESTION_MESSAGE)
try { popupIssue.priority = newPriority.toInteger() }
try { model.popupIssue.priority = newPriority.toInteger() }
catch (exception) {
JOptionPane.showMessage(frame, 'The priority value must ' +
'be an integer in [0-9].', 'Change Priority...',
@ -271,39 +225,37 @@ frame = application(title:'Personal Issue Tracker',
menu('View') {
menu('Category') {
Category.values().each {
checkBoxMenuItem(it.toString(),
selected: filter.categories.contains(it),
Category.values().each { cat ->
checkBoxMenuItem(cat.toString(),
selected: model.filter.categories.contains(cat),
actionPerformed: { evt ->
def cat = Category.toCategory(evt.source.text)
if (filter.categories.contains(cat)) {
filter.categories.remove(cat)
if (model.filter.categories.contains(cat)) {
model.filter.categories.remove(cat)
evt.source.selected = false
} else {
filter.categories.add(cat)
model.filter.categories.add(cat)
evt.source.selected = true
}
projectListModels.clear()
displayProject(selectedProject)
model.projectListModels.clear()
controller.displayProject(model.selectedProject)
})
}
}
menu('Status') {
Status.values().each {
checkBoxMenuItem(it.toString(),
selected: filter.status.contains(it),
Status.values().each { st ->
checkBoxMenuItem(st.toString(),
selected: model.filter.status.contains(st),
actionPerformed: { evt ->
def st = Status.toStatus(evt.source.text[0..5])
if (filter.status.contains(st)) {
filter.status.remove(st)
if (model.filter.status.contains(st)) {
model.filter.status.remove(st)
evt.source.selected = false
} else {
filter.status.add(st)
model.filter.status.add(st)
evt.source.selected = true
}
projectListModels.clear()
displayProject(selectedProject)
model.projectListModels.clear()
controller.displayProject(model.selectedProject)
})
}
}
@ -315,37 +267,37 @@ frame = application(title:'Personal Issue Tracker',
checkBoxMenuItem('By ID',
buttonGroup: sortMenuButtonGroup,
actionPerformed: {
filter.issueSorter = { it.id }
projectListModels.clear()
displayProject(selectedProject)
model.filter.issueSorter = { it.id }
model.projectListModels.clear()
controller.displayProject(selectedProject)
})
checkBoxMenuItem('By Category',
buttonGroup: sortMenuButtonGroup,
actionPerformed: {
filter.issueSorter = { it.category }
projectListModels.clear()
displayProject(selectedProject)
model.filter.issueSorter = { it.category }
model.projectListModels.clear()
controller.displayProject(selectedProject)
})
checkBoxMenuItem('By Status',
buttonGroup: sortMenuButtonGroup,
actionPerformed: {
filter.issueSorter = { it.status }
projectListModels.clear()
displayProject(selectedProject)
model.filter.issueSorter = { it.status }
model.projectListModels.clear()
controller.displayProject(selectedProject)
})
checkBoxMenuItem('By Priority',
buttonGroup: sortMenuButtonGroup,
actionPerformed: {
filter.issueSorter = { it.priority }
projectListModels.clear()
displayProject(selectedProject)
model.filter.issueSorter = { it.priority }
model.projectListModels.clear()
controller.displayProject(selectedProject)
})
checkBoxMenuItem('By Title',
buttonGroup: sortMenuButtonGroup,
actionPerformed: {
filter.issueSorter = { it.title }
projectListModels.clear()
displayProject(selectedProject)
model.filter.issueSorter = { it.title }
model.projectListModels.clear()
controller.displayProject(selectedProject)
})
}
}
@ -374,21 +326,21 @@ frame = application(title:'Personal Issue Tracker',
if (model.rootProject) {
projectTree.rootVisible =
model.rootProject.issues.size()
new DefaultTreeModel(makeNodes(model.rootProject))
new DefaultTreeModel(controller.makeNodes(
model.rootProject))
} else {
projectTree.rootVisible = false
new DefaultTreeModel(new DefaultMutableTreeNode())
}
}),
valueChanged: { evt ->
selectedProject = evt?.newLeadSelectionPath?.
model.selectedProject = evt?.newLeadSelectionPath?.
lastPathComponent?.userObject ?: model.rootProject
displayProject(selectedProject)
//deleteProjectButton.enabled = selectedProject != null
controller.displayProject(model.selectedProject)
},
mouseClicked: { evt ->
if (evt.button == MouseEvent.BUTTON3) {
showProjectPopup(
controller.showProjectPopup(
projectTree.getPathForLocation(evt.x, evt.y)
?.lastPathComponent?.userObject,
evt.x, evt.y)
@ -403,35 +355,12 @@ frame = application(title:'Personal Issue Tracker',
}
// project buttons
button('New Project', icon: imageIcon("/add.png"),
newProjectButton = button(newProject,
constraints: gbc(fill: GBC.NONE, gridx: 0, gridy: 1,
anchor: GBC.WEST),
actionPerformed: {
def name = JOptionPane.showInputDialog(frame,
'Project name:', 'New Project...',
JOptionPane.QUESTION_MESSAGE)
if (!selectedProject) selectedProject = model.rootProject
def newProject = selectedProject.createNewProject(name)
selectedProject.projects[(newProject.name)] = newProject
projectTree.model = new DefaultTreeModel(
makeNodes(model.rootProject))
})
deleteProjectButton = button('Delete Project',
icon: imageIcon("/delete.png"),
anchor: GBC.WEST))
deleteProjectButton = button(deleteProject,
constraints: gbc(fill: GBC.NONE, gridx: 1, gridy: 1,
anchor: GBC.WEST),
enabled: bind(source: projectTree, sourceEvent: 'valueChanged',
sourceValue: { projectTree?.lastSelectedPathComponent != null}),
actionPerformed: {
if (!selectedProject) return
selectedProject.delete()
// do not like, tied to Project implementation
model.rootProject = new FileProject(
model.rootProject.source)
})
anchor: GBC.WEST))
}
// split between issue list and issue details
@ -445,42 +374,33 @@ frame = application(title:'Personal Issue Tracker',
weighty: 2, gridx: 0, gridy: 0, gridwidth: 3)) {
issueList = list(
cellRenderer: new IssueListCellRenderer(
categoryIcons: categoryIcons,
statusIcons: statusIcons),
cellRenderer: bind(source: model,
sourceProperty: 'issueListRenderer'),
selectionMode: ListSelectionModel.SINGLE_SELECTION,
valueChanged: { displayIssue(issueList.selectedValue) },
valueChanged: { evt ->
controller.displayIssue(issueList.selectedValue)
},
mouseClicked: { evt ->
if (evt.button == MouseEvent.BUTTON3) {
issueList.selectedIndex = issueList.locationToIndex(
[evt.x, evt.y] as Point)
showIssuePopup(issueList.selectedValue,
evt.x, evt.y)
controller.showIssuePopup(
issueList.selectedValue, evt.x, evt.y)
}
})
}
wordWrapCheckBox = checkBox('Word wrap',
constraints: gbc(gridx: 0, gridy: 1, weightx: 2,
anchor: GBC.WEST),
selected: true)
button('New Issue',
constraints: gbc(gridx: 1, gridy: 1, anchor: GBC.EAST),
icon: imageIcon("/add.png"), actionPerformed: newIssue)
anchor: GBC.WEST), selected: true)
button(newIssue,
constraints: gbc(gridx: 1, gridy: 1, anchor: GBC.EAST))
deleteIssueButton = button('Delete Issue',
deleteIssueButton = button(deleteIssue,
constraints: gbc(gridx: 2, gridy: 1, anchor: GBC.EAST),
enabled: bind(source: issueList, sourceEvent: 'valueChanged',
sourceValue: { issueList.selectedValue != null }),
icon: imageIcon("/delete.png"),
actionPerformed: {
if (!issueList?.selectedIssue) return
selectedProject.issues.remove(issueList.selectedValue)
projectListModels[(selectedProject.name)]
.removeElement(issueList.selectedValue)
issueList.selectedIssue.delete()
})
sourceValue: { issueList.selectedValue != null }))
}
scrollPane(constraints: "bottom") {
@ -488,6 +408,8 @@ frame = application(title:'Personal Issue Tracker',
wrapStyleWord: true,
lineWrap: bind(source: wordWrapCheckBox,
sourceProperty: 'selected'),
editable: bind( source: issueList, sourceEvent: 'valueChanged',
sourceValue: { issueList.selectedValue != null }),
font: new Font(Font.MONOSPACED, Font.PLAIN, 10),
focusGained: {},
focusLost: {
@ -504,12 +426,3 @@ frame = application(title:'Personal Issue Tracker',
}
}
}
/* ******************
* Auxilary methods
* ******************/
def makeNodes(Project project) {
def rootNode = new DefaultMutableTreeNode(project)
project.eachProject(filter) { rootNode.add(makeNodes(it)) }
return rootNode
}