Scroll Window at TextArea Limits

I have a list of various fields, laid out similar to a table, by way of a template canvas. Within it are various field types, including TextAreas. When I'm trying to scroll the window using the mouse scroll wheel, it will not scroll if the mouse is hovering over a TextArea, but over any other field type it works as expected, scrolling the window. I'm guessing this is due to the fact the TextArea itself has scrolling (even if the scrollbars aren't visible due to text length), which takes precedence over the window.

What I'd like to happen is that, if the limits of the TextArea's scrolling is reached and the user continues to scroll in that direction, it will then scroll the window itself, without having to move the mouse out of the TextArea. I've seen this behavior in other places, but wasn't sure if it was easily achievable in Ignition with the native TextArea. Can I do this with the native TextArea, or would I have to create my own via a module, to get this behavior?

It shocks me that it doesn't behave this way because this describes the normal swing behavior that I'm used to seeing in Ignition.

Yes. It is possible to create a mouse wheel listener that provides the desired behavior and add it to the text area at initialization using the componentRunning property change event.

Example:

# Written for the propertyChange event handler of the templatized text area component
if event.propertyName == 'componentRunning':
	from java.awt import Point
	from java.awt.event import MouseWheelListener
	from javax.swing import JScrollPane, SwingUtilities

	# Create a special mouse wheel listener for the templetized text area
	class TextAreaMouseWheelListener(MouseWheelListener):
		def mouseWheelMoved(self, event):
			# Adjust the scroll pane position within the boundaries of the text area's viewport
			newY = event.source.viewPosition.y + event.wheelRotation * event.scrollAmount
			event.source.viewPosition = Point(event.source.viewPosition.x, min(maxY, max(minY, newY)))
			
			# If the boudaries have been met or exceeded, move the parent scroll pane instead [if it exists]
			if (newY <= minY or newY >= maxY) and parentScrollPane:
				parentScrollPane.viewport.viewPosition = Point(parentScrollPane.viewport.viewPosition.x, parentScrollPane.viewport.viewPosition.y + event.wheelRotation * event.scrollAmount)
				
			
			
	# Define the text area viewport, and attempt to get the repeater's scroll pane
	viewport = event.source.viewport
	parentScrollPane = SwingUtilities.getAncestorOfClass(JScrollPane, event.source)
	
	# Define the boundaries of the viewport
	minY = 0
	maxY = viewport.view.size.height - viewport.size.height
	
	# Add the TextAreaMouseWheelListener, but first,
	# check to see if the listener is already present, so it doesn't get added twice
	if 'TextAreaMouseWheelListener' not in [listener.__class__.__name__ for listener in viewport.mouseWheelListeners]:
		viewport.addMouseWheelListener(TextAreaMouseWheelListener())
1 Like

Awesome! Thank you very much! I'll try this out when I get some time.

So it works, but there are a couple of odd little behaviors with it.

First off, if the mouse cursor ends up on the border of the TextArea, the scrolling behavior stops like it did before. Moving it one pixel into a TextArea allows it to work again. I'm guessing this is due to the border not being part of the component that receives the listener, but if that's the case, why doesn't the default page scrolling take over, then?

Secondly, it allows you to scroll the page up beyond the normal extents of the parent window, but only while over a text area, and only when scrolling is already enabled on the parent. If scrolling is not required due to page length, this does not happen. So over a text area, the top of the page does not stop at the top of the viewport, but will keep going down until the mouse exits the text area (moving the mouse to stay in the text area will keep going until there are no more text areas that you can mouse over), leaving the area blank as the top of the page moves down. The bottom of the scrollable area acts as expected, stopping at the bottom of the viewable area.

To fix the second odd behavior, I simply did a check to see if the Y scrolling went negative, and if it did, set it to 0 instead:

if (newY <= minY or newY >= maxY) and parentScrollPane:
	scrollY = parentScrollPane.viewport.viewPosition.y + event.wheelRotation * event.scrollAmount
	scrlPnt = Point(parentScrollPane.viewport.viewPosition.x, 0 if scrollY < 0 else scrollY)
	parentScrollPane.viewport.viewPosition = scrlPnt

Just used an in-line if statement to keep it a little cleaner (and broke up the line for easier reading without side-scrolling).

I'm still stumped on the first behavior... Any ideas here? The only thing separating my text areas is a basic Line Border, no padding. I'm guessing the window still considers it part of the TextArea, but the listener does not extend beyond the viewport while the border does. Not sure how to extend a listener to a border, though...

Edit: One other behavior results from a difference in viewports. I have this code to speed up the main viewport scrolling:

def fixScrollSpeedTC(scrollableObj,speedValue):
	components = scrollableObj.getComponents()
	
	# find the embedded JideScrollPane$ScrollBar component
	for component in components:
		if str(type(component)) == "<type 'javax.swing.JScrollPane$ScrollBar'>":
			if component.getOrientation() == 1:
				component.setUnitIncrement(speedValue)

called after the construction of the main window and canvas with:

tc = system.gui.getParentWindow(event).getComponentForPath('Root Container.MainCanvas').getComponents()[0].getComponents()[0].getComponents()[2]
UniversalScripts.QoLScripts.fixScrollSpeedTC(tc,20)

But, obviously, this only affects the main window. Scrolling via the text area reverts to the slow 1-pixel scroll speed. I'm working on a way to "inherit" the main window's scroll speed rather than running the function on each individual text area, but if anyone finds a way to do that before I figure it out (if I can figure it out...), feel free to post it!

Edit2:
Was able to fix the scroll speed by adding the line:

scrlSpd = parentScrollPane.getComponents()[1].getUnitIncrement()

after where parentScrollPane was defined, then inside the listener I changed the one line:

scrollY = parentScrollPane.viewport.viewPosition.y + event.wheelRotation * event.scrollAmount

to replace event.scrollAmount with scrlSpd. This way it inherits the parent window's scroll speed automatically, so I could set additional windows to different speeds, if I want.

Still stuck on the issue of not scrolling if the mouse is over one of the text areas' borders...

That makes sense. My code example accounted for the boundaries of the text area viewport, but not the parent viewport. The ternary operator you added will fix the negative overflow, but it won't account for the max position at the bottom of the viewport. The minimum for both will be zero, so the minY variable can be used for both scroll panes. All thats needed is to add a maxParentY variable and to refactor the if statements in this to check the newY values to their respective bounds within both scroll panes.

I recommend avoiding hierarchical dependencies whenever possible because if a component's nested depth or index position within a component list ever changes for any reason, the code will break. Also, I prefer to use bean properties over methods when they are available because when working with Jython, it's best practice. Therefore, I recommend rewriting it this way:

scrlSpd  = parentScrollPane.verticalScrollBar.unitIncrement

Same thing with the tc = script that uses indexes to dig components out of a root container. That seems fragile since any subsequent developer could move something and break the code.

I recommend adding this utility function to the script library:

def getAllComponentsOfClass(container, className):
	foundComponents = []
	for component in container.components:
		if component.__class__.__name__ == className:
			foundComponents.append(component)
		else:
			foundComponents.extend(getAllComponentsOfClass(component, className))
	return foundComponents

Then, refactor the scroll increment fix to look like this:

# Get all the scroll bars in the parent window and adjust the vertical scroll increments to 20
scrollBars = UniversalScripts.QoLScripts.getAllComponentsOfClass(system.gui.getParentWindow(event), 'JScrollPane$ScrollBar')
for scrollBar in scrollBars:
	UniversalScripts.QoLScripts.fixScrollSpeedTC(scrollBar, 20)

This problem is being caused by the OEM mouse wheel listener living on the text area component itself, and in my code example, the listener was added to the inner viewport component. Therefore, outside the viewport the original listener is still consuming the event resulting in the original problem. I recommend removing the original listener because if its left in place, it's always going to be competing in some way with the new listener. Any problems that arise from this action can be addressed in the replacement listener.

Putting it all together, the refactored code example looks like this:

# Written for the propertyChange event handler of the templatized text area component
if event.propertyName == 'componentRunning':
	from java.awt import Point
	from java.awt.event import MouseWheelListener
	from javax.swing import JScrollPane, SwingUtilities

	# Create a special mouse wheel listener for the templetized text area
	class TextAreaMouseWheelListener(MouseWheelListener):
		def mouseWheelMoved(self, event):
			# Calculate what the new y postions will be
			newY = event.source.viewPosition.y + event.wheelRotation * scrlSpd * event.scrollAmount
			newParentY = parentViewport.viewPosition.y + event.wheelRotation * scrlSpd * event.scrollAmount
			
			# Apply the new y positions as needed to move the approperiate scroll bars
			event.source.viewPosition = Point(event.source.viewPosition.x, min(maxTextAreaY, max(minY, newY)))
			if (newY <= minY or newY >= maxTextAreaY):
				parentViewport.viewPosition = Point(parentViewport.viewPosition.x, min(maxParentY, max(minY, newParentY)))
				
			
	# Define the text area viewport, and attempt to get the repeater's scroll pane
	viewport = event.source.viewport
	parentScrollPane = SwingUtilities.getAncestorOfClass(JScrollPane, event.source)
	parentViewport = parentScrollPane.viewport
	
	# Set the overall scroll speed to 20 pixels per increment
	scrlSpd = parentScrollPane.verticalScrollBar.unitIncrement = 20
	
	# Define the boundaries of the viewport
	minY = 0
	maxTextAreaY = viewport.view.size.height - viewport.size.height
	maxParentY = parentViewport.view.size.height - parentViewport.size.height

	# Remove all of the factory listeners
	for listener in event.source.mouseWheelListeners:
		event.source.removeMouseWheelListener(listener)
	
	# Add the TextAreaMouseWheelListener, but first,
	# check to see if the listener is already present, so it doesn't get added twice
	if 'TextAreaMouseWheelListener' not in [listener.__class__.__name__ for listener in viewport.mouseWheelListeners]:
		viewport.addMouseWheelListener(TextAreaMouseWheelListener())

Result:
ScrollListenerExample

3 Likes

Thank you for this! I always felt like the approach I took was, as you said, "fragile". I always see it as best to work backwards from a reference than forwards from a static point, in a dynamic codebase.

I did try something along the lines of your solution, after seeing your approach, but it threw an error. Looking back, I likely didn't run it on the correct object, is why it failed (was trying it as a fix for the tc = script, but I needed something "working" that I could show, so I put it on the back burner). Doesn't help I'm rusty with Java and new to Python/Jython (also entirely self-taught in all fields... so I'm sure there are other mistakes I make and don't realize :sleepy:).

Works great! Sounds like I was on the right track with the listeners... just didn't quite dig deep enough. I did have one issue... For some reason, when I first ran it, the line:

maxParentY = parentViewport.view.size.height - parentViewport.size.height

it threw the error:

File "<event:propertyChange>", line 30, in <module>
AttributeError: 'instancemethod' object has no attribute 'height'

Which I fixed by simply removing the first ".size" call.

maxParentY = parentViewport.view.height - parentViewport.size.height

Using print dir(parentViewport.view.size) shows no attribute for width or height (as stated by the error), while the viewport view does. In fact, the only non-special attributes within view.size are: 'im_class', 'im_func', 'im_self'. Not sure what purpose this particular size function serves. I'll have to dig into it when I get some time.

TLDR: You're awesome! Thank you for taking the time to not only help, but describe what's going on!

Edit:
Looks like that one odd issue popped up again with negative values. This time it only shows up if the page is scrolled when it doesn't need to be (ie: doesn't fill the entire viewable area). Causing the top of the page to move down so the bottom of the page is at the bottom and the top has a gap based on the size of the page. Simple fix by checking if maxParentY is negative, and if so, setting to 0, instead.

1 Like