Multiselect Power Table Custom Cell Editor

By using hints from various other posts, I had built utility functions for defining and using a custom editor that can refresh the drop down list using methods defined when setting up the custom editor.

Now I’m enhancing it to allow for multiple selection.

I’m having few problems presently with my implementation:

  1. Setting the font to the table cell font
  2. Getting the Popup to STAY visible after selecting one of the choices
  3. Clearing the edit mode on the Cell if a different component on the screen is selected

The current source is as follows:

# Convenience functions for Ignition development, typically loaded
# as "shared.MWES.Editors.{Cell Editor}"
#
# Copyright 2008-2018 Midwest Engineered Systems, Inc. <sales@mwes.com>
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
#   1. Redistributions of source code must retain the above copyright notice,
#      this list of conditions and the following disclaimer.
#   2. Redistributions in binary form must reproduce the above copyright notice,
#      this list of conditions and the following disclaimer in the documentation
#      and/or other materials provided with the distribution.
#   3. The name of the author may not be used to endorse or promote products
#      derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED
# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
# SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
# OF SUCH DAMAGE.
#

#
# Custom Cell Editors for use in power tables
#

import system
import re

from java.awt.event import ActionEvent, ActionListener
from javax.swing import JComponent, JComboBox, JCheckBox, JLabel, JList
from javax.swing import DefaultCellEditor, AbstractCellEditor
from javax.swing import DefaultComboBoxModel, ComboBoxModel, ListModel, ListSelectionModel
from javax.swing import ListCellRenderer
from javax.swing.table import TableCellEditor

libName = "MWES"
srcName = "Editors"

class SelectableKeyValueElement:
	def __init__(self, data, selected=False):
		self.elemKey = ''
		self.elemValue = ''
		self.elemSelected = selected
		if data != None:
			if isinstance(data, tuple) or isinstance(data, list):
				if len(data) > 0:
					self.elemKey = data[0]
				if len(data) > 1:
					self.elemValue = data[1]
				else:
					self.elemValue = self.elemKey
			else:
				self.elemKey = str(data)
				self.elemValue = self.elemKey
	
	def key(self, newKey = None):
		if newKey == None:
			return self.getKey()
		else:
			return self.setKey(newKey)
			
	def getKey(self):
		return self.elemKey
	
	def setKey(self, key):
		self.elemKey = key
	
	def value(self, newValue = None):
		if newValue == None:
			return self.getValue()
		else:
			return self.setValue(newValue)
			
	def getValue(self):
		return self.elemValue
	
	def setValue(self, value):
		self.value = elemValue
	
	def isSelected(self):
		return self.elemSelected
	
	def setSelected(self, selected):
		self.elemSelected = selected
		
	def getElement(self):
		return (self.elemKey, self.elemValue, self.elemSelected)
	
	def __str__(self):
		return "%s: %s (%s)" % (str(self.elemKey), str(self.elemValue), str(self.elemSelected))

class ComboSelectableKeyValueModel(ComboBoxModel):
	def __init__(self, *args, **kwargs ):
		self.libName = libName + '.' + srcName
		self.srcName = 'ComboSelectableKeyValueModel'
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, method='__init__' )
		logger.info("Starting")
	
		idx = 0
		sz = len(args)
		myData = shared.MWES.Args.getParam( sz, idx, "Data", None, True, args, kwargs )
		idx+=1
		currValue = shared.MWES.Args.getParam( sz, idx, "CurrentValue", '', False, args, kwargs )
		idx+=1
		keyCol = shared.MWES.Args.getParam( sz, idx, "KeyColumn", 'key', False, args, kwargs )
		idx+=1
		valueCol = shared.MWES.Args.getParam( sz, idx, "ValueColumn", 'value', False, args, kwargs )
		idx+=1
		self.multiSelect = shared.MWES.Args.getParam( sz, idx, "MultiSelect", False, False, args, kwargs )
		idx+=1
	
		self.keyMap = { }
		self.keyList = []
		self.valueMap = { }
		self.selectedKeys = [ ]
		self.data = myData
		self.currValue = currValue
		self.keyCol = keyCol
		self.valueCol = valueCol
		
		self.refreshItemList()
	
	def isMultiSelect(self):
		return self.multiSelect
	
	def setMultiSelect(self, multiSelect):
		self.multiSelect = multiSelect
		
	def refreshItemList (self, row=-1):
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
		logger.info("Starting.  Data %s, CurrValue %s" % (str(self.data), str(self.currValue)))
	
		myData = self.data
		currValue = self.currValue
		keyCol = self.keyCol
		valueCol = self.valueCol
		
		if callable(myData):
			myData = myData(row)
		if callable(currValue):
			currValue = currValue(row)

		self.keyMap = { }
		self.keyList = []
		self.valueMap = { }
		self.selectedKeys = [ ]
		if myData != None:
			currValues = []
			if currValue != None and len(currValue) > 0:
				currValues = currValue.split(',')			
			if isinstance(myData, list):
				for item in myData:
					self.addElement (SelectableKeyValueElement ( item ), currValues)
			elif str(myData)[:9] == 'Dataset [':
				logger.info("Processing Dataset")
				for row in range(myData.getRowCount()):
					try:
						key = myData.getValueAt(row, keyCol)
						value = myData.getValueAt(row, valueCol)
						self.addElement (SelectableKeyValueElement ( (key, value) ), currValues)
					except:
						pass
		
		def alphaNumOrder(key):
			elems = []
			keys = re.split(r'(\d+)', key)
			for elem in keys:
				if elem.isnumeric():
					if len(elem) < 6:
						s = len(elem)
						while s < 6:
							elem = '0' + elem
							s = s + 1
				elems.append(elem)
			return ''.join(elems)
		self.keyList = sorted(self.keyMap.keys(), key=alphaNumOrder)

		logger.info("Finished.  ValueMap: %s, KeyMap: %s, KeyList: %s" % (str(self.valueMap), str(self.keyMap), str(self.keyList)))
	
	def addElement(self, kvElem, currValues=[]):
