Using the Paintable Canvas as a Glass Pane

This is a tutorial I've wanted to do for some time because the glass pane is by far the most versatile Ignition tool I have ever developed.
• I've used them in informationally dense tables to highlight both the column and row underneath the mouse and to provide outlines or special indication for the selected column and row.
• I use them in set up screens to produce right click popups that illustrate conditional configurations.
• I use them to produce spotlights for component types.
• I've used them to augment charts with information that wouldn't be displayable in a conventional plot.
• I've used them to illustrate flow through pipes in real time.
• I have greatly improved the drag and drop animations between tree components, tables, and charts.

The additional functionality that a glass pane can add is only limited by the imagination. Moreover, to do these things with without a glass pane would require a host of additional shapes, components, scripting, and bindings that cumulatively could have a major performance impact to the project, but a glass pane is one component that is fully customizable, and only paints when needed.

Many times, a pure glass pane isn't needed. Meaning, not everything in this tutorial will be required for every situation. Ideally, the pane should be kept as lean as possible, and in my own practice, I often set the visibility of the pane to false when it's not in active use to make the implementation as efficient as possible, but efficiency and custom implementations are fodder for future posts. This first post will only cover creating the pure glass pane, and not any of the applications for it. My hope is that as other developers find uses for this, they will reply to this post and share what they develop to help others.

Paintable Canvas Glass Pane Tutorial

Step 1: Prep the window.
To do this right, the container that displays the content of the window should be a sibling of the glass pane.

To accomplish this, paste a regular container and a paintable canvas into the root container of the window. and then
• Rename the container "Display Container"
• Rename the paintable canvas "Glass Pane"

If done correctly, the tree structure will look like this:

...and the window should look something like this:

Resize the display container and the glass pane, so they both fill the entire window, and set the z-order of the glass pane, so it is on top of the display container. I also like to set the background of the display pane to transparent, so the background color of the window is still set by the root container.

Result:

Step 2: Prep the Root Container
Passing most mouse events to underlying components is relatively simple, but many components such as buttons perform actions on mouse entered and exited events. This will require tracking which component is under the mouse, and manually creating the events whenever the component changes. I do this by storing the component path as a string on a custom property. However, it's important to note that any custom property on a paintable canvas will trigger a repaint event any time the property changes. Therefore, to prevent unnecessary repaints, I recommend putting this custom property on the display container instead of the glass pane.

a custom property on the display container component
image

Step 3: Prep the glass pane
The glass pane will need to continuously reference display pane to pass and receive information to and from underlying components as well as painting over or around components when performing its actual purpose. Needless to say, there's no telling how many of these additional references will end up in the various event handlers, custom methods, and library script calls. This all translates to a big headache if some future developer or project manager decides to arbitrarily change the naming convention. For this reason, I like to go ahead and add a custom property to the glass pane that I bind directly to the display containers name. Then, this property alone can be used to call the display container, and any name changes will not incur an additional maintenance cost as a result of the scripting.

a custom property on the glass pane
bind this to the display container's name property
image

Since the glass pane is at the highest level z-order, it naturally gets in the way when trying to make normal edits to the window in the designer. Therefore, it's useful to put an expression binding on the glass pane's visibility property to automatically hide it when the designer is not in preview mode:

expression binding on the glass pane's "Visible" property

// # Automatically hides the component until preview mode is activated
// # ...or the client is launched
{[System]Client/System/SystemFlags} > 2

Since preview mode is automatically cancelled on save, the glass pane's expression binding ensures visibility property will always be false when the window is saved. Therefore, we can treat the visibility propertyChange event like a componentRunning initialization event. The main advantage to doing it this way, is that initialization can be triggered in the designer every time preview mode is activated.

Since all components are in a sibling container and since the glass pane's visibility property is false outside of run time, it shouldn't be possible at this point for anything to be pasted or placed above the glass pane, but you never know when somebody will inadvertently bring the display container itself to the front when working on the project, so as a safeguard against that type of breakage, I've developed an utility script for bringing a given component to the top level z-order at run time. This is a useful function for more than just glass panes, so I feel it should be put in the script library, so when needed, it can be called from any component in the project. For this tutorial, the library script will be named componentScripts.

componentScripts.setTopPosition(component) in the project library

