Paintable Canvas Hacks

I've been playing around with the paintable canvas quite a bit here lately for fun, and I was wondering if anybody had any cool hacks for canvas development they would be willing to share.

To start things off, I'll share a simple way to get drawn curves right in the canvas.

Edit: The gauge example below is just a component with a lot of curves. There are better and easier ways to make such a gauge with the canvas, but for the purposes of this post, the number of curves this component has makes it ideal for demonstrating this method.

A simple method for drawing perfect curves with the paintable canvas:
Using variable names that are self explanitory, the drawArc method looks like this:

g.drawArc(x, y, width, height, startingAngle, angleLengthInDegrees)

I've found that if you take the circle tool, give it a transparent background, and position it where you want the curve to be, its x, y, width and height parameters will be exactly right.

Therefore. if I wanted to make something like this custom gauge with rounded edges:
image

I could start by simply overlaying my canvas with simple shapes produced by the round shape tool to get the proper dimensions:
image

image

Then, select each shape and press ctrl-P to get the parameters:
image
In my case, the bounds for the four circles are:

[46, 221, 50, 50] # Left small circle
[204, 221, 50, 50] # Right small circle
[50, 50, 200, 200] # Large inner circle
[0, 0, 300, 300] # Large outer circle

Once this is done there will only be two parameters needed for each of the four arcs. This can be determined by simply using a unit circle:
image

It looks like my two large circles should start at about 315 degrees and end at around 225 degrees. That would be a total of 270 degrees travel, so my code would look like this:

g = event.graphics

g.drawArc(0, 0, 300, 300, 315, 270)
g.drawArc(50, 50, 200, 200, 315, 270)

Looking again at the unit circle, I can estimate that my small left circle should start at around 225 degrees and end 180 degrees later at 45 degrees. My right small circle looks like it should start at around 135 degrees, and end 180 degrees later at 315 degrees, so I'll add these lines to my code:

g.drawArc(46, 221, 50, 50, 225, 180)
g.drawArc(204, 221, 50, 50, 135, 180)

When I run preview mode, I see this in my canvas:
image

It's not perfect, but it's pretty darn close. From here, a little trial and error to tweak the numbers produces this result:
image

g = event.graphics

# Large Outer Circle
g.drawArc(0, 0, 300, 300, 310, 280)

# Large Inner Circle
g.drawArc(50, 50, 200, 199, 309, 283)

# Small Left Arc
g.drawArc(46, 221, 51, 51, 228, 180)

# Small Right Arc
g.drawArc(204, 221, 50, 50, 130, 180)
4 Likes

This is how I did it a few years ago...
GaugeArcDonutAnimation
GaugeCanvasArcDonutAnimated.zip (23.5 KB)
I'm using Arc2D.Double with g.setStroke(BasicStroke(arcwidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)).
CAP_ROUND is for rounded ends, CAP_BUTT is for normal ends...

6 Likes

Customized Borders:
Simply fill the bottom layer of a container with a paintable canvas to create custom borders like these:
image

# Curved border
# Developed for the repaint event handler of a paintable canvas component

# Define the parent container
parent = event.source.parent

# Define a border width
borderWidth = 15

# Gradient severity
darkenPerPixel = 3

# Get graphics instance
graphics = event.graphics

# Define the unedited background color using rgba intengers
# Since the border color shifts by the amount to darken for every pixel of border width,
# ...each rgb must be at least the same integer value as the border width to ensure no integer can be calculated as less than zero.
startingRed = parent.background.red if parent.background.red >= borderWidth * darkenPerPixel else borderWidth * darkenPerPixel
startingGreen = parent.background.green if parent.background.green >= borderWidth else borderWidth * darkenPerPixel
startingBlue = parent.background.blue if parent.background.blue >= borderWidth else borderWidth * darkenPerPixel

# If any rgb values were adjusted, apply the adjusted values to the parent container's background property
startingBackground = system.gui.color(startingRed, startingGreen, startingBlue, parent.background.alpha)
if parent.background != startingBackground:
	parent.background = startingBackground

# For each side of the canvas, paint the border
for side in range(4):

	# Initialize RGB values for the border. For left and top (side 0 and 1), reduce each by borderWidth
	# For bottom and right (side 2 and 3), keep the original starting RGB values
	# Determine the start and end points for drawing lines based on the side being processed
	if side == 0 or side == 1: # Left side or Top side
		red = startingRed - borderWidth * darkenPerPixel
		green = startingGreen - borderWidth * darkenPerPixel
		blue = startingBlue - borderWidth * darkenPerPixel
		start = 0
		end = borderWidth
	else:
		red = startingRed
		green = startingGreen
		blue = startingBlue				
		if side == 3: # Right side
			start = event.source.width - borderWidth
			end = event.source.width
		else: # Bottom side
			start = event.source.height - borderWidth
			end = event.source.height
			
	# Draw the border lines, adjusting the RGB values to create a gradient effect along each side
	for pixel in range(start, end):
	
		# Increment or decrement RGB values to create a rounded fading effect on the border
		if pixel - start < borderWidth and side < 2 :
			red +=  darkenPerPixel
			green += darkenPerPixel
			blue += darkenPerPixel
		else:
			red -= darkenPerPixel
			green -= darkenPerPixel
			blue -= darkenPerPixel
			
		# Set the current color for drawing
		graphics.setColor(system.gui.color(red, green, blue))
		
		# Draw lines based on the side being processed to create the border effect
		if side == 0: # Left side
			graphics.drawLine(pixel, (0 + pixel), pixel, (event.source.height - pixel))
		elif side == 1: # Top side
			graphics.drawLine((0 + pixel), pixel, (event.source.width - pixel), pixel)
		elif side == 3: # Right side
			increment = event.source.width - pixel
			graphics.drawLine(pixel, (0 + increment), pixel, (event.source.height - increment))
		else: # Bottom side
			increment = event.source.height - pixel
			graphics.drawLine((0 + increment), pixel, (event.source.width - increment), pixel)