#		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
#		logger.info ("Starting, Item = %s, CurrValues = %s" % (str(kvElem), str(currValues)))
		key = kvElem.key()
		value = kvElem.value()
		if key in currValues or value in currValues:
			if (self.multiSelect) or (len(self.selectedKeys) == 0):
				kvElem.setSelected(True)
				self.selectedKeys.append(key)
		self.valueMap[value] = kvElem
		self.keyMap[key] = kvElem
	
	def getSize(self):	
		return len(self.keyMap.keys())
		
	def getElementAt(self, index):
#		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
#		logger.info ("Starting, Index = %s" % (str(index)))
		if index >= 0 and index < len(self.keyList):
			return self.keyMap[self.keyList[index]]
		else:
			return ''
			
	def setSelectedItem(self, anItem, force=None):
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
		logger.info ("Starting, Item = %s, Force=%s" % (str(anItem), str(force)))
	
		key, kvElem = self.getElementAndKey(anItem)
		logger.info ("Starting, Key %s, Item = %s" % (str(key), str(kvElem)))

		if kvElem != None:				
			selected = not kvElem.isSelected()
			if force != None and isinstance(force, bool):
				selected = force
			elif not self.multiSelect:
				selected = True
				
			logger.info ("Setting Selected to %s for Item = %s" % (str(selected), str(kvElem)))
			kvElem.setSelected ( selected )
			if key in self.selectedKeys:
				if not kvElem.isSelected():
					self.selectedKeys.remove(key)
			elif kvElem.isSelected():
				if not self.multiSelect and len(self.selectedKeys) > 0:
					self.clearAllSelectedKeys()

				self.selectedKeys.append(key)
		logger.info ("Finish, Key %s, Item = %s, SelectedKeys=%s" % (str(key), str(kvElem), str(self.selectedKeys)))

	def clearAllSelectedKeys(self):
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
		logger.info ("Starting")
		for key in self.selectedKeys:
			self.keyMap[key].setSelected(False)
		self.selectedKeys = []
						
	def getSelectedKeys(self, asList=True):
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
		logger.info ("Starting, As List = %s" % (str(asList)))
		ret = ''
		if asList:
			ret = self.selectedKeys
		else:
			ret=''
			for key in self.selectedKeys:
				if len(ret) > 0:
					ret = ret + ','
				ret = ret + key
		logger.info ("Finish, Key(s) = %s" % (str(ret)))
		return ret
	
	def setSelectedKeys(self, indices):
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
		logger.info ("Starting, Indices = %s" % (str(indices)))
		idxList = indices
		if not isinstance(idxList, list):
			idxList = str(indices).split(',')
			
		for idx in idxList:
			self.setSelectedItem(idx, True)
	
	def getSelectedValues(self):
#		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
#		logger.info ("Starting")
		ret = ''
		for key in self.selectedKeys:
			if len(ret) > 0:
				ret = ret + ', '
			ret = ret + self.keyMap[key].value()
		