def setTopPosition(component):
	'''
	Arguments:
		component: The component to position at the top level z-order
	
	Returns:
		None
	
	Overview:
		Since z-order in java is really paint order, and its set from top to bottom in order from last component added to first,
		...this function simply removes the component and adds it back to give it top level positioning
	'''
	# Assign the parent to a variables,
	# ...since the component can't call its parent after it has been removed from it
	parent = component.parent
			
	# Remove the component from the parent container and add it back,
	# ...so it receives top level z-order and won't inadvertently be underneath anything
	parent.remove(component)
	parent.addComponent(component)
	
	# Repaint the component in its new position
	parent.repaint()

With all that prep work done, we can now write the initialization script for the glass pane's propertyChange event handler:

# On visible == True, initialize the glass pane
if event.propertyName == 'visible' and event.newValue:
	
	# Force the glass pane to be at the highest layer z order in the root container
	componentScripts.setTopPosition(event.source)
	
	# Clear the current component path that is used for triggering mouse entered and exited events
	event.source.parent.getComponent(event.source.displayContainerName).currentComponentPath = ''

Step 4: Handle mouse events.
With the exeptions of the mouseExited and mouseMoved events, all mouse events are handled by simply passing the mouse event to the deepest component with a mouse listener. The mouseExited event handler can't use this approach because there are no underlying components under the mouse when the mouse leaves the canvas, and the mouseMoved event handler has to keep track of the components currently under the mouse, so it can it can trigger individual mouseEntered and mouseExited events in the underlying components. It also has the task of floating tool tips to the surface.

To simplify this, and to make sure any future updates that are needed for the glass panes are not grotesquely time consuming, I package all this up into a library script called setGlasspaneMouseEvent and call it from every mouse event in the canvas.

Example: calling the setGlasspaneMouseEvent function in a generic library script called componentScripts
place this on all of the glass pane's mouse event handlers

componentScripts.setGlasspaneMouseEvent(event)

Within the library script, I use several utility functions I've developed to process the underlying components. The first one uses swing utilities to get the deepest component at a given point, and then loops through the ancestor components until it finds one with a mouse listener.

script for finding the component under the mouse

def getMouseComponentAtPoint(component, container, point):
	'''
	Arguments:
		component: the component that the given point is relative to
		
		container: The container to search in for the deepest component
		
		point: The point relative to the given component that will be converted to the relative point in the container
	
	Returns:
		The innermost component with a mouse listener
	'''
	
	# Get the point in the content pane relative to the glass pane
	contentPoint = SwingUtilities.convertPoint(component, point, container)
	
	# Find the closest mousable ancestor
	# ...if the current component lacks mouse listeners
	targetComponent = SwingUtilities.getDeepestComponentAt(container, contentPoint.x, contentPoint.y)
	while targetComponent:
		if targetComponent.mouseListeners:
			return targetComponent
		targetComponent = targetComponent.parent

Once the underlying component is found, it will need to be stored for reference and comparison during future events. I store the component as a simple comma delimited set of component indexes from the root container to the component itself. Component names could be used for this up to a point, but the names of nested Ignition components will be null, and the names of templates that are rendered by a template repeater or canvas would be indistinguishable from each other. Consequently, I've elected to stick with indexes to avoid any parsing convolution.

script for converting a deeply nested component path into a comma delimited list of indexes

def getComponentPath(rootContainer, component):
	"""
	Arguments:
		rootContainer: A reference to the root container of the window,
		...which will be the ending point of the path building
		
		component: The actual component that needs an encoded string path
	
	Returns:
		A string that represents the path from the root container of the window to a given component
		
	Overview:
		Creates a comma delimited string of all the hierarchical indexes from the root container to a given component.
	"""
	# Validate a component as not being the root container a direct child of the root container or the parent window.
	# If exempt from the above conditions,
	# ...define the parent and path variables that will be needed for looping through all of the parents,
	# ...and obtaining their relative hierarchical indexes
	if component != rootContainer and component.parent != rootContainer and not hasattr(component, 'rootContainer'):
		parent = component
		path = unicode(component.parent.components.index(component))
	
	# If the component is the direct child of the root container, directly return its relative string index
	elif component.parent == rootContainer:
		return unicode(component.parent.components.index(parent))
	
	# If the component is the root container or the parent window,
	# ...return a blank string. [the getComponentFromPath function that decodes the path string
	# ...always starts with the root container, and returns it by default if no subsequent indexes are in the string]
	else:
		return ''
	
	# Loop through all parent components inserting their names into the path string until the root container is reached
	while parent.parent != rootContainer:
		parent = parent.parent
		path = '{},{}'.format(parent.parent.components.index(parent), path)
	return path