1 Like

That looks familiar...

This demonstrates using hit testing on the canvas to turn your gauge into a "slider" for input purposes.

Be warned in advance that there is some "fancy" math involved.

I also often find that is useful to do all of the drawing at a known unit size and then transform and/or scale to the final size at the end.

1 Like

These lines from the default component example are handy:

#### Scale graphics to actual component size
dX = (event.width-1)/100.0
dY = (event.height-1)/100.0
g.scale(dX,dY)
1 Like

On Screen Presentation Mouse:
Presentation3

I was doing a demo video for a tutorial I made about modifying a slider's click behavior, and to make the mouse clicks visible, I created a simple demo mouse with the paintable canvas. It's useful, so I'm posting the tutorial on how to create it here in case anybody else would like to use it.

First: add two custom boolean properties to the paintable canvas called leftClick and rightClick:
image

Second: Add this script to the paintable canvas's repaint event handler:

# Get the graphics object from the event
graphics = event.graphics

# Create the main body of the mouse
graphics.setColor(system.gui.color('grey'))
graphics.fillOval(40, 20, 120, 200)

# Set the button colors according to whether or not they have been clicked
# and create the buttons

# Left Button
if event.source.leftClick:
	graphics.setColor(system.gui.color('lightgreen'))
	graphics.fillArc(50, 50, 100, 60, 90, 180)
	
else:
	graphics.setColor(system.gui.color('lightgrey'))
graphics.fillArc(50, 50, 100, 60, 90, 180)

# Right Button
if event.source.rightClick:
	graphics.setColor(system.gui.color('lightgreen'))
	graphics.fillArc(50, 50, 100, 60, 270, 180)

else:
	graphics.setColor(system.gui.color('lightgrey'))
graphics.fillArc(50, 50, 100, 60, 270, 180)

graphics.setColor(system.gui.color('black'))
#Draw a centerline between the buttons
graphics.drawLine(100, 50, 100, 110)

# Create a center mouse wheel
graphics.setColor(system.gui.color('grey'))
graphics.fillOval(95, 65, 10, 30)

# Create a shadow around the buttons when they are clicked,
# ...to create a depressed effect
if event.source.leftClick:
	graphics.setColor(system.gui.color('black'))
	graphics.drawArc(50, 50, 100, 60, 90, 180)
if event.source.rightClick:
	graphics.setColor(system.gui.color('black'))
	graphics.drawArc(50, 50, 100, 60, 270, 180)

Finally: On the component that is being clicked on for the demonstration, add this script to the mousePressed event handler:

if event.button == 1: # left click
	event.source.parent.getComponent('Paintable Canvas').leftClick = True
elif event.button == 3: # right click
	event.source.parent.getComponent('Paintable Canvas').rightClick = True

...and add this script to the same component's mouseReleased event handler:

if event.button == 1:
	event.source.parent.getComponent('Paintable Canvas').leftClick = False
elif event.button == 3:
	event.source.parent.getComponent('Paintable Canvas').rightClick = False

Gaming Applications:

I was showing off the paintable canvas to my son and simultaneous showing him some practical applications for the vector math he was studying in trigonometry class. Things probably got a little carried away, and Bananas! was born. The whole game is rendered by a single paintable canvas, so there are a lot of cool hacks in there: slider controls, switches, buttons, animations, etc.

I had already constructed some of the ground work this a couple of years ago when I made this post:

That code includes moving dynamically created shapes around inside a canvas with a mouse, and it has various animation concepts such as momentum, reflection, etc.

5 Likes

Keyboard Controls:

It's possible that some wouldn't find this intuitive because simply typing code into the key event handlers for the paintable canvas doesn't work. To enable the controls, the paintable canvas has to be given focus first. An easy and intuitive way to accomplish this is to simply add event.source.requestFocusInWindow() to the mouse clicked event handler. Then, the key event handlers will work once the canvas has been clicked on.

Example keyPressed script:

# A timer component could be started with the keyPressed event handler
# ...to conduct sustained repetative presses when a key is held down,
# ...and the key released event handler would stop and reset the timer.

if event.keyCode == event.VK_UP:
	print 'UP'
elif event.keyCode == event.VK_DOWN:
	print 'DOWN'
elif event.keyCode == event.VK_ENTER:
	print 'ENTER'
elif event.keyCode == event.VK_RIGHT:
	print 'RIGHT'
elif event.keyCode == event.VK_LEFT:
	print 'LEFT'

Edit/Additional information:
When painting controls in the canvas, a focus traversal policy can be simulated by putting a focusable component in the container with the canvas. Use the component's focusGained event handler to simply redirect the focus back to the canvas. Inside the canvas itself, use the focusLost event handler to traverse painted controls:

if event.cause == event.cause.TRAVERSAL_FORWARD:
	# Drive simulated forward traversal here
elif event.cause == event.cause.TRAVERSAL_BACKWARD:
	# Drive simulated reverse traversal here

Note: It could seem like the requestFocusInWindow could be called directly from the focusLost event, eliminating the need for some arbitrary focusable component, but without it, there will be nothing for the real focus traversal policy to tab to, and consequently, there is no guarantee that the focusLost event will occur.

Tangibly, I'm not aware of a simple way to detect or consume the tab key events in Vision, so I imagine this approach could easily be adapted for use cases with other components where it is desirable to mute or manipulate tab actions.