DirectGUI markup language

This is based on the File parser for DirectGUI thread.

Basically, what I’ve tried to do it create a HTML-esque language for defining objects in DirectGUI (I guess the acronym would be GUIML?) in a hierarchical fashion. Here’s a sample markup.

GUI: myinterface.gui

<DirectGUI>
	<DirectFrame name="Background" width="80%" height="70%" left="10%" top="15%" frameColor="(0,1,1,0.5)">
		<DirectFrame name="Toolbar" width="100%" height="7.5%" top="0" frameColor="(1,0.5,0.5,0.5)">
			<DirectButton name="CloseBtn" right="0" width="40px" height="100%" clickSound="None" command="%self.hide" rolloverSound="None"/>
		</DirectFrame>
		<DirectScrolledList name="EquipmentList" width="33%" height="45%" top="7.5%" frameColor="(1,1,0,0.5)" relief="%DGG.SUNKEN">
			<DirectLabel name="Engine" width="100%" height="15%" top="0" text="Engine:" frameColor="(0,0,0,0.5)" text_fg="(1,1,1,1)" text_scale="0.05"/>
		</DirectScrolledList>
		<DirectScrolledList name="SystemList" width="34%" height="45%" left="33%" top="7.5%" frameColor="(1,0,1,0.5)" relief="%DGG.SUNKEN"/>
		<DirectScrolledList name="WeaponList" width="33%" height="45%" right="0" top="7.5%" frameColor="(0,1,1,0.5)" relief="%DGG.SUNKEN"/>
	</DirectFrame>
</DirectGUI>

The basic idea is that items have parents, and positions and sizes are relative to those parents. So, for example, if you define a frame that takes up have the screen, and then give it a child node of width 50%, the child will take up half of it’s parent frame, or a quarter of a frame.

The syntax is as follows:

  1. name attribute to reference the object in code. Must be supplied or the item is ignored.
  2. left/right attribute to specify margin on the X-axis (use only one). Defaults to left=0.
  3. top/bottom attributes to specify margin on the Y-axis (use only one). Defaults to bottom=0.
  4. height/width attributes to specify the size of the object
  5. position attribute which specifies if the object is positioned and scaled by the parent or by the world space (values are “absolute” and “relative”). Defaults to relative scaling.
  6. Size may be specified in either pixels or percentages, using ‘px’ or ‘%’ to suffix the value respectively. Defaults to percentage.
  7. Attribute values prefixed with ‘%’ will be run through the eval function in python. Use for constants and methods.
  8. Fonts are prefixed with ‘*’.
  9. The node names are directly evaluated as objects, so theoretically other objects such as OnscreenText could be used in the markup.
  10. Any attribute in the API should be usable within the markup, although I may have missed a few specific conversions. For example, I don’t think audio files will import yet.

I think that’s it for the new syntax, so here’s the code. It’s quite a large snippet, but it’s fairly self contained and hasn’t had much cleaning up done yet.

Source: GUIParser.py

from pandac.PandaModules import *
from direct.gui.DirectGui import *
from direct.showbase.DirectObject import DirectObject
from xml.dom.minidom import *

ignore=     ["bottom", "left", "name", "right", "top", "height", "position", "width"]
ints=       "0123456789-+ "
floats=     "0123456789-+. "

def parseList(value):
    value=value.strip('()').split(',')
    return [x for x in value]

def parseTuple(value):
    value=value.strip('()').split(',')
    list=[float(x) for x in value]
    return tuple(list)

