Writing a Widget for Pyjs

An important part of a widget toolkit is being able to write your own widgets. In many widget sets, you are confronted immediately with a quite complex set of unusual-looking functions - paint, draw, refresh and other manipulations. Pyjs has none of that: in both Pyjs and Pyjs-Desktop you're manipulating the DOM model - an HTML page - as if it was an XML document. Better than that: unlike with manipulating an HTML page, you don't have to get involved with Javascript - unless you want to. Pyjs provides a module which makes the job of controlling the underlying DOM model that much easier, and this tutorial shows step-by-step how to go about creating your own widget.

Missing from the HTML specification, but present in Adobe Flash, is things like sliders and dials. Many Desktop widget sets have them, so it makes a lot of sense to create one. We'll start with a simple Vertical scroller which receives "mouse click" to change the position. (For those people who would like to see the completed source code click here).

Vertical Slider

We start off by importing the DOM model and, because the slider will receive mouse (and later keyboard) events, we base it on FocusWidget. FocusWidget has the means to add keyboard and event listeners, set a "tab order" index, and to set and clear focus:

from pyjamas import DOM
from pyjamas.ui import FocusWidget

So, we derive our class from FocusWidget. We don't declare a width and height as parameters, because Pyjs Widgets are based on HTML principles: DOM models. So, you either set the CSS "Class" with setStyleName(), or you use the Pyjs Widget functions setWidth() and setHeight(). We do however want to pass in the slider's minimum, maximum and default values, and we may also want to keep track of who might be interested to know that the slider's value has changed. This is an important point to emphasise: your widgets should not impose "look" onto users - that should, ideally be defined through CSS: your Widget Class API should be about "function" rather than "form". So - the constructor for the widget has minimum, maximum and default values not width and height.

class VerticalDemoSlider(FocusWidget):

  def __init__(self, min_value, max_value, start_value=None):

    element = DOM.createDiv()
    FocusWidget.__init__(self, element)

    self.min_value = min_value
    self.max_value = max_value
    if start_value is None:
      start_value = min_value
    self.value = start_value
    self.valuechange_listeners = []

Here also is the first actual bit of underlying HTML / DOM model showing through: we're basing the widget on a "div" tag, hence we call DOM.createDiv() and set that as the FocusWidget's element. (Immediately, therefore, you can see that the Pyjs Widgets are effectively... "guardian" classes that look after and manipulate bits of the underlying DOM model, making the whole process of creating and maintaining your application just that little bit easier to understand). We're also going to copy what AbsolutePanel.__init__() does, making the container DIV free-moving, and we're also going to throw in a second hard-coded "div" for the actual slider handle:

DOM.setStyleAttribute(element, "position", "relative")
DOM.setStyleAttribute(element, "overflow", "hidden")

self.handle = DOM.createDiv()
DOM.appendChild(element, self.handle)

Then, as this is just a demonstration, we're going to hand-code the slider handle with some attributes, making it 10 pixels high, a border of 1 pixel, fixing it to be the same width as the Widget, and making it a grey colour. A much better way to do this would be to set a CSS stylesheet where people could over-ride all these settings. Note that we don't use DOM.setAttribute() to set the border, width and height. You should consult HTML specifications: you will find that "border" is an attribute for DOM tags such as "table". So, if you try to call DOM.setAttribute() on a DIV tag, you'll find that it silently fails in the browser - or if you remember, and examine the Javascript Console, you might be lucky and find a warning. However, if you try the same thing under Pyjs-Desktop you will be rewarded with a much more useful run-time error. The upshot is: pay attention to the underlying DOM model, and remember to simultaneously develop your app using both Pyjs and Pyjs-Desktop, to save yourself a great deal of time. If you want to set a border on a "div" tag, you must set it as a CSS Style attribute:

DOM.setStyleAttribute(self.handle, "border", "1px")
DOM.setStyleAttribute(self.handle, "width", "100%")
DOM.setStyleAttribute(self.handle, "height", "10px")
DOM.setStyleAttribute(self.handle, "backgroundColor", "#808080")

Testing

With the basic beginnings, it's enough to test out, to see if we have it working. If all we wanted was a little grey box in our widget, we'd be entirely done.

""" testing our demo slider
"""
from pyjamas.ui import RootPanel
from pyjamas.Controls import VerticalDemoSlider