#		logger.info ("Finish: Ret %s" % (str(ret)))
		return ret

	def getElementAndKey(self, anItem):
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
		logger.info ("Starting, Item = %s" % (str(anItem)))
		if isinstance(anItem, SelectableKeyValueElement):
			key = anItem.key()
		else:
			key = str(anItem)
		kvElem = None
		try:
			kvElem = self.keyMap[key]
		except:
			try:
				kvElem = self.valueMap[key]
				key = kvElem.key()
			except:
				logger.info("Unable to find '%s' in maps" % (str(key)))
		
		logger.info ("Finished, Key = %s, Elem = %s" % (str(key), str(kvElem)))
		return ( key, kvElem )
		
	def isSelected(self, anItem):
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
		logger.info ("Starting, Item = %s" % (str(anItem)))
		key, kvElem = self.getElementAndKey(anItem)
		if kvElem != None:
			return kvElem.isSelected()
		else:
			return False
	
class ComboSelectableKeyValueCellRenderer ( JComponent, ListCellRenderer ):
	def __init__(self):
		self.libName = libName + '.' + srcName
		self.srcName = 'ComboKeyValueCellRenderer'
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, method='__init__' )
		logger.info("Starting")

	def getListCellRendererComponent(self, list, value, index, isSelected, cellHasFocus):
		model = list.getModel()
		retValue = None
		stringValue = ''
		if index != -1:
			checked = False
			multiSelect = False
			if isinstance(model, ComboSelectableKeyValueModel):
				multiSelect = model.isMultiSelect()
			if value != None:
				if isinstance(value, SelectableKeyValueElement):
					stringValue = value.value()
					checked = value.isSelected()
				else:
					stringValue = str(value)

			if checked:
				self.setBackground(list.getSelectionBackground())
				self.setForeground(list.getSelectionForeground())
			else:
				self.setBackground(list.getBackground())
				self.setForeground(list.getForeground())
	
			if multiSelect:		
				retValue = JCheckBox(stringValue, checked)
			else:		
				retValue = JLabel(stringValue)
		else:
			if isinstance(model, ComboSelectableKeyValueModel):
				stringValue = model.getSelectedValues()
			else:
				stringValue = model.getSelectedItem()
			if stringValue != None and len(stringValue) > 0:
				stringValue = '<HTML>'+stringValue
			retValue = JLabel(stringValue)

		return retValue

class ComboSelectableKeyValue(JComboBox):
	def __init__(self):
		self.libName = libName + '.' + srcName
		self.srcName = 'ComboSelectableKeyValue'
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, method='__init__' )
		logger.info("Starting")

	def setPopupVisible ( self, v ):
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
		logger.info("Flag %s, IsShowing? %s" % (str(v), str(self.isShowing())))
#		if v == 2:
#			JComboBox.setPopupVisible(self, False)
#		elif v == 1:
#			JComboBox.setPopupVisible(self, True)
#		elif v == 0:
#			JComboBox.setPopupVisible(self, True)

class SelectableKeyValueCellEditor(DefaultCellEditor):
	comboBox = ComboSelectableKeyValue()

	def __init__(self, component):
		self.libName = libName + '.' + srcName
		self.srcName = 'SelectableKeyValueCellEditor'
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, method='__init__' )
		logger.info("Starting")
		DefaultCellEditor.__init__(self, component)

	@staticmethod
	def getCellEditorValue():
		result = SelectableKeyValueCellEditor.comboBox.getModel().getSelectedKeys(False)
		
		return result
				
	def getTableCellEditorComponent(self, table, value, isSelected, row, column):
		logger = shared.MWES.Logging.getLog(lib=self.libName, src=self.srcName, autoMethod=True )
		logger.info("Starting, Value %s, IsSelected %s, Row %d, Column %d" % (str(value), str(isSelected), row, column))
	
		comp = DefaultCellEditor.getTableCellEditorComponent(self, table, value, isSelected, row, column)

		font = table.getFont()
		renderer = comp.getRenderer()
		if renderer != None:
			logger.info("Setting Renderer Font %s" % (str(font)))
			renderer.setFont(font)
			comp.setRenderer(renderer)

		comp.setFont(font)

		if isinstance(comp, ComboSelectableKeyValue):
			comp.getModel().refreshItemList(row)

		comp.setModel(comp.getModel())
		SelectableKeyValueCellEditor.comboBox = comp
			
		return comp