class GUIParser(DirectObject):
    def __init__(self, file, visible=True):
        self.mAspectRatio=None
        self.mDict={}
        self.mPixel=None
        self.mVisible=visible
        
        self.accept('window-event', self._windowEvent)
        self.load(file)

    # Must be called after scaling is performed, moves objects into position
    def _applyPosTransform(self, node, parent, params, position="relative"):
        if position == "relative": 
            left=self._getScaledValue(node, "left")[0]
            right=self._getScaledValue(node, "right")[0]
        else:
            left=self._getScaledValue(node, "left", self.mAspectRatio)[0]
            right=self._getScaledValue(node, "right", self.mAspectRatio)[0]            

        bottom=self._getScaledValue(node, "bottom")[0]
        top=self._getScaledValue(node, "top")[0]
        
        height=params[0]
        width=params[1]
        pfs=params[2]
        
        # Parent values
        pheight=(pfs[3]-pfs[2])/2 if parent else 1.0
        pwidth=(pfs[1]-pfs[0])/2 if parent else 1.0
        
        # Determine width positions
        if right != None: 
            offsetR=pfs[1]-right*pwidth
            offsetL=offsetR-width
        else: 
            if left == None: left=0
            offsetL=pfs[0]+left*pwidth
            offsetR=offsetL+width
        
        # Determine height positions
        if top != None: 
            offsetT=pfs[3]-top*pheight
            offsetB=offsetT-height
        else: 
            if bottom == None: bottom=0
            offsetB=pfs[2]+bottom*pheight
            offsetT=offsetB+height
            
        return (offsetL, offsetR, offsetB, offsetT)

    # Scale the object relative to its parent (unless otherwise specified)
    def _applyScaleTransform(self, node, object, parent, position="relative"):
        # Set default layout options     
        heightPair=self._getScaledValue(node, "height")
        height=heightPair[0] if heightPair[0] else 0.2
        unitY=heightPair[1]
        
        if position == "relative": 
            widthPair=self._getScaledValue(node, "width")
        else: 
            widthPair=self._getScaledValue(node, "width", self.mAspectRatio)
        width=widthPair[0] if widthPair[0] else 0.2
        unitX=widthPair[1]
        
        # Assign to parent and scale proportionally
        if position == "relative":
            if parent:
                pfs=parent['frameSize']
                if unitY == "%": height*=(pfs[3]-pfs[2])/2
                if unitX == "%": width*=(pfs[1]-pfs[0])/2
            else: pfs=(-1, 1, -1, 1)
        else: 
            pfs=(-self.mAspectRatio, self.mAspectRatio, -1, 1)
        
        # Return object height, width, and bounding box
        return [height, width, pfs]
    
    # Set some parameters that make GUI creation a little easier
    def _applySimplifications(self, node, object, parent):
        # Assign the object to its logical parent
        if parent: 
            object.reparentTo(parent)
            if parent.__class__.__name__ == "DirectScrolledList":
                parent.addItem(object)
        
        # Default the scroll buttons
        if node.nodeName == "DirectScrolledList":       
            oS=object['frameSize']
            object.incButton['frameColor']=(1,0,0,1)
            object.incButton['frameSize']=(oS[1]-0.1, oS[1], oS[2], oS[2]+0.1)
            object.decButton['frameColor']=(1,0,0,1)
            object.decButton['frameSize']=(oS[1]-0.1, oS[1], oS[3]-0.1, oS[3])

        # Give text a default position
        oS=object['frameSize']
        textScaleX=(oS[1]-oS[0])/2
        textScaleY=(oS[3]-oS[2])/2
        object['text_pos']=(oS[0]+textScaleX, oS[2]+textScaleY, 0)
        

    # This method positions elements according to their hierarchy
    def _applyTransforms(self, node, object, parent):
        params=self._applyScaleTransform(node, object, parent)
        object['frameSize']=self._applyPosTransform(node, parent, params)
        
        try: object.resetFrameSize()
        except: pass

    def cleanup(self):
        for obj in self.mDict: 
            self.mDict[obj].destroy()
        self.mDict={}

    # Take an XML node and turn it into a DirectGUI object
    def _createObject(self, node, parent):
        if not node or node.nodeName == "DirectGUI": return None
        attributes=node.attributes

        try: # Create the object
            object=eval(node.nodeName)()
            self.mDict[node.getAttribute("name").lower()]=object
        except:
            print "Node name or name attribute are incorrect for", node.nodeName 
            return None

        # Loop through attributes and set them
        for index in range(attributes.length):
            att=attributes.item(index)
            key=str(att.name)
            value=str(att.value)
            
            if key not in ignore:
                try: # Parse an attribute
                    if value.lower() == "none": value=None
                    else: value=self._parseValue(value)
    
                    if key == "hpr": object.setHpr(value)
                    elif key == "pos": object.setPos(value)
                    elif key == "scale": object.setScale(value)
                    else: object[key]=value
                except:
                    print "Could not set attribute", key, "on object", node.nodeName
          
        # Scale and position the object with regard to its parent      
        self._applyTransforms(node, object, parent)
        
        # TODO will cause problems because can't be overwritten
        self._applySimplifications(node, object, parent)
    
        return object
        
    def destroy(self):
        self.cleanup()
        del self.mAspectRatio
        del self.mDict
        del self.mFile
        del self.mPixel
        del self.mVisible
       
    def getAttribute(self, name, attribute):
        try: 
            return self.mDict[name.lower()][attribute]
        except:
            print "Could not get attribute", attribute, "from object", name
    
    def getObject(self, name):
        try: 
            return self.mDict[name.lower()]
        except: 
            print "Could not find object:", name
            return None
    
    # Returns a size and its unit for rendering an object
    def _getScaledValue(self, node, att, scale=1.0):
        raw=node.getAttribute(att)
        if raw != "":
            try:
                if raw.endswith("px"): 
                    return [float(raw.strip("px%"))*self.mPixel, "px"]
                else: 
                    return [float(raw.strip("px%"))*0.02*scale, "%"]
            except:
                print "Problem scaling value:", node.nodeName, att
        return [None, "%"]
        
    def getVisible(self):
        return self.mVisible
    
    # Hide the GUI's objects
    def hide(self):
        self.mVisible=False
        for obj in self.mDict: 
            self.mDict[obj].hide()
            
    # Reloads the interface from file
    def load(self, file):
        self.mFile=None
        
        try:
            self.mFile=parse(file)
            self.mFile=self.mFile.firstChild
        except:
            print "GUI file", file, "could not be parsed, ensure all tags are correctly matched"
            return
        
        if self.mAspectRatio: self.redraw()
            
    # Three steps to creating an object:
    # 1). Create the DirectObject
    # 2). Expand the node and add any children to the open list
    # 3). Recursively call the expand method until the open list is empty
    def _parse(self, openList):
        # Take first list element
        pair=openList.pop()
        node=pair[0]
        parent=pair[1]
        if not node: return
        
        # Create the object
        object=self._createObject(node, parent)
        
        # Expand the node and add its children to the open list
        for child in node.childNodes:
            # Dom parses whitespace into nodes; ignore it
            if child.nodeType != Node.TEXT_NODE: 
                openList.append([child, object])
        
        if len(openList) <= 0: return
        return self._parse(openList)
    
    # Parse a value into the correct format
    def _parseValue(self, value):
        if len(value.strip(ints)) == 0:         return int(value)
        elif len(value.strip(floats)) == 0:     return float(value)
        elif value.startswith('['):             return parseList(value)
        elif value.startswith('('):             return parseTuple(value)
        elif value.startswith('%'):             return eval(value.strip('%'))
        elif value.startswith('*'):             return loader.loadFont(value.strip('*'))
        else:                                   return str(value)
        
    # Redraws the interface, called when window size changes
    def redraw(self):
        self.cleanup()
        self._parse([[self.mFile, None]])
        
        if self.mVisible: self.show()
        else: self.hide()
        
    # Show the GUI's objects
    def show(self):
        self.mVisible=True
        for obj in self.mDict: 
            self.mDict[obj].show()
    
    # Toggle the GUI's visibility
    def toggle(self):
        if self.mVisible: 
            self.hide()
        else: 
            self.show()
    
    # Update an object's attribute
    def updateAttribute(self, object, attribute, value):
        try:
            self.mDict[object.lower()][attribute]=value
        except:
            print "Could not update attribute", attribute, "for object", object
    
    # Handle window changes, and responsible for drawing the GUI
    def _windowEvent(self, window=None): 
        if not window: return
        
        wp=window.getProperties()
        self.mAspectRatio=float(wp.getXSize() )/float(wp.getYSize())
        self.mPixel=2.0/float(wp.getYSize())
        
        self.redraw()