class ControlDemo:
  def onModuleLoad(self):
    b = VerticalDemoSlider(0, 100)
    RootPanel().add(b)
    b.setWidth("20px")
    b.setHeight("100px")

One thing I love about Pyjs: this is enough code to do exactly what you want: create our slider, add it to the root panel, set its width to 20 pixels and the height to 100. Couldn't get any easier. A quick run of this code shows that yes, indeed, we have a little grey box, which is very exciting. Next on the list is to make it move, and for that, we'll add a "click listener".

Making it move

To receive a click event, we use FocusWidget.addClickListener(). We're going to make the widget itself receive the mouse click event. Looking at FocusWidget.onBrowserEvent(), we can see that we must add a function called onClick() to our VerticalDemoSlider. As we want to know where the mouse was clicked, we will need to add two arguments to the onClick() function, in order to receive the mouse event object as the second. Then, we simply take the mouse event y position, the absolute location of the container, and the "offset height" of the widget, do a little math and, copying some lines of code from absolutePanel.setWidgetPosition, we can change the location of the slider handle:

def onClick(self, sender, event):

    # work out the relative position of cursor
    mouse_y = DOM.eventGetClientY(event) - \
               DOM.getAbsoluteTop(sender.getElement())
    self.moveSlider(mouse_y)

def moveSlider(self, mouse_y):

    relative_y = mouse_y - DOM.getAbsoluteTop(self.getElement())
    widget_height = self.getOffsetHeight()

    # limit the position to be in the widget!
    if relative_y < 0:
        relative_y = 0
    height_range = widget_height - 10 # handle height is hard-coded
    if relative_y >= height_range:
        relative_y = height_range

    # move the handle
    DOM.setStyleAttribute(self.handle, "top", "%dpx" % relative_y)
    DOM.setStyleAttribute(self.handle, "position", "absolute")

Okay - let's test it! Save, run... lights, camera, action, aaand... nothing. huh. What have we done wrong? Oh yes, we forgot a very important line. Go back to VerticalDemoSlider.__init__ and add this, at the end, and try again:

self.addClickListener(self)

Amazing! We have a slider widget! A single-click moves the slider to where you clicked the mouse. Notice how the slider centre moves to where your mouse pointer actually points to: this is entirely a fluke, and is probably due to bugs in the CSS style implementation of your browser. Notice also that we haven't actually set the value of the "slider", but there's enough maths to calculate it. We can add these extra lines on to the end of moveSlider():

val_diff = self.max_value - self.min_value
new_value = ((val_diff * relative_y) / height_range) + self.min_value
self.setValue(new_value)

Then, we also add a setValue() function, which not only records the new value but also notifies any listeners. Copying the style of Label and other widgets' addClickListener() and removeClickListener() functions, we're doing addControlValueListener() and removeControlValueListener() to match.

def setValue(self, new_value):

    old_value = self.value
    self.value = new_value
    for listener in self.valuechange_listeners:
        listener.onControlValueChanged(self, old_value, new_value)

def addControlValueListener(self, listener):
    self.valuechange_listeners.append(listener)

def removeControlValueListener(self, listener):
    self.valuechange_listeners.remove(listener)

Now we should really see if that works. In the "test code", add these extra lines to ControlDemo.onModuleLoad() and also add the additional function onControlValueChanged:

    b.addControlValueListener(self)
    self.label = Label("Not set yet")
    RootPanel().add(self.label)

def onControlValueChanged(self, slider, old_value, new_value):
    self.label.setText("Value: %d" % int(new_value))

A quick run of this shows our Control Demo app has a very boring Text Label 200 pixels underneath a grey box, with the words "Not set yet". Clicking anywhere between the box and the words not only moves the slider, but also changes the text to say "Value: 83" or something to that effect. Amazing.

Improvements

Congratulations, you have a slider, in 70 lines of python code, and a demonstration of its use in 20. You can click on it. Please feel free to resist the urge to press the up and down arrows and to click and hold the mouse: it won't work. Adding that functionality will be for another day's hacking, which you're welcome to send to me (lkcl@lkcl.net) and I will add it in.

Hints: look at Image.onBrowserEvent() - you will notice that it differs from FocusWidget.onBrowserEvent() in that it also handles mouse move, enter, leave and up. These events all get passed to an instance of MouseListener(), calling its fireMouseEvent() function. Then, look at DialogBox and notice that it does all sorts of strange and wonderful things, including the use of "capture":