When needed, the component can be obtained using the encoded path with this function:

function for obtaining a component using a comma delimited list of indexes

def getComponentFromPath(rootContainer, path):
	"""
	Arguments:
		path: A comma delimited string that represents the nested indexes needed to obtain the desired component
		
	Returns:
		The component represented by a given path
	
	Overview:
		Iterates through the elements of the given path string, and returns the nested component object the string represents
	"""
	currentComponent = rootContainer
	
	# If the path is blank or null, simply return the root container
	if not path:
		return currentComponent
	
	# The path string is comma delimited, so the elements can be broken up into a list on the comma that can be iterated through
	for element in path.split(','):
		index = int(element)
		
		# As a safeguard against hierarchical change, validate the given index before attempting to retrieve the component it represents.
		if 0 <= index < currentComponent.componentCount:
			currentComponent = currentComponent.getComponent(index)
	return currentComponent

As stated above, there are three different scenarios for passing mouse events. In all three cases, the basic task of passing mouse events is the same, and there are many other uses for passing mouse events than just glass panes, so for the generic task of dispatching a mouse event from one component to another, I developed this utility function:

utility function for converting and passing a mouse event from one component to another.

def passMouseEvent(listenerComponent, event):
	if listenerComponent and hasattr(listenerComponent, 'dispatchEvent'):
		listenerComponent.dispatchEvent(SwingUtilities.convertMouseEvent(event.source, event, listenerComponent))

With all that in place, the setGlasspaneMouseEvent function is fairly straight forward. All mouse events other than mouseMoved are simply converting and passing the paintable canvas's mouse event to an underlying component. However, the mouseMoved event has to do a little bit more. As the mouse moves, the underlying component has to be monitored for change. If the component under the mouse changes, a mouseExited event has to be dispatched to the old component and a mouseEntered event has to be dispatched to the new component.

Furthermore, the toolTipText property of the paintable canvas has to updated to reflect the toolTipText of the underlying component, so tool tips will display properly. One thing that sometimes annoys me about tool tips it the fact that once they are triggered, they stay up and in the way even as they change from one component to the next. Consequently, I put my own spin on this by forcing a dismiss of the tool tip when the underlying component changes, and I don't reenable the display timer until a half a second has passed. I have found through trial and error that this timing feels the most natural.

library function for passing glass pane mouse events to the underlying components

from java.awt import MouseInfo 							# For determining mouse pointer relative locations
from java.awt.event import MouseEvent					# For creating passable mouse entered and mouse exited events
from javax.swing import SwingUtilities, ToolTipManager	# SwingUtilities for facilitating mouse point conversions and ToolTipManager for manipulating when the tooltip appears

