Friday, September 11, 2009

Hacking spark.components.Window

This post is about hacking the spark.components.Window component in the Flex 4 SDK.

Introduction

I wanted to create a simple debug console for an AIR application at work. Nothing fancy, just a standalone window with a non-editable text area for displaying log messages. This debug console should be accessible through a tray/dock menu and via a global keyboard shortcut.

Problem 1: removing the status bar

Supposedly, spark.components.Window is the bee's knees when it comes to standalone native windows in AIR. After struggling for the good part of an hour trying to create a Window programmatically and opening it, I finally realized what I really should be doing is creating an MXML Component. Boom, five minutes later I had my ConsoleWindow up an running, GUI hooked up with event handlers and everything:

Simple enough. However; did you notice the unused — thus annoying — status bar? Why all Windows in AIR have an empty status bar is beyond me, but well, let's hide it. If you're familiar with Flex, you're probably thinking 'just use showStatusBar="false" in the MXML and be done with it'. Can it be that it was all so simple then? No, it cannot. This is what happens:

Extra space not contributing to anything in particular. That's even more annoying than having an unused status bar. So I tried various (combinations of) statements to remove it:

  1. Set this.showStatusBar = false after Window creation, does same as when setting it in MXML
  2. Specifying statusBar="{null}" in MXML, does absolutely nothing
  3. Set this.statusBar = null after Window creation, does nothing...
  4. this.removeChild(statusBar) doesn't compile (expects DisplayObject, gets IVisualElement)
  5. this.removeElement(statusBar) throws an ArgumentError: ConsoleWindow12.WindowedApplicationSkin13.Group14.Group16 is not found in this Group. in spark.components.Group.as:1086
  6. this.removeChild(statusBar as DisplayObject) throws an Error: removeChild() is not available in this class. Instead, use removeElement() or modify the skin, if you have one.
  7. this.statusBar.parent.removeChild(statusBar) doesn't compile, same reason as #4

As you may see, there's a lot of bogus going on because of different types; removeChild() works on DisplayObject classes, removeElement() works on IVisualElement classes, statusBar.parent returns a DisplayObjectContainer which doesn't have removeElement(), etc.

Solution

So what's the solution? In the creationComplete event handler, cast the statusBar.parent to an IVisualElementContainer and call removeElement(statusBar), like this:

private function onCreationComplete():void
{
    // hack to remove status bar
    (statusBar.parent as IVisualElementContainer)
        .removeElement(statusBar);
}

Extremely elegant, I know. It works, though:


Problem 2: closing the console and opening it again later

You'd think that you could open() and close() the window component freely after it was created. As it turns out, the API docs for NativeWindow.close() points out a teeny-weeny gotcha:

“Closed windows cannot be reopened. If the window is already closed, no action is taken and no events are dispatched.”

This doesn't exactly go hand in hand with my intentions of using the window as a debug console, where you'd open the console and check something, close it, wait for something to happen, then reopen the console and check for any messages.

Of course, I do understand why the window is destroyed per default. It saves memory, and one might argue that in most cases you'd actually want it to be destroyed. In my case, though, this is not desirable, so let's hack it.

Solution

There's a two-step solution to making the Window behave as desired.

  1. Add an event handler for the closing event, in which you prevent default action and instead hide the window.
  2. Override the open() method and add some more logic to it.
Like this:
private function onClosing(event:Event):void
{
    event.preventDefault();
    visible = false;
}

override public function open(openWindowActive:Boolean = true):void
{
 if (_opened) {
  visible = true;
  if (openWindowActive)
     activate();
 } else {
  super.open(openWindowActive);
  _opened = true;
 }
}

Conclusion

So there you have it. That's how to tweak the Window class to your liking. There's also the problem of not being able to write messages to the text area until it is created, but that's easily solved by buffering messages. Here's the code for the entire debug window:

<?xml version="1.0" encoding="utf-8"?>
<s:Window
    xmlns:fx="http://ns.adobe.com/mxml/2009"
    xmlns:s="library://ns.adobe.com/flex/spark"
    xmlns:mx="library://ns.adobe.com/flex/halo"
    width="640"
    height="480"
    title="Debug Console"
    showStatusBar="false"
    closing="onClosing(event)"
    creationComplete="onCreationComplete()">
    
    <s:TextArea id="out" left="0" right="0" top="0" bottom="36" editable="false"/>
    <s:Button bottom="8" left="8" label="Clear" click="clear()"/>
    <s:Button bottom="8" right="8" label="Close" click="close()"/>
    <s:layout>
        <s:BasicLayout/>
    </s:layout>
    
    <fx:Script>
        <![CDATA[
            import mx.core.IVisualElementContainer;
            import flash.filesystem.File;
            
            private var _buffer:Array = [];
            private var _opened:Boolean = false;
            
            /**
             * Adds message to console.
             * 
             * @param  message  Message to add to console.
             */
            public function log(message:String):void
            {
                if (_buffer != null) {
                    _buffer.push(message);
                } else {
                   out.appendText(message + File.lineEnding);
                }
            }

            /**
             * Removes all messages.
             */
            public function clear():void
            {
                out.text = "";
            }
            
            /**
             * @inheritDoc
             */ 
            override public function open(openWindowActive:Boolean = true):void
            {
                if (_opened) {
                    visible = true;
                    if (openWindowActive)
                       activate();
                } else {
                    super.open(openWindowActive);
                    _opened = true;
                }
            }
            
            /**
             * @private
             * Hack to prevent window destruction.
             */
            private function onClosing(event:Event):void
            {
                event.preventDefault();
                visible = false;
            }

            /**
             * @private
             * Post-creation setup.
             */
            private function onCreationComplete():void
            {
                // hack to remove status bar
                (statusBar.parent as IVisualElementContainer)
                        .removeElement(statusBar);
                
                // clear buffered log messages
                for (var i:* in _buffer)
                    out.appendText(_buffer[i] + File.lineEnding);
                _buffer = null;
            }

        ]]>
    </fx:Script>
</s:Window>