def DynamicDropdownCellEditor(buildOptions, getCurrentValue, keyCol=None, valueCol=None): 
	"""
	This will create a Custom dropdown editor that will retrieve the values for the dropdown list dynamically when the cell is selected.
	
	Arguments:
		buildOptions: A function that will return a list of tuples.  
					The format of the returned list should be: 
						[ ( Key, Description ), ( Key, Description ) ]
					OR with the keyCol and valueCol fields defined, you can also return a Dataset and the extraction of the key,value pairs
							will be done by the ComboSelectableKeyValueModel class.
													
					Note that the returned list should NOT contain duplicate descriptions 
					since internally the methods key off of the DESCRIPTION field NOT the KEY field!! 
		getCurrentValue: A function that will return the value that should be highlighted when the dropdown appears.
					This should return the KEY value not the description value.
	
	Results:
		A Custom Editor Class Instance to be returned as the 'editor' map entry from the configureEditor power table extension function
		
	Example:
		#
		# TypeList is a dataset that gets converted to a list of tuples
		#
		def configureEditor(self, colIndex, colName):
			rootContainer = self.parent.parent
		
			if colName == 'type':
				# You can either build a list of tuples
				def buildTypeOptionsTupleList(row=None):
					options = []
					
					descData = rootContainer.TypeList
					for typeRow in range(descData.rowCount):
						type = descData.getValueAt(typeRow, "type")
						desc = descData.getValueAt(typeRow, "description")
						if desc != None and len(desc) > 0:
							options.append( ( str(type), str(desc) ) )
					
					return options
				# Or just return the Dataset directly, if you passed the keyCol and valueCol fields 
				def buildTypeOptions(row=None):
					return rootContainer.TypeList
			
				def getCurrentTypeValue(row=None):
					currType = ''
					if row == None and rootContainer.RobotRow >= 0:
						row = rootContainer.RobotRow
					if row != None and row >= 0:
						currType = rootContainer.RobotList.getValueAt(row, 'type')
						if currType != None and len(currType) > 0:
							currType = str(currType)
						else:
							currType = None
					
					return currType
		
				customCellEditor = shared.MWES.Editors.DynamicDropdownCellEditor(buildTypeOptions, getCurrentTypeValue)
				return {'editor': customCellEditor}
	"""
	
	comboBoxModel = ComboSelectableKeyValueModel(buildOptions, getCurrentValue, keyCol, valueCol, False)
	comboBox = ComboSelectableKeyValue()
	comboBox.setModel(comboBoxModel)	
	comboBox.setRenderer(ComboSelectableKeyValueCellRenderer())				

	return SelectableKeyValueCellEditor(comboBox)
	
def DynamicMultiSelectCheckBoxCellEditor(buildOptions, getCurrentValue, keyCol=None, valueCol=None): 
	"""
	This will create a Custom dropdown editor that will retrieve the values for the dropdown list dynamically when the cell is selected.
			
	Arguments:
		buildOptions: A function that will return a list of tuples.  
					The format of the returned list should be: 
						[ ( Key, Description ), ( Key, Description ) ]
					OR with the keyCol and valueCol fields defined, you can also return a Dataset and the extraction of the key,value pairs
						will be done by the ComboSelectableKeyValueModel class.
						
					Note that the returned list should NOT contain duplicate descriptions 
					since internally the methods key off of the DESCRIPTION field NOT the KEY field!! 
		getCurrentValue: A function that will return the value that should be highlighted when the dropdown appears.
					This should return the KEY value not the description value.
	
	Results:
		A Custom Editor Class Instance to be returned as the 'editor' map entry from the configureEditor power table extension function
		
	Example:
		#
		# TypeList is a dataset that gets converted to a list of tuples
		#
		def configureEditor(self, colIndex, colName):
			rootContainer = self.parent.parent
		
			if colName == 'type':
				# You can either build a list of tuples
				def buildTypeOptionsTupleList(row=None):
					options = []
					
					descData = rootContainer.TypeList
					for typeRow in range(descData.rowCount):
						type = descData.getValueAt(typeRow, "type")
						desc = descData.getValueAt(typeRow, "description")
						if desc != None and len(desc) > 0:
							options.append( ( str(type), str(desc) ) )
					
					return options
				# Or just return the Dataset directly, if you passed the keyCol and valueCol fields 
				def buildTypeOptions(row=None):
					return rootContainer.TypeList
			
				def getCurrentTypeValue(row=None):
					currType = ''
					if row == None and rootContainer.RobotRow >= 0:
						row = rootContainer.RobotRow
					if row != None and row >= 0:
						currType = rootContainer.RobotList.getValueAt(row, 'type')
						if currType != None and len(currType) > 0:
							currType = str(currType)
						else:
							currType = None
					
					return currType
		
				customCellEditor = shared.MWES.Editors.DynamicDropdownCellEditor(buildTypeOptions, getCurrentTypeValue)
				return {'editor': customCellEditor}
	"""
	
	comboBoxModel = ComboSelectableKeyValueModel(buildOptions, getCurrentValue, keyCol, valueCol, True)
	comboBox = ComboSelectableKeyValue()
	comboBox.setModel(comboBoxModel)	
	comboBox.setRenderer(ComboSelectableKeyValueCellRenderer())				

	return SelectableKeyValueCellEditor(comboBox)
	

Anybody got any ideas?