def setGlasspaneMouseEvent(event):
	# The rootContainer variable is used for getting the componentPath
	rootContainer = system.gui.getParentWindow(event).rootContainer
	displayContainer = system.gui.getParentWindow(event).rootContainer.getComponent(event.source.displayContainerName)
	
	# Pass the given mouse event to the deepest underlying component with a mouse listener
	if event.ID in [MouseEvent.MOUSE_CLICKED, MouseEvent.MOUSE_ENTERED, MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_DRAGGED]:
		
		passMouseEvent(getMouseComponentAtPoint(event.source, displayContainer, event.point), event)
	
	# Pass any mouse exited event to the previous underlying component with a mouse listener
	elif event.ID == MouseEvent.MOUSE_EXITED:
		passMouseEvent(getComponentFromPath(rootContainer, displayContainer.currentComponentPath), event)
		
	elif event.ID == MouseEvent.MOUSE_MOVED:
		
		# Locate the deepest component with a mouse listener using the getMouseComponentAtPoint library script
		deepestComponent = getMouseComponentAtPoint(event.source, displayContainer, event.point)
		
		# If a deepest component with a mouse listener is found that has toolTipText that's different from the current toolTipText...
		if deepestComponent and hasattr(deepestComponent, 'toolTipText') and deepestComponent.toolTipText != event.source.toolTipText:
		
			# Set the dismiss delay to 0, so the current tooltip will be dismissed immediately
			ToolTipManager.sharedInstance().dismissDelay = 0
		
			# Update the glass pane with the new toolTipText
			event.source.toolTipText = deepestComponent.toolTipText
				
			# Set the reshow delay to one second (Trial and error determined that this looks the best at run time)
			ToolTipManager.sharedInstance().reshowDelay = 1000
			
			# Delay the restore for a half a second
			def restoreToolTip():
				
				# Reset the dismiss delay 
				ToolTipManager.sharedInstance().dismissDelay = 5000
			system.util.invokeLater(restoreToolTip, 500)
		
		# Pass the mouse moved event to the deepest component with a mouse listener
		passMouseEvent(deepestComponent, event)
		
		# Get the component path for the previous mouse event and the current mouse event
		# ...These are coded strings that represent how to get references the specific components in the content container
		oldComponentPath = displayContainer.currentComponentPath
		newComponentPath = getComponentPath(rootContainer, deepestComponent)
		
		# If the new path is now different from the old path, that means the mouse has left the previous component and entered a new component,
		# ...so it's time to trigger each component's relative mouse entered and exited events
		if newComponentPath != oldComponentPath:
			
			# Get the old and new components and a point that is relative to the screen
			oldComponent = getComponentFromPath(rootContainer, oldComponentPath)
			newComponent = getComponentFromPath(rootContainer, newComponentPath)
			currentPoint = MouseInfo.getPointerInfo().location
			
			# Verifiy that the old component exists, create a mouse exited event, and pass it to the component
			if oldComponent:
				mouseExited = MouseEvent(
					oldComponent,			
					MouseEvent.MOUSE_EXITED,													# Type of MouseEvent
					system.date.toMillis(system.date.now()),									# Timestamp
					0,																			# Modifiers
					SwingUtilities.convertPoint(event.source, currentPoint, oldComponent).x,	# X coordinate
					SwingUtilities.convertPoint(event.source, currentPoint, oldComponent).y,	# Y coordinate
					0,																			# Click count
					False)																		# Popup Trigger?
				passMouseEvent(oldComponent, mouseExited)
			
			# Verifiy that the new component exists, create a mouse entered event, and pass it to the component
			if newComponent:
				mouseEntered = MouseEvent(
					newComponent,			
					MouseEvent.MOUSE_ENTERED,													# Type of MouseEvent
					system.date.toMillis(system.date.now()),									# Timestamp
					0,																			# Modifiers
					SwingUtilities.convertPoint(event.source, currentPoint, newComponent).x,	# X coordinate
					SwingUtilities.convertPoint(event.source, currentPoint, newComponent).y,	# Y coordinate
					0,																			# Click count
					False)																		# Popup Trigger?	
				passMouseEvent(newComponent, mouseEntered)
			
			# Update the currentComponentPath custopm property to represent the new component
			displayContainer.currentComponentPath = newComponentPath

That's it! That's all that's needed to build a generic paintable canvas glass pane that is perfectly imperceptible to the user but can be used to dynamically paint anything that is needed anywhere in a window.

Here is a copy of the test window and library script that were both used to illustrate this tutorial. Note how the behavior of the underlying components is indistinguishable from the behavior of the same components in a window without an overlay.

GlasspaneLibraryScripts.zip (3.7 KB)

GlasspaneTestWindow.zip (49.7 KB)

...and have fun adding unlimited functionality to your projects using the paintable canvas as an overlay

7 Likes

Not sure if you've already tried this and discarded it, but a lot of the mouse/event shenanigans might be simplified if you instantantiate and use our MouseEventDispatcher from EventDelegateDispatcher:

1 Like

Pretty cool. Is there any reason you didn't want to use the native Swing glass pane support? You would have had to compose a JComponent in place of the paintable canvas, but it doesn't seem that you are using anything that requires the canvas. (Presuming you draw in a library function instead of directly in the canvas.)

It would have the distinct advantage of not requiring any special container structure within the target window. You could attach it with a one-shot objectScript() binding, which works whether in preview mode or not.

For me, these tend to be highly specialized, and I don't always use them at top level. Consequently, I do use the repaint event directly, and I drive the actions of the canvas with its custom properties.