def onMouseDown(self, sender, x, y):
    self.dragging = True
    DOM.setCapture(self.caption.getElement())
    self.dragStartX = x
    self.dragStartY = y

This is pretty important - to set "capture" of all mouse events. In the case of the Dialog Box, the events are captured by the "caption" at the top of the box, which is the "drag bar". That sounds... very much exactly like what we want, so feel free to cut-and-paste DialogBox's onMouseDown, onMouseUp, onMouseMove, onMouseLeave and onMouseEnter functions; remember to call VerticalDemoSlider.moveSlider() not setPopupPosition(); and remember to take a copy e.g. of Image.onBrowserEvent(), or take a copy of FocusWidget.onBrowserEvent() and extend it with these all-important lines:

elif type == "mouseup" or type == "mousemove" or type == "mouseover" or type == "mouseout":
    MouseListener().fireMouseEvent(self.mouseListeners, self, event)

That should give you a slider which now accepts mouse drag-move events, making it that little bit much more useful than one which deals in single-clicks. Once you've done that, of course, you might want to remove the onClick() altogether, because of course a single-click is also a mouse-down followed by a mouse-up event, often, in many cases, accompanied by intermediate but very brief mouse-move events. The end-result will be doubling- or possibly tripling- or more of value change notifications, all redundant, and all wasting CPU cycles.

Once you've done that, you might also wish to improve the widget further by detecting whether or not the mouse position was actually changed. Otherwise, you may end up with entirely unnecessary calls to onControlValueChanged(). Don't be tempted to short-cut the process by doing "if new_value != old_value", although this is, strictly speaking, needed as well. Record the old mouse position on mouse-down, and only start sending onControlValueChanged() notifications when the mouse "y" position differs from this recorded value.

Further enhancements and considerations

As part of a c++ widget set I helped develop and maintain in 1992 we had "ganging" sliders, which were for Stereo Volume control. We created a "container" widget which allowed us to receive value changes from an arbitrary number of "Control" widgets placed within it. The "difference" between the old and new values were very important: we utilised these to make changes to the other volume slider, allowing users to set the Left volume slightly higher or lower than the Right volume, and then, on selecting the "Ganging" switch, both volumes would waggle up and down by the same amount, without mashing up to the same absolute value: an undesirable oversight.

We had quite a lot of fun making test applications with half a dozen sliders and dials, waggling one and watching the rest go bananas. Given that this was running on 386sx 25mhz systems, it was also quite impressive. As it was a real-time (single-process!) application, we had to pay particular attention to ensuring that the priority was on getting the volume values to the DSP as fast as possible, leaving visual changes as the absolute last priority-queued event. Yes, we have a message-event system, like in Windows 3.1, except we added "priority" to it, thus avoiding the need for threading and still giving the application both responsiveness as well as aural and visually accurate representation.

Any good "Control" widget system should keep these principles and lessons in mind.

Advanced Widgets

So, you read this tutorial: you went wow, big deal: my imagination is now unlimited by the wondrous possibilities of being able to manipulate the DOM model of a "page" - a screen - in a quick and easy fashion. However, my requirements are beyond what "DOM model" can handle. I want video. I want 2D graphics. I want 3D graphics. What do I do?

There are two answers. The first one is: if your project requires advanced 2D graphics manipulation, there is always the SVG Canvas, if you have a web browser that can support SVG. The Pyjs "Canvas" widget has been ported from the GWT Addons, and it works very well. Pyjs-Desktop does not at the moment support SVG, however it is on the Roadmap.

Your second really "far out" option - given that the underlying technology is web-browser-based - is to use Web Plugins. For Safari, Netscape, Firefox and WebKit (the last in the case of Pyjs-Desktop), you can write a plugin that conforms to the NPAPI standard, and you're away. I recommend that you start with the Adobe Flash plugin source code.

But, before you go writing your own 3D, Video or other Multimedia plugin, first check to see whether there is an existing NPAPI or existing Web-based plugin that you can place into an iframe or otherwise embed into a page. Can you write the plugin in Java, and run a Java applet in your page? Can you use Adobe Flash? All of these possibilites are open to you, with no extra particularly complex programming.