Howto talk:Processing legacy PUI dialogs using Canvas
Prioritizing tag/widget support
Note The following Python script is WIP, intended to determine the most important PUI/XML related tags (widgets and layouting, and expects the $FG_ROOT environment variable to be set up correctly |
#!/bin/python
import os
import fnmatch
import string
import getopt, sys
from xml.dom.minidom import parseString
def usage():
print "Usage info ..."
def main():
try:
opts, args = getopt.getopt(sys.argv[1:], "ho:v", ["help", "output="])
except getopt.GetoptError as err:
# print help information and exit:
print(err) # will print something like "option -a not recognized"
usage()
sys.exit(2)
output = None
verbose = False
for o, a in opts:
if o == "-v":
verbose = True
elif o in ("-h", "--help"):
usage()
sys.exit()
elif o in ("-o", "--output"):
output = a
else:
assert False, "unhandled option"
# ...
fgroot = os.environ['FG_ROOT']
dialogs = os.path.join(fgroot, "gui/dialogs")
os.chdir(dialogs)
xml_dialogs = []
for file in os.listdir('.'):
if fnmatch.fnmatch(file, '*.xml'):
xml_dialogs.append(file)
print "xml dialogs found:", len(xml_dialogs)
file = open('autopilot.xml','r')
data = file.read()
file.close()
dom = parseString(data)
layouting_directives = ['group','frame']
# https://sourceforge.net/p/flightgear/flightgear/ci/next/tree/src/GUI/FGPUIDialog.cxx#l853
pui_tags = ['hrule','vrule','list','airport-list','property-list','input','text','checkbox','radio','button','map','canvas','combo','slider','dial','textbox','select','waypointlist','loglist','label']
for tag in pui_tags:
print tag + " occurrences: ", len(dom.getElementsByTagName(tag))
if __name__ == "__main__":
main()
pastebin ...
# beginning of PUI namespace var PUI = { # static unsupported: func(tag, what="Widget") { print("PUI/XML ",what," not yet supported:", tag); }, ## # call with exception handling (stub) # safeCall: func(codeObj, namespace=nil) { var retval = call(codeObj, [], nil, namespace, var err=[]); if (size(err)) { debug.dump(err); } return retval; }, ## # generator for creating callbacks used for all Nasal compilation # TODO: this should take a closure for the <open> and <load> blocks ... makeTagCompiler: func(codeTag, description=nil) { if (description==nil) description = codeTag ~" block"; return func(node, namespace=nil) { var codeSrc = node.getNode(codeTag).getValue(); return func PUI.safeCall(compile(codeSrc), namespace); }; }, # makeTagCompiler setupCommandBinding: func(window, node) { var command = node.getNode("command").getValue(); if (command=="nasal") return PUI.setupScriptBinding(node); # TODO: fgcommands relating to PUI must be dynamically patched/overridden (e.g. dialog-apply/dialog-close) if (contains(PUI.fgcommandPatches, command)) { print("Most PUI related fgcommand patching not yet implemented"); # returns a patched fgcommand (Nasal callback) for the window/node return PUI.fgcommandPatches[command](window, node); } # else print("Returning non-PUI fgcommand"); # standard fgcommand, wrapped in a Nasal func # return func() { fgcommand(command, node); }; }, # setupCommandBinding setupScriptBinding: func(node) { # TODO: needs to be bound to the dialog's open block return PUI.nasalBindingCompiler(node); }, # setupScriptBinding # returns a vector of callback bindings (fgcommands and nasal scripts) setupBindings: func(window, node) { var callbacks=[]; var b=node.getChildren("binding"); foreach(var binding; b) { append(callbacks, PUI.setupCommandBinding(window, binding)); } return callbacks; }, # setupBindings makeBindingsCallback: func(bindingsVec) { return func() { foreach(var b; bindingsVec) { # bindings are invoked via call to ensure that # runtime errors don't block other bindings # i.e. if one binding fails, the other ones will still be executed # PUI.safeCall(b); b(); } # foreach binding } # return anonymous func }, # makeBindingsCallback ### # TODO remove default PUI close button based on heuristic ? ### # TODO: Some PUI fgcommands may need to be patched/replaced dynamically # and mapped to their Canvas equivalents - e.g. to call a window.del() destructor fgcommandPatches: { 'dialog-close': func(window, node) { return func window.del(); }, 'dialog-apply': func(window, node) { print("dialog-apply is not yet implemented ..."); return func; }, 'dialog-update': func(window, node) { print("patched dialog-update is not yet implemented"); return func; }, 'dialog-show': func(window, node) { return func; }, }, # fgcommandPatches ### # TODO: unify node checking (vector], callback to be invoked if found applyPUIAttributes: func(widget, layout, node) { var stretch = node.getNode('stretch'); if (stretch != nil) { print("stretch not yet implemented"); } var halign = node.getNode('halign'); if (halign != nil) { print("halign not yet supported"); } var width = node.getNode('pref-width'); var height = node.getNode('pref-height'); height = height != nil ? height.getValue() : 32; if (width!=nil and height!=nil) widget.setFixedSize(width.getValue(), height); # TODO: visible & enable, live var visible = node.getNode("visible"); if (visible != nil) print("visible tag not yet supported"); var live = node.getNode("live"); if (live != nil) { # TODO: listeners should be stored and freed properly print("live properties not yet supported:", node.getNode('property').getValue()); } var format = node.getNode('format'); if (format !=nil) { var property=node.getNode('property').getValue(); var legend=sprintf(format.getValue(), getprop(property)); widget.setText(legend); } }, # checks if a tag is a known widget (i.e. listed in the WidgetFactory hash) isWidget: func(tag) { return contains(PUI.WidgetFactory,tag); }, getOrCreateTableCell: func(layout, row, col) { }, # pre-allocate a table data structure using ROW/COLS as per README.layout # and Canvas vbox/hbox layouts createTable: func(layout, node) { var nodeHash = node.getValues(); var topLevelLayout = layout; foreach(var child; node.getChildren()) { var tagType = child.getName(); if (PUI.isWidget(tagType)) { print("widget found in table:", tagType); } # known widget } # foreach return []; }, # createTable() ## # deals with different layouts (group/frame) # and creates a table layout using Canvas vbox/hbox layouts LayoutFactory: { 'layout': func(window, root, layout, node) { debug.dump(node); var mode = node.getNode('layout').getValue(); #getValues().layout; var myLayout = PUI.LayoutFactory[mode](window, root, layout, node); #layout.addItem(myLayout); }, 'hbox': func(window, root, layout, node) { var myLayout = canvas.HBoxLayout.new(); PUI.parseWidgets(window, root, myLayout, node); layout.addItem(myLayout); }, # hbox 'vbox': func(window, root, layout, node) { var myLayout = canvas.VBoxLayout.new(); PUI.parseWidgets(window, root, myLayout, node); layout.addItem(myLayout); }, #vbox 'table': func(window, root, layout, node) { var layouts = PUI.createTable(layout, node); }, #table }, # LayoutFactory ## # maps PUI tags ($FG_ROOT/Docs/README.gui) to callbacks/widgets implementing the Canvas equivalent WidgetFactory: { 'frame': func(window, root, layout, node) { # groups and frames are equivalent PUI.WidgetFactory.group(window, root, layout, node); }, # frame 'group': func(window, root, layout, node) { var myLayout = PUI.LayoutFactory.layout(window, root, layout, node); }, # group 'dummy': func(window, root, layout, node) { PUI.WidgetFactory.button(window, root, layout, node); }, # dummy widget, adds a button 'text': func(window, root, layout, node) { var legend = node.getNode('label').getValue(); var label = canvas.gui.widgets.Label.new(root, canvas.style, {wordWrap: 0}); label.setText(legend); PUI.applyPUIAttributes(label, layout, node); layout.addItem(label); }, # text 'button': func(window, root, layout, node) { var legend = node.getNode('legend').getValue(); var button = canvas.gui.widgets.Button.new(root, canvas.style, {}) .setText(legend); layout.addItem(button); PUI.applyPUIAttributes(button, layout, node); # set up bindings: (should be moved to applyPUI*) var bindings = PUI.setupBindings(window, node); button.listen("clicked", PUI.makeBindingsCallback(bindings)); }, # button 'input': func(window, root, layout, node) { var input = canvas.gui.widgets.LineEdit.new(root, canvas.style, {}); PUI.applyPUIAttributes(input, layout, node); layout.addItem(input); }, # input 'checkbox': func(window, root, layout, node) { var hbox=canvas.HBoxLayout.new(); var checkbox = canvas.gui.widgets.CheckBox.new(root, canvas.style, {}); hbox.addItem(checkbox); # add a label PUI.WidgetFactory.text(window, root, hbox, node); layout.addItem(hbox); }, # checkbox 'radio': func(window, root, layout, node) { }, # radio 'combo': func(window, root, layout, node) { PUI.unsupported("combo"); }, # combo # this one is special in that it is actually shared between different list implementations: 'list': func(window, root, layout, node) { var vbox = canvas.VBoxLayout.new(); layout.addItem(vbox); var scroll = canvas.gui.widgets.ScrollArea.new(root, canvas.style, {size: [96, 128]}).move(20, 100); vbox.addItem(scroll, 1); var scrollContent = scroll.getContent() .set("font", "LiberationFonts/LiberationSans-Bold.ttf") .set("character-size", 16) .set("alignment", "left"); var list = canvas.VBoxLayout.new(); scroll.setLayout(list); # returns the created list to the caller, which means we can reuse the code for other types of lists (airports, waypoints, property browser) PUI.applyPUIAttributes(scroll, layout, node); return {layout:list, root:scrollContent}; }, # list 'airport-list': func(window, root, layout, node) { var list = PUI.WidgetFactory.list(window, root, layout, node); }, # airport-list 'property-list': func(window, root, layout, node) { var myList = PUI.WidgetFactory.list(window, root, layout, node); var list = myList.layout; var scrollContent = myList.root; # we will be using this callback to populate/update the property browser var updateList = func(node) { var upButton = canvas.gui.widgets.Button.new(scrollContent, canvas.style, {}) .setText('..' ) .setFixedSize(200, 25); upButton._view._root.addEventListener("dblclick", func() { list.clear(); var parent = node.getParent(); if (parent == nil) parent = node; updateList( parent ); }); # add the navigation button (..) list.addItem( upButton ); # next, add all child nodes # TODO: this should probbably be sorted ... foreach(var n; node.getChildren() ) { # TODO: should probably use label instead here ? var PropertyButton = canvas.gui.widgets.Button.new(scrollContent, canvas.style, {}) .setText(n.getPath() ) .setFixedSize(200, 25); # save the node in a closure for later use (func() { var n = n; var bindings = PUI.setupBindings(window, node); # FIXME: this needs to use the patched fgcommand bindings ... for dialog-apply etc PropertyButton.listen("clicked", PUI.makeBindingsCallback(bindings)); PropertyButton._view._root.addEventListener("click", func() { print("click event:",n.getPath() ); }); # this will clear the list and call the function with the selected new node PropertyButton._view._root.addEventListener("dblclick", func() { print("double click event",n.getPath() ); # clear list list.clear(); updateList( n ); }); }()); # call the anonymous function list.addItem(PropertyButton); } # foreach child } # updateList() # invoke the updateList() function with thhe root node updateList( props.getNode("") ); }, # property-list 'waypointlist': func(window, root, layout, node) { var list = PUI.WidgetFactory.list(window, root, layout, node); }, # waypointlist 'select': func(window, root, layout, node) { }, # select 'slider':func(window, root, layout, node) { }, # slider 'dial': func(window, root, layout, node) { }, # dial 'textbox':func(window, root, layout, node) { # TODO: should be list wrapping a label ... var myList = PUI.WidgetFactory.list(window, root, layout, node); var list = myList.layout; var scrollContent = myList.root; var input = canvas.gui.widgets.LineEdit.new(scrollContent, canvas.style, {}); #PUI.applyPUIAttributes(input, layout, node); list.addItem(input); }, # textbox 'hrule': func(window, root, layout, node) { PUI.unsupported("hrule"); }, #hrule 'vrule': func(window, root, layout, node) { PUI.unsupported("vrule"); }, # vrule 'canvas': func(window, root, layout, node) { }, # canvas 'map': func(window, root, layout, node) { # must be mapped to a Canvas that instantiates a MapStructure map }, # map 'loglist': func(window, root, layout, node) { #die("loglist widget cannot be reimplemented due to missing C++ hooks: logstream"); }, # loglist 'empty': func(window, root, layout, node) { layout.addStretch(1); }, # empty }, # WidgetFactory parseWidgets: func(window, root, layout, node) { foreach(var tag; node.getChildren()) { var t=tag.getName() ; if(contains(PUI.WidgetFactory, t)) { PUI.WidgetFactory[t] (window, root, layout, tag); # TODO: applyPUIAttributes # TODO: addItem/layout here # TODO: process bindings } # TODO: check if Nasal tag, layout tag, styling tag elsif (!contains(PUI.LayoutFactory, t) ) PUI.unsupported(t, "Tag"); } # foreach tag }, ## # this is basically the entrypoint of the whole parser, it's what will be called by the fgcommand showDialog: func(node) { var filename = node.getNode('dialog-name').getValue() or die("Missing dialog file name"); # append .xml suffix: filename ~= ".xml"; # build path relative to $FG_ROOT # TODO: this may need to be fixed for aircraft-level dialogs var path = getprop("/sim/fg-root")~"/gui/dialogs/"~filename; print("Custom PUI/Canvas parser for dialog:", filename); # load the dialog from the base package # FIXME: should be using this to support aircraft dialogs: http://wiki.flightgear.org/Nasal_library#resolvepath.28.29 var dlgNode = io.read_properties(path); # turn the property node into a hash dlgHash = dlgNode.getValues(); # create a Canvas window var window = canvas.Window.new([640,480],"dialog").set('title', dlgHash.name); # adding a canvas to the new window and setting up background colors/transparency var myCanvas = window.createCanvas().set("background", canvas.style.getColor("bg_color")); # window.setCanvas(myCanvas); # creating the top-level/root group which will contain all other elements/group var root = myCanvas.createGroup(); # determine/set top-level layout var layoutMode = dlgHash.layout; var topLevelLayout = (layoutMode=='vbox')?canvas.VBoxLayout.new() : canvas.HBoxLayout.new(); myCanvas.setLayout(topLevelLayout); var namespace = {cmdarg: func dlgNode}; if(dlgNode.getNode("nasal/open")!=nil) { print("Executing nasal open block"); var cb=PUI.nasalOpenCompiler(dlgNode, namespace); # compile cb(); # and call } window.del = func() { var namespace = closure(cb); if(dlgNode.getNode("nasal/close")!=nil) { var close=PUI.nasalCloseCompiler(dlgNode, namespace); # invoke Nasal/close block #close=bind(close, namespace); close(); } # call the original dtor call(canvas.Window.del, [], window, var err=[]); } # http://wiki.flightgear.org/Built-in_Profiler#Nasal fgcommand("profiler-start", props.Node.new({filename:"pui2canvas-"~filename})); PUI.parseWidgets(window:window, root:root, layout:topLevelLayout, node:dlgNode); fgcommand("profiler-stop"); }, # showDialog }; # end of PUI namespace ### # create a handful of tag compiler instances for different purposes # TODO: these should be moved to function scope for the closure handling ... # helper to compile bindings PUI.nasalBindingCompiler = PUI.makeTagCompiler(codeTag: "script"); # helper to compile & call embedded blocks PUI.nasalOpenCompiler = PUI.makeTagCompiler(codeTag:"nasal/open"); PUI.nasalCloseCompiler = PUI.makeTagCompiler(codeTag:"nasal/close"); PUI.canvasLoadCompiler = PUI.makeTagCompiler(codeTag:"canvas/load"); ## # finally, register a new fgcommand for turning PUI dialogs into Canvas widgets # NOTE: Once/if the parser is sufficiently complete and works for most dialogs # this could actually override the built-in dialog-show fgcommand var fgcommand_name = getprop('/sim/gui/override-pui-fgcommands',0) == 1 ? 'dialog-show' : 'canvas-dialog-show'; addcommand(fgcommand_name, PUI.showDialog); print("pui2canvas module loaded, fgcommand registered using name:", fgcommand_name);
Table parser
var filename = "/timeofday.xml";
var path = getprop('/sim/fg-root') ~ filename;
var parseTable = func(path) {
var dialog = io.read_properties(path);
}
var createTable = func() {
}
parseTable(path: path);
Turning the whole thing into a Nasal submodule
Need to change the code to turn it into a submodule that can be easily reloaded on-demand (for faster prototying/testing), including de-registration of the corresponding fgcommands. --Hooray (talk) 11:22, 9 October 2016 (EDT)
To be added
FlightGear is using a built-in GUI engine using legacy OpenGL code (using PLIB's PUI): PUI On the one hand, this is providing the menubar, on the other hand it basically provides all the GUI dialogs and widgets. For the time being, the only effort realistically able to "compete" with PUI is Torsten's mongoose/Phi work, because that's using industry standards like HTML5/jQuery (JavaScript): Phi However, that work happens to be "external" in the sense of having to be rendered by a browser (or GUI widget able to render DHTML). Then, there is the ongoing Qt5 effort - this is the most solid approach at implementing a cross-platform GUI because that is really what Qt is all about. However, it doesn't come with the capability to render the GUI externally (as per Phi) - also, it is complicated to coax Qt5 code into an existing OpenGL application whose context it also didn't create originally, especially an multi-threaded application like FlightGear that happens to use all kinds of threads for different purposes, in conjunction with tons of legacy OpenGL code that isn't even using OSG (OpenSceneGraph) yet. It would be much easier to start a new application from scratch with a Qt5 UI than integrating the whole thing at this point: FlightGear Qt launcher In addition, there are literally tons of legacy resources that must continue to be supported to /some/ degree - i.e. existing dialogs, some of which are dynamically/procedurally created, while others are outside the domain of fgdata/fgaddon (think aircraft specific dialogs). This is where simply "porting" (as in "converting" or translating) existing dialogs is becoming hugely complicated.
The Phi approach is hitting significant challenges when it comes to supporting dialogs that contain tons of embedded FlightGear specific scripts (so called Nasal code). Then again, given the current state of affairs, Phi is the most mature, and most promising, path forward - because it's inherently asynchronous due it its design - as far as I am aware, there isn't a single Phi/mongoose related feature that causes segfaults in FlightGear, whereas the history of the Qt5 integration is hugely different, despite having been tackled by one of the most experienced FlightGear core developers with an enormous track record when it comes to SimGear/FlightGear and Qt5 matters, the Qt5 code has been introducing tons of regressions in the form of segfaults and race conditions. In addition, there is the issue of the Qt5 GUI component having to remain optional according to the original core developer consensus - whereas Phi is non-intrusive, i.e. there is no requirement to keep it optional. All of this is massively complicating matters for anyone interested in porting/updating the legacy GUI in FlightGear, especially in the light of the relatively low importance/impact of the UI on people actually using the flight sim. Besides, PUI inevitably must go sooner or later - simply because it's not playing nice with new/more modern OSG/OpenGL code, but for us to be able to get rid of PUI, we also must make sure not to cause any major regressions - no matter if that means retaining existing functionality, or retaining at least the same degree of accessibility (which is to say that people should continue to be able to modify/update and create GUI dialogs without necessarily having to be experienced C++ developers). As far as I can tell, the work that lies ahead to properly port the existing UI to a new framework has been massively underestimated, and while Phi is very promising, it's also demonstrated that we cannot afford ignoring the DRY-principle, because otherwise we end up with diverging functionality among different UI approaches - e.g. imagine having to use Phi to control the space shuttle, having to use PUI to fly the 777 etc Unfortuntely, there's not been much of a consensus (or holistic approach) between the folks working towards these solutions, despite original comments on designing, and providing a "common UI service layer". What's been happening instead is that Qt5 is promoted, Phi is maintained and PUI is extended, i.e. new hard-coded PUI widgets are still added, which goes to show that neither the Qt5 nor the Phi work is anywhere close to actually "replacing" PUI in the sense of dealing with the in-sim use case. Realistically, this means that there are some lessons to be learnt from this - e.g. by using Phi's approach at implementing am asynchronous UI using RPC/IPC in the form of non-blocking web sockets At some point, this may mean that Phi may actually work for the in-sim use-case, too - at the mere cost of rendering a webkit view to a Qt widget and showing that inside FlightGear. However, that still would not deal with the core developer requirement to retain an in-sim GUI that does not require on optional depencies, unless this requirement is revisited and reconsidered. Also, in the light of the challenges that the Qt5 integration has been facing, what would make more sense is wrapping a QWidget using a Canvas::Element sub-class - at which point, many issues would go away. Finally, there's the original idea to get rid of PUI by implementing a parser in Nasal that dyncamilly interprets arbitrary PUI/XML markup to translate that into the corresponding Canvas equivalent, this idea is based on the realiation that Canvas is all about 2D drawing, and that modern avionics are easily more complex than a standard GUI engine with a handful of widgets (in fact, PUI is rather archaic and simple in comparison to JavaScript/Qt5). This whole idea was originally brought up by core developer James Turner, as part of his idea to help unify the 2D rendering back-end (2D panels, HUDs, GUI etc), so that legacy OpenGL code could be replaced and unified using a single back-end that ends up using modern OpenSceneGraph code. More recently, the Qt5 effort has seen much more activity - however, the original proposal is definitely sound and implementing such a parser is not as much work as manually porting the whole GUI while retaining all existing functionality (checklists, joystick GUI, tutorials etc). Basically, anybody not adopting the parser-based approach to deal with existing resources (including thouse outside fgdata/fgaddon) is basically deluding themselves when it comes to the degree of regressions that would sooner or later be introduced, unless the same person (or team of people) is also willing to re-implement all existing functionality accordingly, which is representing years of work by dozens of contributors.[1]
Procedurally created/updated PUI dialogs
Extending namespaces
var code1 = "var self = 100;";
var code2 = "print(self);";
var codeObj1 = compile(code1);
var codeObj2 = compile(code2);
var locals = {};
var result1 = call(codeObj1,[],nil,locals );
# debug.dump(locals );
call(codeObj2, [], nil, locals);
Removing close button automatically using heuristics
Typically, many PUI/XML diaogs will contain a simple close button to emulate window-like behavior, this won't be needed for the Pui2Canvas parser, so could be dropped automatically
<button>
<legend/>
<key>Esc</key>
<pref-width>16</pref-width>
<pref-height>16</pref-height>
<border>2</border>
<binding>
<command>dialog-close</command>
</binding>
</button>
<button>
<pref-width>16</pref-width>
<pref-height>16</pref-height>
<legend></legend>
<keynum>27</keynum>
<border>2</border>
<binding>
<command>dialog-close</command>
</binding>
</button>
Measuring dialog complexity
- number of layouts used
- number of widgets used
- number of PUI related fgcommands
- number of fgcommands used
- number of Nasal script tags found
- number of conditions/visible tags
- canvas widgets
- ↑ Hooray (Dec 3rd, 2016). Re: Any plans for a new GUI? .