And then to use it in Panda3D:

from pandac.PandaModules import loadPrcFileData, KeyboardButton, MouseButton, WindowProperties, TextNode 
from direct.gui.DirectGui import * 
import direct.directbase.DirectStart 
import GUIParser 

parser = GUIParser.GUIParser() 

def reload(): 
   parser.load("myinterface.gui") 

base.accept('r', reload) 
  
run()

This code is not mature, and I haven’t done too much testing yet (I’ve used it for my own purposes and it’s done the job). I’m aware that there are likely a lot of problems with it, and it’s definitely going to be far slower than manually creating objects. The goal of this code at the moment is no speed, just usefulness. I’m hoping to get some feedback on what functionality would be useful to add, or what doesn’t work very well.

Hey,

I think I’m going to use this, but, python doesn’t work well with XML, as it’s too slow and you can load other things faster. Also a question: How do you actually give an object a position?

Maybe you should add an ‘export’ option, so you can use this while developing and designing your GUI, and later on export it to normal code.

Positions are relative to their parent object. Unless you specify otherwise, the top level elements (the children of the DirectGUI element) will be treated as if they’re parented to the (-1, 1, -1, 1) bounding box. The child of that element will then be positioned according to the frameSize of that element.

To position an object, you just specify a margin. The margins are top, bottom, left and right. The left and bottom margins take precedent over top and right. So in the XML markup, you just specify left=“15%” for example, and the left side of the object will be to the right 15% of the available space of the parent element. If you specified right=“15%” instead, the right side of the object would move left 15% of the available space. Hopefully that makes sense.

I am aware of the speed issues with XML, but it’s the most convenient method I could think of for this system. The speed wasn’t of particular concern either, as a GUI file should just be loaded once at start up and then just hidden and shown as needed.

As for an export option, I am planning to do this at some point in the future. For now I’m just trying to get the functionality to the point where it’s actually useful, as while it does make positioning elements a lot easier it doesn’t make GUI design in general much easier yet.

That just answered both my questions.

I’m enjoying this snippet because my first experience with DirectGUI was not a fun one :slight_smile: