Using ‘Image’ rather than ‘Button’ in ScriptUI.


Originally posted on April 6, 2011. Updated to support InDesign CC. See details below.


The starting point is very simple: instead of using the native ScriptUI Button wrapper, we want to display a pure Image object that will receive mouse events and behave like a button.

Note. — The Image widget is poorly documented and should not be confused with the ScriptUIImage structure that encapsulates a few properties of an actual image. For its part, the Image widget is a simple container and it virtually operates as a Group. Although it may remain empty it is designed to contain a ScriptUIImage that is accessed to by the image (or icon) property.

Since ScriptUI 4.0, every component —including passive widgets such as Group and Image— is allowed to dispatch mouseover, mousedown, mouseout, and mousemove events. Hence we can write event listeners to make this kind of widget more reactive. Of course it is possible —though not so obvious!— to dynamically change the underlying bitmap of an Image object. But why not trying to break the back of the beast with a single image?

Three Sprites under the hood

To mimic a reactive button, what we basically need is a three-state component, as shown below:

Three states of the image-based button.

An important prerequisite is that the different states of this triptych have the same dimensions. Now let's see the situation from a ‘sprite’ perspective:

The sprite perspective.

The above screen shows the sliced PNG image (1), and the state of the GUI (2) when the mouse rolls over the widget. All we have to do is to appropriately shift the image along the vertical axis of that container. The container acts like a mask: it displays the relevant part of the bitmap within its own area.

Technically, the whole trick is based on the ScriptUIGraphics.drawImage(…) method, defined as follows:

    drawImage(image, x, y, width, height)

where:

image refers to a ScriptUIImage object;
x (Number) is the (positive or negative) left coordinate of the region, relative to the origin of the element (0 is default);
y (Number) is the (positive or negative) top coordinate of the region, relative to the origin of the element (0 is default);
width and height (Number) are the dimensions of the image. If provided, the image is stretched or shrunk to fit. If omitted, uses the original image dimensions.

As you probably guessed, we are going to play with the y argument…

Further in the code

There are two critical issues to be aware of:

• By default, an Image widget —i.e. the container— will get the size of the underlying bitmap at construction time, provided that we set the content at construction time and that we do not override the default mechanism:
    myImage = myWindow.add('image', undefined, myBitmap).
Note that myBitmap might be: a File (PNG, JPEG), a ‘resource’ code (see Peter Kahrel's ScriptUI for Dummies for advanced details on this topic), a ScriptUIImage, or even a String that directly contains the bytes of the bitmap —undocumented but really cool feature!
Then, in order to use the sprite-based strategy, we have to explicitly set the size of the container by dividing by three the height of the image. As I wanted to keep my code as generic as possible, I preferred not to hard-code the size of any component. Therefore I let the default mechanism detects the actual size of the supplied bitmap, then I refine the size of the container:

// . . .
 
// Number of vertical slices
// ---
var V_SPRITES = 3;
 
// Create the UI
// ---
var w = new Window('dialog',"ScriptUI Sprites"),
    myImage = w.add('image', undefined, myPNG),
    iSize = myImage.image.size,
    spriteHeight = iSize[1] / V_SPRITES;
 
myImage.size = [iSize[0], spriteHeight];
 
// . . .
 

• Another important point is that we cannot invoke myImage.graphics.drawImage(...) without precaution. As far as I know, such operation is only available during the draw event, hence in the scope of an onDraw routine. That's why the mouse event handler artificially forces a redraw via the custom Image.prototype.refresh() method we have implemented in v. 1.02 of the sample script. The previous version was invoking myImage.parent.layout.layout(true), an old way to trigger onDraw, but that trick does not work in CC anymore and, to be honest, this was not an elegant solution. Here is the new routine:

// Force an Image widget to repaint itself (= onDraw trigger)
// CS4-CS6  ->  just reassigning this.size
// CC       ->  we need to temporarily *change* the size
// Note: using layout.layout(1) would not work anymore in CC
// ---
 
const CC_FLAG = +(9 <= parseFloat(app.version));
 
Image.prototype.refresh = CC_FLAG ?
    function()
    {
        var wh = this.size;
        this.size = [1+wh[0],1+wh[1]];
        this.size = [wh[0],wh[1]];
        wh = null;
    }:
    function()
    {
        this.size = [this.size[0],this.size[1]];
    };
 

InDesign CC Compatibility Note #1. — The key idea, as you can see, is to reset the size property of the widget. This seems stupid at first glance, as we just reassign the same width and height to this.size. But ScriptUI internally detects this assignment and then triggers this.onDraw, which we couldn't do directly in a secure way. However, this trick does not work as easily in CC. Indeed, ScriptUI CC is somewhat smarter than its previous versions in that it only calls onDraw if the size is actually modified. So we have implemented a compatibility patch in which we temporarily increase the size by 1 pixel then revert to the original value.

InDesign CC Compatibility Note #2. — In the previous versions of ScriptUI we observed that when the image is shifted within its container by a non-zero offset we needed to compensate the move for 1 pixel. This bug is now fixed in ScriptUI CC. For that reason I introduced a constant FIX_OFFSET which I set to 0 in CC only.

Finally, here is the main routine of the script:

// . . .
 
myImage.onDraw = function()
{
    var dy = this.properties.state*spriteHeight + FIX_OFFSET;
    this.graphics.drawImage(this.image,0,-dy);
};
 
var mouseEventHandler = function(ev)
{
    // Update the 'state' of myImage (internal flag)
    // ---
    this.properties.state = ('mouseover'==ev.type)+
        2*('mousedown'==ev.type);
 
    // Force onDraw (custom prototyped method)
    // ---
    this.refresh();
};
 
// Register the mouse event handler
// ---
myImage.addEventListener('mouseover', mouseEventHandler);
myImage.addEventListener('mousedown', mouseEventHandler);
myImage.addEventListener('mouseup', mouseEventHandler);
myImage.addEventListener('mouseout', mouseEventHandler);
 
// . . .
 

I hope you'll enjoy customizing ScriptUI buttons!