Put a paintable canvas over a Vision window and every button, table, and tooltip beneath it stops responding. Follow the event. The OS pointer event enters the Swing window and descends through the root container. Swing delivers it to the topmost component under the pointer that has a mouse listener. The canvas registers mouse listeners, so the event stops at the canvas. The fix is a relay. The canvas receives every event, finds the component that would have received it without the overlay, converts the coordinates, and redispatches. Enter, exit, and tooltip behavior depend on tracking state, so the relay also has to synthesize those events. Build the relay from the bottom up: hierarchy first, then state, then scripts, and verify each layer before adding the next.
Which components does the window need, and how are they stacked?
Start with the physical layer, which here is the component tree. The relay searches a single container for the component under the pointer, using SwingUtilities.getDeepestComponentAt. If the glass pane lived inside that container, the search would return the pane itself and the event would loop back to its source. The content and the pane therefore have to be siblings under the root container.
- Paste a standard Container and a Paintable Canvas into the window's root container.
- Rename the container
Display Containerand the canvasGlass Pane. - Resize both to fill the entire window.
- Set the z-order so
Glass Panesits aboveDisplay Container. - Set the
Display Containerbackground to transparent so the root container still sets the window background color. - Build all operator content inside
Display Container, not directly in the root container.
Root Container
Display Container (all window content lives here)
Glass Pane (paintable canvas, top z-order)
| Component | Type | Bounds | Z-order | Background |
|---|---|---|---|---|
Display Container |
Container | Full window | Below pane | Transparent |
Glass Pane |
Paintable Canvas | Full window | Top | Painted by repaint handler only |
Check: The project browser tree shows exactly two children under the root container. Both have bounds equal to the window, and the window background color still comes from the root container.
Where does the hover-tracking state live?
Clicks and presses need no memory, because the target is simply whatever sits under the pointer at that instant. Enter and exit do need memory. Buttons and many other components change appearance on mouseEntered and mouseExited. Once the canvas owns the pointer, those events never fire naturally on the components underneath. The relay has to remember which component was under the pointer on the previous move. When that component changes, the relay generates the exit and enter events itself.
Store that memory as a String custom property named currentComponentPath. Put it on Display Container, not on the canvas. Any change to a custom property on a paintable canvas triggers a repaint. The relay rewrites this property every time the pointer crosses a component boundary, so hosting it on the canvas would produce a repaint on every boundary crossing even when the overlay has nothing new to draw.
| Property | Host component | Type | Default | Written by |
|---|---|---|---|---|
currentComponentPath |
Display Container |
String | blank | Relay on mouseMoved; reset on pane initialization |
Check: The property exists on Display Container as a String with a blank value, and the Glass Pane's custom property list does not contain it.
How does the pane address the display container after a rename?
Every event handler, custom method, and library call on the pane has to reach the display container. If the name Display Container is hard-coded in each of those places, a later naming-convention change breaks all of them without any warning. Instead, give the pane one indirection point.
- Add a String custom property
displayContainerNametoGlass Pane. - Bind it with a property binding to the
Nameproperty ofDisplay Container. - Resolve the container everywhere with
rootContainer.getComponent(glassPane.displayContainerName).
This is a custom property on the canvas, so it does trigger a repaint when it changes. It only changes on a rename, so the cost is negligible.
Check: Rename Display Container temporarily and confirm that displayContainerName follows the new name. Then restore the name.
How does the pane stay out of the Designer and come alive in preview?
A full-window component at the top z-order blocks every click-to-select in the Designer. Bind the pane's Visible property with an expression that is false in design mode and true in preview mode or a launched client:
// Hidden in design mode; visible in preview mode or a launched client
{[System]Client/System/SystemFlags} > 2
This binding does a second job. The Designer cancels preview mode on save, so the saved value of Visible is always false. At runtime the property therefore always transitions from false to true when the window opens. That transition can serve as the pane's initialization trigger, in place of componentRunning. It also fires every time preview mode is activated in the Designer, so the startup path can be tested without launching a client.
Check: In design mode, the pane is invisible and every component in Display Container can be selected. Enter preview and the pane becomes visible. Save, and confirm that the Designer leaves preview mode and the pane is hidden again.
What keeps the pane on top at runtime?
Content lives in a sibling container, and the pane is hidden whenever someone edits the window, so nothing should end up above it. However, one accidental "bring to front" on Display Container during later maintenance would put the content above the pane, and the overlay would silently stop painting where operators can see it. Enforce the order at runtime instead of trusting the saved order.
In Java, z-order is paint order, and removing a component and re-adding it through the parent's addComponent gives it the top position. Place the helper in a project library script. This walkthrough uses the name componentScripts:
def setTopPosition(component):
# Capture the parent first; a removed component can no longer reach it
parent = component.parent
# Remove and re-add to take the top z-order position
parent.remove(component)
parent.addComponent(component)
parent.repaint()
Then initialize the pane in its propertyChange handler:
# On visible == True, initialize the glass pane
if event.propertyName == 'visible' and event.newValue:
# Force the pane to the top z-order in the root container
componentScripts.setTopPosition(event.source)
# Clear hover tracking so the first move starts from a known state
event.source.parent.getComponent(event.source.displayContainerName).currentComponentPath = ''
Check: Add a temporary outline to the pane's repaint handler so you can see the pane:
g = event.graphics
g.drawRect(0, 0, event.width - 1, event.height - 1)
In the Designer, bring Display Container to the front and enter preview. The outline must still draw over the content. Remove the test line afterward.
How does a click reach the component underneath?
Each relayed event takes the same path. The relay converts the point from pane coordinates into Display Container coordinates and finds the deepest component at that point. It then walks up the ancestors until it reaches one with mouse listeners, converts the event into that component's coordinate space, and dispatches it. The ancestor walk matters because the deepest component is often an inner label or renderer with no listeners, while the listening component is its parent. Add these helpers to componentScripts:
from java.awt.event import MouseEvent
from javax.swing import SwingUtilities, ToolTipManager
def getMouseComponentAtPoint(component, container, point):
# Convert the pane-relative point into the container's coordinate space
contentPoint = SwingUtilities.convertPoint(component, point, container)
target = SwingUtilities.getDeepestComponentAt(container, contentPoint.x, contentPoint.y)
# Walk up until a component with mouse listeners is found
while target:
if target.mouseListeners:
return target
target = target.parent
def passMouseEvent(listenerComponent, event):
if listenerComponent and hasattr(listenerComponent, 'dispatchEvent'):
listenerComponent.dispatchEvent(
SwingUtilities.convertMouseEvent(event.source, event, listenerComponent))
Call one library entry point from every mouse and mouse-motion handler on the pane. This keeps all routing logic in one file, so later fixes do not require editing dozens of handlers:
componentScripts.setGlasspaneMouseEvent(event)
| Event ID on the pane | Relay target | Reason |
|---|---|---|
MOUSE_CLICKED, MOUSE_PRESSED, MOUSE_RELEASED
|
Deepest listening component at the point | Target is whatever is under the pointer right now |
MOUSE_ENTERED |
Deepest listening component at the point | Pointer entered the window over a specific component |
MOUSE_DRAGGED |
Deepest listening component at the point | Same point lookup as clicks (see drag pitfall below) |
MOUSE_EXITED |
Component decoded from currentComponentPath
|
Nothing is under the pointer once it leaves the canvas, so the stored target is used |
MOUSE_MOVED |
Deepest listening component, plus synthesized exit/enter | Tracks boundary crossings and floats tooltips |
SwingUtilities.convertMouseEvent carries over the modifiers, click count, and popup-trigger flag. Double-clicks and right-click popups therefore arrive intact.
Check: In preview, click a button, select table rows, and right-click a component that has a popup menu. Each one must respond exactly as it does with the pane hidden.
How does the relay remember the hovered component between events?
Store the hovered component as a comma-delimited list of child indexes, from the root container down to the component. Names do not work for this. Nested Ignition sub-components have null names, and template instances rendered by a template repeater or template canvas carry identical names, so a name path could not tell them apart. Indexes are unique at every level and are simple to parse.
def getComponentPath(rootContainer, component):
# None, the root container, or the window itself encode to a blank path
if component is None or component == rootContainer or hasattr(component, 'rootContainer'):
return ''
# Direct child of the root container
if component.parent == rootContainer:
return unicode(rootContainer.components.index(component))
# Deeper component: prepend each ancestor's index until the root is reached
parent = component
path = unicode(component.parent.components.index(component))
while parent.parent != rootContainer:
parent = parent.parent
path = '{},{}'.format(parent.parent.components.index(parent), path)
return path
def getComponentFromPath(rootContainer, path):
currentComponent = rootContainer
# Blank path returns the root container
if not path:
return currentComponent
for element in path.split(','):
index = int(element)
# Guard against hierarchy changes since the path was stored
if 0 <= index < currentComponent.componentCount:
currentComponent = currentComponent.getComponent(index)
return currentComponent
Two corrections are built into the encoder above. First, the direct-child branch indexes component. Indexing an unassigned parent variable raises an UnboundLocalError the first time the pointer hovers a direct child of the root container. Second, the None guard matters because getMouseComponentAtPoint returns None when no listening ancestor exists, and reading None.parent raises an AttributeError inside the mouseMoved handler.
A blank path decodes to the root container. At transitions to or from a blank path, the root container itself therefore receives the synthesized enter or exit event. Keep enter/exit logic off the root container, or add a check that skips it.
Check: In preview, add a temporary print in the mouseMoved branch that encodes the hovered component and decodes it back. Confirm that getComponentFromPath(root, getComponentPath(root, c)) is c holds for a top-level button, a nested container member, and two different instances inside a template repeater.
How are enter, exit, and tooltip events synthesized?
MOUSE_MOVED does four jobs. It relays the move, compares the encoded path of the component under the pointer with the stored path, and synthesizes exit and enter events when the path changes. It also copies the underlying component's toolTipText onto the pane. That last step is required because Swing's tooltip manager only shows the tooltip of the component that actually owns the pointer, which is the pane.
def _synthesize(component, eventId, source, point):
local = SwingUtilities.convertPoint(source, point, component)
return MouseEvent(
component,
eventId, # MOUSE_EXITED or MOUSE_ENTERED
system.date.toMillis(system.date.now()), # Timestamp
0, # Modifiers
local.x, local.y, # Component-relative point
0, # Click count
False) # Popup trigger
def setGlasspaneMouseEvent(event):
rootContainer = system.gui.getParentWindow(event).rootContainer
displayContainer = rootContainer.getComponent(event.source.displayContainerName)
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)
elif event.ID == MouseEvent.MOUSE_EXITED:
passMouseEvent(getComponentFromPath(rootContainer, displayContainer.currentComponentPath), event)
elif event.ID == MouseEvent.MOUSE_MOVED:
deepest = getMouseComponentAtPoint(event.source, displayContainer, event.point)
# Float the underlying tooltip to the pane
if deepest and hasattr(deepest, 'toolTipText') and deepest.toolTipText != event.source.toolTipText:
ToolTipManager.sharedInstance().dismissDelay = 0
event.source.toolTipText = deepest.toolTipText
ToolTipManager.sharedInstance().reshowDelay = 1000
def restoreToolTip():
ToolTipManager.sharedInstance().dismissDelay = 5000
system.util.invokeLater(restoreToolTip, 500)
passMouseEvent(deepest, event)
oldPath = displayContainer.currentComponentPath
newPath = getComponentPath(rootContainer, deepest)
if newPath != oldPath:
oldComponent = getComponentFromPath(rootContainer, oldPath)
newComponent = getComponentFromPath(rootContainer, newPath)
if oldComponent:
passMouseEvent(oldComponent, _synthesize(oldComponent, MouseEvent.MOUSE_EXITED, event.source, event.point))
if newComponent:
passMouseEvent(newComponent, _synthesize(newComponent, MouseEvent.MOUSE_ENTERED, event.source, event.point))
displayContainer.currentComponentPath = newPath
Build the synthesized coordinates from event.point, which is already in the pane's coordinate space. MouseInfo.getPointerInfo().location returns screen coordinates. If you pass that value to convertPoint with the pane as the source, x and y are offset by the pane's screen position. Components that ignore enter/exit coordinates will not show a problem, but anything that hit-tests on entry will.
By default, a Swing tooltip stays on screen and changes text as the pointer moves between components. The timing below forces the old tooltip to close when the underlying component changes, and it holds the reshow for a short interval. These values were tuned by trial and error for natural feel at runtime:
ToolTipManager setting |
Value | Applied | Effect |
|---|
ToolTipManager.sharedInstance() is one instance per client JVM. These writes change tooltip timing for every window in the client, not only the overlaid one. If the project sets its own tooltip delays elsewhere, read the current dismissDelay before overriding it and restore that value, not a fixed 5000.
Check: Sweep the pointer slowly across a row of buttons in preview. Each button's hover highlight must turn on at entry and off at exit, one at a time. A tooltip that is showing must close as soon as the pointer crosses onto the next component.
Which event paths does the relay not cover, and what are the alternatives?
Two paths need testing on any window that uses them.
| Path | Behavior under the relay | What to test / change |
|---|---|---|
| Drag beyond the pressed component |
MOUSE_DRAGGED goes to whatever is under the current point. Native Swing keeps sending drags to the component that received the press. |
Drag a slider thumb or a table column edge past its bounds. If the drag stops, store the press target on MOUSE_PRESSED and route drags and the release to it. |
| Scroll wheel | Wheel events are not in the relayed set. Swing retargets them to the nearest ancestor of the canvas that has a wheel listener, and that ancestor is never a table inside Display Container. |
Scroll a table or list under the pane. If it does not scroll, add a wheel listener to the pane that relays wheel events through the same point lookup. |
| Idle overlay | Every event pays the lookup and dispatch cost. | Set the pane's Visible to false when the overlay is not in active use. Events then go straight to the content, and setting it back to true re-runs the initialization. |
Two other designs avoid parts of this build:
| Approach | What it changes | Trade-off |
|---|---|---|
Ignition's EventDelegateDispatcher (package com.inductiveautomation.factorypmi.application.components.util), using its MouseEventDispatcher
|
Replaces the hand-built dispatch and enter/exit tracking with a built-in delegate dispatcher | Internal API. Read the class declaration in Inductive Automation's published API documentation for your Ignition version before depending on it. |
Native Swing glass pane: a JComponent composed in script and attached with a one-shot objectScript() binding |
No sibling container structure is needed, and it attaches in both preview mode and design mode | Painting has to be driven from a library function, because there is no canvas repaint event or custom-property-driven repaint |
| Paintable canvas relay (this build) | Painting runs in the canvas repaint event and is driven by custom properties | Requires the container layout above. It can also cover a single region, such as one table, instead of the whole window. |
Check: Every drag, wheel, and visibility-toggle case that the window depends on behaves the same with the pane visible and with it hidden.
How do you prove the overlay is transparent end to end?
Run the full sequence in preview first, then in a launched client. The client adds the separate JVM and the real tooltip manager state.
- Open the window. The pane is visible,
currentComponentPathis blank, and the pane is at the top z-order even ifDisplay Containerwas saved in front. - Hover every interactive component type on the window (buttons, tables, template instances, charts). Hover visuals turn on at entry and off at exit, and only one component is highlighted at a time.
- Hover components that have tooltips. The tooltip text matches the component under the pointer and closes when the pointer crosses to a new component.
- Click, double-click, and right-click each component. Actions, row selection, and popup menus fire once per gesture, with no duplicates.
- Drag sliders, split panes, and column edges past their bounds, and scroll every scrollable component with the wheel.
- Move the pointer out of the window from over a button. That button receives its exit and drops its hover state.
- Toggle the pane's visibility off and on at runtime. After re-enable, hover tracking starts clean from a blank path.
- Open the same window with the pane deleted, side by side with the overlaid copy, and repeat steps 2 through 6. Every component must behave the same in both copies. If they do, the overlay is ready to carry painting logic.
FAQ
Does changing a custom property on an Ignition paintable canvas trigger a repaint?
Yes. Any change to a custom property on a paintable canvas triggers a repaint event. Keep frequently changing state, such as the hover-tracking path, on a sibling component like the display container, and reserve canvas custom properties for values that should actually redraw the overlay.
Can I use a native Swing glass pane instead of a paintable canvas in Ignition Vision?
Yes. Compose a JComponent and attach it with a one-shot objectScript() binding. This removes the need for a sibling container structure and works whether or not preview mode is active. You give up the canvas repaint event, so painting has to be driven from a library function.
Does the glass pane block editing in the Ignition Designer?
Not if its Visible property is bound to {[System]Client/System/SystemFlags} > 2. The pane is hidden in design mode and shown in preview mode and in clients. Because saving cancels preview mode, the saved state is always hidden, and the false-to-true visibility change acts as a reliable initialization trigger.
Can I hide the glass pane at runtime to reduce overhead?
Yes. Set its Visible property to false when the overlay has nothing to show, and mouse events go directly to the content without the lookup and dispatch cost. Setting it back to true re-runs the propertyChange initialization, which restores the top z-order and clears currentComponentPath.