New option to display frame latency.
[fg:toms-fgdata.git] / Nasal / gui.nas
1 ##
2 # Pop up a "tip" dialog for a moment, then remove it.  The delay in
3 # seconds can be specified as the second argument.  The default is 1
4 # second.  The third argument can be a hash with override values.
5 # Note that the tip dialog is a shared resource.  If someone else
6 # comes along and wants to pop a tip up before your delay is finished,
7 # you lose. :)
8 #
9 var popupTip = func(label, delay = nil, override = nil) {
10     var tmpl = props.Node.new({
11             name : "PopTip", modal : 0, layout : "hbox",
12             y: screenHProp.getValue() - 140,
13             text : { label : label, padding : 6 }
14     });
15
16     if (override != nil) tmpl.setValues(override);
17     popdown();
18     fgcommand("dialog-new", tmpl);
19     fgcommand("dialog-show", tipArg);
20
21     currTimer += 1;
22     var thisTimer = currTimer;
23
24     # Final argument is a flag to use "real" time, not simulated time
25     settimer(func { if(currTimer == thisTimer) { popdown() } }, delay or DELAY, 1);
26 }
27
28 var showDialog = func(name) {
29     fgcommand("dialog-show", props.Node.new({ "dialog-name" : name }));
30 }
31
32 ##
33 # Enable/disable named menu entry
34 #
35 var menuEnable = func(searchname, state) {
36     foreach (var menu; props.globals.getNode("/sim/menubar/default").getChildren("menu")) {
37         foreach (var name; menu.getChildren("name")) {
38             if (name.getValue() == searchname) {
39                 menu.getNode("enabled").setBoolValue(state);
40             }
41         }
42         foreach (item; menu.getChildren("item")) {
43             foreach (name; item.getChildren("name")) {
44                 if (name.getValue() == searchname) {
45                     item.getNode("enabled").setBoolValue(state);
46                 }
47             }
48         }
49     }
50 }
51
52 ##
53 # Set the binding for a menu item to a Nasal script,
54 # typically a dialog open() command.
55 #
56 var menuBind = func(searchname, command) {
57     foreach (var menu; props.globals.getNode("/sim/menubar/default").getChildren("menu")) {
58         foreach (item; menu.getChildren("item")) {
59             foreach (name; item.getChildren("name")) {
60                 if (name.getValue() == searchname) {
61                     item.getNode("binding", 1).getNode("command", 1).setValue("nasal");
62                     item.getNode("binding", 1).getNode("script", 1).setValue(command);
63                     fgcommand("gui-redraw");
64                 }
65             }
66         }
67     }
68 }
69
70 ##
71 # Set mouse cursor coordinates and shape (number or name), and return
72 # current shape (number).
73 #
74 # Example:  var cursor = gui.setCursor();
75 #           gui.setCursor(nil, nil, "wait");
76 #
77 var setCursor = func(x = nil, y = nil, cursor = nil) {
78     var args = props.Node.new();
79     if (x != nil) args.getNode("x", 1).setIntValue(x);
80     if (y != nil) args.getNode("y", 1).setIntValue(y);
81     if (cursor != nil) {
82         if (num(cursor) == nil)
83             cursor = cursor_types[cursor];
84         if (cursor == nil)
85             die("cursor must be one of: " ~ string.join(", ", keys(cursor_types)));
86         setprop("/sim/mouse/hide-cursor", cursor);
87         args.getNode("cursor", 1).setIntValue(cursor);
88     }
89     fgcommand("set-cursor", args);
90     return args.getNode("cursor").getValue();
91 }
92 var cursor_types = { none: 0, pointer: 1, wait: 2, crosshair: 3, leftright: 4,
93     topside: 5, bottomside: 6, leftside: 7, rightside: 8,
94     topleft: 9, topright: 10, bottomleft: 11, bottomright: 12,
95 };
96
97
98
99 ########################################################################
100 # Private Stuff:
101 ########################################################################
102
103 ##
104 # Initialize property nodes via a timer, to insure the props module is
105 # loaded.  See notes in view.nas.  Simply cache the screen height
106 # property and the argument for the "dialog-show" command.  This
107 # probably isn't really needed...
108 #
109 var fdm = getprop("/sim/flight-model");
110 var screenHProp = nil;
111 var tipArg = nil;
112 var autopilotDisableProps = [
113   "/autopilot/hide-menu",
114   "/autopilot/KAP140/locks",
115   "/autopilot/CENTURYIIB/locks",
116   "/autopilot/CENTURYIII/locks"
117 ];
118
119 _setlistener("/sim/signals/nasal-dir-initialized", func {
120     screenHProp = props.globals.getNode("/sim/startup/ysize");
121     tipArg = props.Node.new({ "dialog-name" : "PopTip" });
122
123     props.globals.getNode("/sim/help/debug", 1).setValues(debug_keys);
124     props.globals.getNode("/sim/help/basic", 1).setValues(basic_keys);
125     props.globals.getNode("/sim/help/common", 1).setValues(common_aircraft_keys);
126
127     # enable/disable menu entries
128     menuEnable("fuel-and-payload", fdm == "yasim" or fdm == "jsb");
129     var isAutopilotMenuEnabled = func {
130       foreach( var apdp; autopilotDisableProps ) {
131         if( props.globals.getNode( apdp ) != nil )
132           return 0;
133       }
134       return 1;
135     }
136     menuEnable("autopilot", isAutopilotMenuEnabled() );
137     menuEnable("multiplayer", multiplayer.is_active());
138     menuEnable("tutorial-start", size(props.globals.getNode("/sim/tutorials", 1).getChildren("tutorial")));
139     menuEnable("joystick-info", size(props.globals.getNode("/input/joysticks").getChildren("js")));
140
141     # frame-per-second display
142     var fps = props.globals.getNode("/sim/rendering/fps-display", 1);
143     setlistener(fps, fpsDisplay, 1);
144     setlistener("/sim/startup/xsize", func {
145         if (fps.getValue()) {
146             fpsDisplay(0);
147             fpsDisplay(1);
148         }
149     });
150
151     # frame-latency display
152     var latency = props.globals.getNode("/sim/rendering/frame-latency-display", 1);
153     setlistener(latency, latencyDisplay, 1);
154     setlistener("/sim/startup/xsize", func {
155         if (latency.getValue()) {
156             latencyDisplay(0);
157             latencyDisplay(1);
158         }
159     });
160
161     # only enable precipitation if gui *and* aircraft want it
162     var p = "/sim/rendering/precipitation-";
163     var precip_gui = getprop(p ~ "gui-enable");
164     var precip_ac = getprop(p ~ "aircraft-enable");
165     props.globals.getNode(p ~ "enable").setAttribute("userarchive", 0); # TODO remove later
166     var set_precip = func setprop(p ~ "enable", precip_gui and precip_ac);
167     setlistener(p ~ "gui-enable", func(n) set_precip(precip_gui = n.getValue()),1);
168     setlistener(p ~ "aircraft-enable", func(n) set_precip(precip_ac = n.getValue()),1);
169
170     # the autovisibility feature of the menubar
171     # automatically show the menubar if the mouse is at the upper edge of the window
172     # the menubar is hidden by a binding to a LMB click in mode 0 in mice.xml
173     var menubarAutoVisibilityListener = nil;
174     var menubarAutoVisibilityEdge = props.globals.initNode( "/sim/menubar/autovisibility/edge-size", 5, "INT" );
175     var menubarVisibility = props.globals.initNode( "/sim/menubar/visibility", 0, "BOOL" );
176     var currentMenubarVisibility = menubarVisibility.getValue();
177     var mouseMode = props.globals.initNode( "/devices/status/mice/mouse/mode", 0, "INT" );
178
179     setlistener( "/sim/menubar/autovisibility/enabled", func(n) {
180       if( n.getValue() and menubarAutoVisibilityListener == nil ) {
181         currentMenubarVisibility = menubarVisibility.getValue();
182         menubarVisibility.setBoolValue( 0 );
183         menubarAutoVisibilityListener = setlistener( "/devices/status/mice/mouse/y", func(n) {
184           if( n.getValue() == nil ) return;
185           if( mouseMode.getValue() != 0 ) return;
186
187           if(  n.getValue() <= menubarAutoVisibilityEdge.getValue() )
188             menubarVisibility.setBoolValue( 1 );
189
190         }, 1, 0 );
191       }
192
193       # don't listen to the mouse position if this feature is enabled
194       if( n.getValue() == 0 and menubarAutoVisibilityListener != nil ) {
195         removelistener( menubarAutoVisibilityListener );
196         menubarAutoVisibilityListener = nil;
197         menubarVisibility.setBoolValue(currentMenubarVisibility);
198       }
199   }, 1, 0);
200
201 });
202
203
204 ##
205 # Show/hide the fps display dialog.
206 #
207 var fpsDisplay = func(n) {
208     var w = isa(n, props.Node) ? n.getValue() : n;
209     fgcommand(w ? "dialog-show" : "dialog-close", props.Node.new({"dialog-name": "fps"}));
210 }
211 var latencyDisplay = func(n) {
212     var w = isa(n, props.Node) ? n.getValue() : n;
213     fgcommand(w ? "dialog-show" : "dialog-close", props.Node.new({"dialog-name": "frame-latency"}));
214 }
215
216 ##
217 # How many seconds do we show the tip?
218 #
219 var DELAY = 1.0;
220
221 ##
222 # Pop down the tip dialog, if it is visible.
223 #
224 var popdown = func { fgcommand("dialog-close", tipArg); }
225
226 # Marker for the "current" timer.  This value gets stored in the
227 # closure of the timer function, and is used to check that there
228 # hasn't been a more recent timer set that should override.
229 var currTimer = 0;
230
231 ########################################################################
232 # Widgets & Layout Management
233 ########################################################################
234
235 ##
236 # A "widget" class that wraps a property node.  It provides useful
237 # helper methods that are difficult or tedious with the raw property
238 # API.  Note especially the slightly tricky addChild() method.
239 #
240 var Widget = {
241     set : func { me.node.getNode(arg[0], 1).setValue(arg[1]); },
242     prop : func { return me.node; },
243     new : func { return { parents : [Widget], node : props.Node.new() } },
244     addChild : func {
245         type = arg[0];
246         idx = size(me.node.getChildren(type));
247         name = type ~ "[" ~ idx ~ "]";
248         newnode = me.node.getNode(name, 1);
249         return { parents : [Widget], node : newnode };
250     },
251     setColor : func(r, g, b, a = 1) {
252         me.node.setValues({ color : { red:r, green:g, blue:b, alpha:a } });
253     },
254     setFont : func(n, s = 13, t = 0) {
255         me.node.setValues({ font : { name:n, "size":s, slant:t } });
256     },
257     setBinding : func(cmd, carg = nil) {
258         var idx = size(me.node.getChildren("binding"));
259         var node = me.node.getChild("binding", idx, 1);
260         node.getNode("command", 1).setValue(cmd);
261         if (cmd == "nasal") {
262             node.getNode("script", 1).setValue(carg);
263         } elsif (carg != nil and (cmd == "dialog-apply" or cmd == "dialog-update")) {
264             node.getNode("object-name", 1).setValue(carg);
265         }
266     },
267 };
268
269
270 ##
271 # Dialog class. Maintains one XML dialog.
272 #
273 # SYNOPSIS:
274 # (B) Dialog.new(<dialog-name>);   ... use dialog from $FG_ROOT/gui/dialogs/
275 #
276 # (A) Dialog.new(<prop>, <path> [, <dialog-name>]);
277 #                                  ... load aircraft specific dialog from
278 #                                      <path> under property <prop> and under
279 #                                      name <dialog-name>; if no name is given,
280 #                                      then it's taken from the XML dialog
281 #
282 #         prop        ... target node (name must be "dialog")
283 #         path        ... file path relative to $FG_ROOT
284 #         dialog-name ... dialog <name> of dialog in $FG_ROOT/gui/dialogs/
285 #
286 # EXAMPLES:
287 #
288 #     var dlg = gui.Dialog.new("/sim/gui/dialogs/foo-config/dialog",
289 #                              "Aircraft/foo/foo_config.xml");
290 #     dlg.open();
291 #     dlg.close();
292 #
293 #     var livery_dialog = gui.Dialog.new("livery-select");
294 #     livery_dialog.toggle();
295 #
296 var Dialog = {
297     new: func(prop, path = nil, name = nil) {
298         var m = { parents: [Dialog] };
299         m.state = 0;
300         if (path == nil) { # global dialog in $FG_ROOT/gui/dialogs/
301             m.name = prop;
302             m.prop = props.Node.new({ "dialog-name" : prop });
303         } else {           # aircraft dialog with given path
304             m.name = name;
305             m.path = path;
306             m.prop = isa(prop, props.Node) ? prop : props.globals.getNode(prop, 1);
307             if (m.prop.getName() != "dialog")
308                 die("Dialog class: node name must end with '/dialog'");
309
310             m.listener = setlistener("/sim/signals/reinit-gui", func m.load(), 1);
311         }
312         return Dialog.instance[m.name] = m;
313     },
314     # doesn't need to be called explicitly, but can be used to force a reload
315     load: func {
316         var state = me.state;
317         if (state)
318             me.close();
319
320         me.prop.removeChildren();
321         io.read_properties(me.path, me.prop);
322
323         var n = me.prop.getNode("name");
324         if (n == nil)
325             die("Dialog class: XML dialog must have <name>");
326
327         if (me.name == nil)
328             me.name = n.getValue();
329         else
330             n.setValue(me.name);
331
332         me.prop.getNode("dialog-name", 1).setValue(me.name);
333         fgcommand("dialog-new", me.prop);
334         if (state)
335             me.open();
336     },
337     # allows access to dialog-embedded Nasal variables/functions
338     namespace: func {
339         var ns = "__dlg:" ~ me.name;
340         me.state and contains(globals, ns) ? globals[ns] : nil;
341     },
342     open: func {
343         fgcommand("dialog-show", me.prop);
344         me.state = 1;
345     },
346     close: func {
347         fgcommand("dialog-close", me.prop);
348         me.state = 0;
349     },
350     toggle: func {
351         me.state ? me.close() : me.open();
352     },
353     is_open: func {
354         me.state;
355     },
356     instance: {},
357 };
358
359
360 ##
361 # Overlay selector. Displays a list of overlay XML files and copies the
362 # chosen one to the property tree. The class allows to select liveries,
363 # insignia, decals, variants, etc. Usually the overlay properties are
364 # fed to "select" and "material" animations.
365 #
366 # SYNOPSIS:
367 #       OverlaySelector.new(<title>, <dir>, <nameprop> [, <sortprop> [, <mpprop> [, <callback>]]]);
368 #
369 #       title    ... dialog title
370 #       dir      ... directory where to find the XML overlay files,
371 #                    relative to FG_ROOT
372 #       nameprop ... property in an overlay file that contains the name
373 #                    The result is written to this place in the
374 #                    property tree.
375 #       sortprop ... property in an overlay file that should be used
376 #                    as sorting criterion, if alphabetic sorting by
377 #                    name is undesirable. Use nil if you don't need
378 #                    this, but want to set a callback function.
379 #       mpprop   ... property path of MP node where the file name should
380 #                    be written to
381 #       callback ... function that's called after a new entry was chosen,
382 #                    with these arguments:
383 #
384 #                    callback(<number>, <name>, <sort-criterion>, <file>,  <path>)
385 #
386 # EXAMPLE:
387 #       aircraft.data.add("sim/model/pilot");  # autosave the pilot
388 #       var pilots_dialog = gui.OverlaySelector.new("Pilots",
389 #               "Aircraft/foo/Models/Pilots",
390 #               "sim/model/pilot");
391 #
392 #       pilots_dialog.open();  # or ... close(), or toggle()
393 #
394 #
395 var OverlaySelector = {
396     new: func(title, dir, nameprop, sortprop = nil, mpprop = nil, callback = nil) {
397         var name = "overlay-select-";
398         var data = props.globals.getNode("/sim/gui/dialogs/", 1);
399         for (var i = 1; 1; i += 1)
400             if (data.getNode(name ~ i, 0) == nil)
401                 break;
402         data = data.getNode(name ~= i, 1);
403
404         var m = Dialog.new(data.getNode("dialog", 1), "gui/dialogs/overlay-select.xml", name);
405         m.parents = [OverlaySelector, Dialog];
406
407         # resolve the path in FG_ROOT, and --fg-aircraft dir, etc
408         m.dir = resolvepath(dir) ~ "/";
409
410         var relpath = func(p) substr(p, p[0] == `/`);
411         m.nameprop = relpath(nameprop);
412         m.sortprop = relpath(sortprop or nameprop);
413         m.mpprop = mpprop;
414         m.callback = callback;
415         m.result = data.initNode("result", "");
416         m.listener = setlistener(m.result, func(n) m.select(n.getValue()));
417
418         m.prop.getNode("group/text/label").setValue(title);
419         m.prop.getNode("group/button/binding/script").setValue('gui.Dialog.instance["' ~ name ~ '"].close()');
420         m.list = m.prop.getNode("list");
421         m.list.getNode("property").setValue(m.result.getPath());
422
423         if (m.mpprop != nil)
424             aircraft.data.add(m.nameprop);
425
426         m.rescan();
427         m.current = -1;
428         m.select(getprop(m.nameprop) or "");
429         return m;
430     },
431     del: func {
432         removelistener(me.listener);
433     },
434     rescan: func {
435         me.data = [];
436         var files = directory(me.dir);
437         if (size(files)) {
438             foreach (var file; files) {
439                 if (substr(file, -4) != ".xml")
440                     continue;
441                 var n = io.read_properties(me.dir ~ file);
442                 var name = n.getNode(me.nameprop, 1).getValue();
443                 var index = n.getNode(me.sortprop, 1).getValue();
444                 if (name == nil or index == nil)
445                     continue;
446                 append(me.data, [name, index, substr(file, 0, size(file) - 4), me.dir ~ file]);
447             }
448             me.data = sort(me.data, func(a, b) num(a[1]) == nil or num(b[1]) == nil
449                     ? cmp(a[1], b[1]) : a[1] - b[1]);
450         }
451
452         me.list.removeChildren("value");
453         forindex (var i; me.data)
454             me.list.getChild("value", i, 1).setValue(me.data[i][0]);
455     },
456     set: func(index) {
457         var last = me.current;
458         me.current = math.mod(index, size(me.data));
459         io.read_properties(me.data[me.current][3], props.globals);
460         if (last != me.current and me.callback != nil)
461             call(me.callback, [me.current] ~ me.data[me.current], me);
462         if (me.mpprop != nil)
463             setprop(me.mpprop, me.data[me.current][2]);
464     },
465     select: func(name) {
466         forindex (var i; me.data)
467             if (me.data[i][0] == name)
468                 me.set(i);
469     },
470     next: func {
471         me.set(me.current + 1);
472     },
473     previous: func {
474         me.set(me.current - 1);
475     },
476 };
477
478
479 ##
480 # FileSelector class (derived from Dialog class).
481 #
482 # SYNOPSIS: FileSelector.new(<callback>, <title>, <button> [, <pattern> [, <dir> [, <file> [, <dotfiles>]]]])
483 #
484 #         callback ... callback function that gets return value as first argument
485 #         title    ... dialog title
486 #         button   ... button text (should say "Save", "Load", etc. and not just "OK")
487 #         pattern  ... array with shell pattern or nil (which is equivalent to "*")
488 #         dir      ... starting dir ($FG_ROOT if unset)
489 #         file     ... pre-selected default file name
490 #         dotfiles ... flag that decides whether UNIX dotfiles should be shown (1) or not (0)
491 #
492 # EXAMPLE:
493 #
494 #     var report = func(n) { print("file ", n.getValue(), " selected") }
495 #     var selector = gui.FileSelector.new(
496 #             report,                 # callback function
497 #             "Save Flight",          # dialog title
498 #             "Save",                 # button text
499 #             ["*.sav", "*.xml"],     # pattern for displayed files
500 #             "/tmp",                 # start dir
501 #             "flight.sav");          # default file name
502 #     selector.open();
503 #
504 #     selector.close();
505 #     selector.set_title("Save Another Flight");
506 #     selector.open();
507 #
508 var FileSelector = {
509     new: func(callback, title, button, pattern = nil, dir = "", file = "", dotfiles = 0) {
510         var name = "file-select-";
511         var data = props.globals.getNode("/sim/gui/dialogs/", 1);
512         for (var i = 1; 1; i += 1)
513             if (data.getNode(name ~ i, 0) == nil)
514                 break;
515         data = data.getNode(name ~= i, 1);
516
517         var m = Dialog.new(data.getNode("dialog", 1), "gui/dialogs/file-select.xml", name);
518         m.parents = [FileSelector, Dialog];
519         m.data = data;
520         m.set_title(title);
521         m.set_button(button);
522         m.set_directory(dir);
523         m.set_file(file);
524         m.set_dotfiles(dotfiles);
525         m.set_pattern(pattern);
526         m.cblistener = setlistener(data.getNode("path", 1), callback);
527         return m;
528     },
529     # setters only take effect after the next call to open()
530     set_title: func(title) { me.data.getNode("title", 1).setValue(title) },
531     set_button: func(button) { me.data.getNode("button", 1).setValue(button) },
532     set_directory: func(dir) { me.data.getNode("directory", 1).setValue(dir) },
533     set_file: func(file) { me.data.getNode("selection", 1).setValue(file) },
534     set_dotfiles: func(dot) { me.data.getNode("dotfiles", 1).setBoolValue(dot) },
535     set_pattern: func(pattern) {
536         me.data.removeChildren("pattern");
537         if (pattern != nil)
538             forindex (var i; pattern)
539                 me.data.getChild("pattern", i, 1).setValue(pattern[i]);
540     },
541     del: func {
542         me.close();
543         delete(me.instance, me.name);
544         removelistener(me.cblistener);
545         me.data.remove();
546     },
547 };
548
549
550 ##
551 # Save/load flight menu functions.
552 #
553 var save_flight_sel = nil;
554 var save_flight = func {
555     foreach (var n; props.globals.getNode("/sim/presets").getChildren())
556         n.setAttribute("archive", 1);
557     var save = func(n) fgcommand("save", props.Node.new({ file: n.getValue() }));
558     if (save_flight_sel == nil)
559         save_flight_sel = FileSelector.new(save, "Save Flight", "Save",
560                 ["*.sav"], getprop("/sim/fg-home"), "flight.sav");
561     save_flight_sel.open();
562 }
563
564
565 var load_flight_sel = nil;
566 var load_flight = func {
567     var load = func(n) {
568         fgcommand("load", props.Node.new({ file: n.getValue() }));
569         fgcommand("presets-commit");
570     }
571     if (load_flight_sel == nil)
572         load_flight_sel = FileSelector.new(load, "Load Flight", "Load",
573                 ["*.sav"], getprop("/sim/fg-home"), "flight.sav");
574     load_flight_sel.open();
575 }
576
577
578 ##
579 # Open property browser with given target path.
580 #
581 var property_browser = func(dir = nil) {
582     if (dir == nil)
583         dir = "/";
584     elsif (isa(dir, props.Node))
585         dir = dir.getPath();
586     var dlgname = "property-browser";
587     foreach (var module; keys(globals))
588         if (find("__dlg:" ~ dlgname, module) == 0)
589             return globals[module].clone(dir);
590
591     setprop("/sim/gui/dialogs/" ~ dlgname ~ "/last", dir);
592     fgcommand("dialog-show", props.Node.new({"dialog-name": dlgname}));
593 }
594
595
596 ##
597 # Open one property browser per /browser[] property, where each contains
598 # the target path. On the command line use  --prop:browser=orientation
599 #
600 settimer(func {
601     foreach (var b; props.globals.getChildren("browser"))
602         if ((var browser = b.getValue()) != nil)
603             foreach (var path; split(",", browser))
604                 if (size(path))
605                     property_browser(string.trim(path));
606
607     props.globals.removeChildren("browser");
608 }, 0);
609
610
611 ##
612 # Apply whole dialog or list of widgets. This copies the widgets'
613 # visible contents to the respective <property>.
614 #
615 var dialog_apply = func(dialog, objects...) {
616     var n = props.Node.new({ "dialog-name": dialog });
617     if (!size(objects))
618         return fgcommand("dialog-apply", n);
619
620     var name = n.getNode("object-name", 1);
621     foreach (var o; objects) {
622         name.setValue(o);
623         fgcommand("dialog-apply", n);
624     }
625 }
626
627
628 ##
629 # Update whole dialog or list of widgets. This makes the widgets
630 # adopt and display the value of their <property>.
631 #
632 var dialog_update = func(dialog, objects...) {
633     var n = props.Node.new({ "dialog-name": dialog });
634     if (!size(objects))
635         return fgcommand("dialog-update", n);
636
637     var name = n.getNode("object-name", 1);
638     foreach (var o; objects) {
639         name.setValue(o);
640         fgcommand("dialog-update", n);
641     }
642 }
643
644
645 ##
646 # Searches a dialog tree for widgets with a particular <name> entry and
647 # sets their <enabled> flag.
648 #
649 var enable_widgets = func(node, name, enable = 1) {
650     foreach (var n; node.getChildren())
651         enable_widgets(n, name, enable);
652     if ((var n = node.getNode("name")) != nil and n.getValue() == name)
653         node.getNode("enabled", 1).setBoolValue(enable);
654 }
655
656
657
658 ########################################################################
659 # GUI theming
660 ########################################################################
661
662 var nextStyle = func {
663     var curr = getprop("/sim/gui/current-style");
664     var styles = props.globals.getNode("/sim/gui").getChildren("style");
665     forindex (var i; styles)
666         if (styles[i].getIndex() == curr)
667             break;
668     if ((i += 1) >= size(styles))
669         i = 0;
670     setprop("/sim/gui/current-style", styles[i].getIndex());
671     fgcommand("gui-redraw");
672 }
673
674
675 ########################################################################
676 # Dialog Boxes
677 ########################################################################
678
679 var dialog = {};
680
681 var setWeight = func(wgt, opt) {
682     var lbs = opt.getNode("lbs", 1).getValue();
683     wgt.getNode("weight-lb", 1).setValue(lbs);
684
685     # Weights can have "tank" indices which set the capacity of the
686     # corresponding tank.  This code should probably be moved to
687     # something like fuel.setTankCap(tank, gals)...
688     if(wgt.getNode("tank") == nil) { return 0; }
689     var ti = wgt.getNode("tank").getValue();
690     var gn = opt.getNode("gals");
691     var gals = gn == nil ? 0 : gn.getValue();
692     var tn = props.globals.getNode("consumables/fuel/tank["~ti~"]", 1);
693     var ppg = tn.getNode("density-ppg", 1).getValue();
694     var lbs = gals * ppg;
695     var curr = tn.getNode("level-gal_us", 1).getValue();
696     curr = curr > gals ? gals : curr;
697     tn.getNode("capacity-gal_us", 1).setValue(gals);
698     tn.getNode("level-gal_us", 1).setValue(curr);
699     tn.getNode("level-lbs", 1).setValue(curr * ppg);
700     return 1;
701 }
702
703 # Checks the /sim/weight[n]/{selected|opt} values and sets the
704 # appropriate weights therefrom.
705 var setWeightOpts = func {
706     var tankchange = 0;
707     foreach(w; props.globals.getNode("sim").getChildren("weight")) {
708         var selected = w.getNode("selected");
709         if(selected != nil) {
710             foreach(opt; w.getChildren("opt")) {
711                 if(opt.getNode("name", 1).getValue() == selected.getValue()) {
712                     if(setWeight(w, opt)) { tankchange = 1; }
713                     break;
714                 }
715             }
716         }
717     }
718     return tankchange;
719 }
720 # Run it at startup and on reset to make sure the tank settings are correct
721 _setlistener("/sim/signals/fdm-initialized", func { settimer(setWeightOpts, 0) });
722 _setlistener("/sim/signals/reinit", func(n) { props._getValue(n, []) or setWeightOpts() });
723
724
725 # Called from the F&W dialog when the user selects a weight option
726 var weightChangeHandler = func {
727     var tankchanged = setWeightOpts();
728
729     # This is unfortunate.  Changing tanks means that the list of
730     # tanks selected and their slider bounds must change, but our GUI
731     # isn't dynamic in that way.  The only way to get the changes on
732     # screen is to pop it down and recreate it.
733     if(tankchanged) {
734         var p = props.Node.new({"dialog-name": "WeightAndFuel"});
735         fgcommand("dialog-close", p);
736         showWeightDialog();
737     }
738 }
739
740
741
742 ##
743 # Dynamically generates a weight & fuel configuration dialog specific to
744 # the aircraft.
745 #
746 var showWeightDialog = func {
747     var name = "WeightAndFuel";
748     var title = "Weight and Fuel Settings";
749
750     #
751     # General Dialog Structure
752     #
753     dialog[name] = Widget.new();
754     dialog[name].set("name", name);
755     dialog[name].set("layout", "vbox");
756
757     var header = dialog[name].addChild("group");
758     header.set("layout", "hbox");
759     header.addChild("empty").set("stretch", "1");
760     header.addChild("text").set("label", title);
761     header.addChild("empty").set("stretch", "1");
762     var w = header.addChild("button");
763     w.set("pref-width", 16);
764     w.set("pref-height", 16);
765     w.set("legend", "");
766     w.set("default", 0);
767     w.setBinding("dialog-close");
768
769     dialog[name].addChild("hrule");
770
771     if (fdm != "yasim" and fdm != "jsb") {
772         var msg = dialog[name].addChild("text");
773         msg.set("label", "Not supported for this aircraft");
774         var cancel = dialog[name].addChild("button");
775         cancel.set("key", "Esc");
776         cancel.set("legend", "Cancel");
777         cancel.setBinding("dialog-close");
778         fgcommand("dialog-new", dialog[name].prop());
779         showDialog(name);
780         return;
781     }
782
783     # FDM dependent settings
784     if(fdm == "yasim") {
785         var fdmdata = {
786             grosswgt : "/yasim/gross-weight-lbs",
787             payload  : "/sim",
788             cg       : nil,
789         };
790     } elsif(fdm == "jsb") {
791         var fdmdata = {
792             grosswgt : "/fdm/jsbsim/inertia/weight-lbs",
793             payload  : "/payload",
794             cg       : "/fdm/jsbsim/inertia/cg-x-in",
795         };
796     }
797
798     var contentArea = dialog[name].addChild("group");
799     contentArea.set("layout", "hbox");
800
801     var grossWgt = props.globals.getNode(fdmdata.grosswgt);
802     if(grossWgt != nil) {
803         var gwg = dialog[name].addChild("group");
804         gwg.set("layout", "hbox");
805         gwg.addChild("empty").set("stretch", 1);
806         gwg.addChild("text").set("label", "Gross Weight:");
807         var txt = gwg.addChild("text");
808         txt.set("label", "0123456789");
809         txt.set("format", "%.0f lb");
810         txt.set("property", fdmdata.grosswgt);
811         txt.set("live", 1);
812         gwg.addChild("empty").set("stretch", 1);
813     }
814
815     var massLimits = props.globals.getNode("/limits/mass-and-balance");
816     if(massLimits != nil ) {
817
818         var weightitem = func( group, name, node, format ) {
819           group.set("layout", "hbox");
820           var n = isa( node, props.Node ) ? node : massLimits.getNode( node );
821           if( n == nil ) return;
822           group.addChild("empty").set("stretch", 1);
823           group.addChild("text").set("label", name ~ ":" );
824           var txt = group.addChild("text");
825           txt.set("label", "");
826           txt.set("format", format );
827           txt.set("property", n.getPath() );
828           txt.set("live", 1);
829           group.addChild("empty").set("stretch", 1);
830         }
831         weightitem( dialog[name].addChild("group"), "Max. Ramp Weight", "maximum-ramp-mass-lbs", "%.0f lb" );
832         weightitem( dialog[name].addChild("group"), "Max. Takeoff  Weight", "maximum-takeoff-mass-lbs", "%.0f lb" );
833         weightitem( dialog[name].addChild("group"), "Max. Landing  Weight", "maximum-landing-mass-lbs", "%.0f lb" );
834         weightitem( dialog[name].addChild("group"), "Max. Zero Fuel Weight", "maximum-zero-fuel-mass-lbs", "%.0f lb" );
835
836         if( fdmdata.cg != nil ) {
837           var n = massLimits.getNode("cg/dimension");
838           weightitem( dialog[name].addChild("group"), "CG", fdmdata.cg, "%.1f " ~ (n == nil ? "in" : n.getValue()));
839         }
840         weightitem = nil;
841     }
842
843     dialog[name].addChild("hrule");
844
845     var buttonBar = dialog[name].addChild("group");
846     buttonBar.set("layout", "hbox");
847     buttonBar.set("default-padding", 10);
848
849     var close = buttonBar.addChild("button");
850     close.set("legend", "Close");
851     close.set("default", "true");
852     close.set("key", "Enter");
853     close.setBinding("dialog-close");
854
855     # Temporary helper function
856     var tcell = func(parent, type, row, col) {
857         var cell = parent.addChild(type);
858         cell.set("row", row);
859         cell.set("col", col);
860         return cell;
861     }
862
863     #
864     # Fill in the content area
865     #
866     var fuelArea = contentArea.addChild("group");
867     fuelArea.set("layout", "vbox");
868     fuelArea.addChild("text").set("label", "Fuel Tanks");
869
870     var fuelTable = fuelArea.addChild("group");
871     fuelTable.set("layout", "table");
872
873     fuelArea.addChild("empty").set("stretch", 1);
874
875     tcell(fuelTable, "text", 0, 0).set("label", "Tank");
876     tcell(fuelTable, "text", 0, 3).set("label", "Pounds");
877     tcell(fuelTable, "text", 0, 4).set("label", "Gallons");
878
879     var tanks = props.globals.getNode("/consumables/fuel").getChildren("tank");
880     for(i=0; i<size(tanks); i+=1) {
881         var t = tanks[i];
882
883         var tname = i ~ "";
884         var tnode = t.getNode("name");
885         if(tnode != nil) { tname = tnode.getValue(); }
886
887         var tankprop = "/consumables/fuel/tank["~i~"]";
888
889         var cap = t.getNode("capacity-gal_us", 1).getValue();
890
891         # Hack, to ignore the "ghost" tanks created by the C++ code.
892         if(cap == nil or cap < 1) { continue; }
893
894         var title = tcell(fuelTable, "text", i+1, 0);
895         title.set("label", tname);
896         title.set("halign", "right");
897
898         var selected = props.globals.initNode(tankprop ~ "/selected", 1, "BOOL");
899         if (selected.getAttribute("writable")) {
900             var sel = tcell(fuelTable, "checkbox", i+1, 1);
901             sel.set("property", tankprop ~ "/selected");
902             sel.set("live", 1);
903             sel.setBinding("dialog-apply");
904         }
905
906         var slider = tcell(fuelTable, "slider", i+1, 2);
907         slider.set("property", tankprop ~ "/level-gal_us");
908         slider.set("live", 1);
909         slider.set("min", 0);
910         slider.set("max", cap);
911         slider.setBinding("dialog-apply");
912
913         var lbs = tcell(fuelTable, "text", i+1, 3);
914         lbs.set("property", tankprop ~ "/level-lbs");
915         lbs.set("label", "0123456");
916         lbs.set("format", cap < 1 ? "%.3f" : cap < 10 ? "%.2f" : "%.1f" );
917         lbs.set("live", 1);
918
919         var gals = tcell(fuelTable, "text", i+1, 4);
920         gals.set("property", tankprop ~ "/level-gal_us");
921         gals.set("label", "0123456");
922         gals.set("format", cap < 1 ? "%.3f" : cap < 10 ? "%.2f" : "%.1f" );
923         gals.set("live", 1);
924     }
925
926     var weightArea = contentArea.addChild("group");
927     weightArea.set("layout", "vbox");
928     weightArea.addChild("text").set("label", "Payload");
929
930     var weightTable = weightArea.addChild("group");
931     weightTable.set("layout", "table");
932
933     weightArea.addChild("empty").set("stretch", 1);
934
935     tcell(weightTable, "text", 0, 0).set("label", "Location");
936     tcell(weightTable, "text", 0, 2).set("label", "Pounds");
937
938     var payload_base = props.globals.getNode(fdmdata.payload);
939     if (payload_base != nil)
940         var wgts = payload_base.getChildren("weight");
941     else
942         var wgts = [];
943     for(i=0; i<size(wgts); i+=1) {
944         var w = wgts[i];
945         var wname = w.getNode("name", 1).getValue();
946         var wprop = fdmdata.payload ~ "/weight[" ~ i ~ "]";
947
948         var title = tcell(weightTable, "text", i+1, 0);
949         title.set("label", wname);
950         title.set("halign", "right");
951
952         if(w.getNode("opt") != nil) {
953             var combo = tcell(weightTable, "combo", i+1, 1);
954             combo.set("property", wprop ~ "/selected");
955             combo.set("pref-width", 300);
956
957             # Simple code we'd like to use:
958             #foreach(opt; w.getChildren("opt")) {
959             #   var ent = combo.addChild("value");
960             #   ent.prop().setValue(opt.getNode("name", 1).getValue());
961             #}
962
963             # More complicated workaround to move the "current" item
964             # into the first slot, because dialog.cxx doesn't set the
965             # selected item in the combo box.
966             var opts = [];
967             var curr = w.getNode("selected");
968             curr = curr == nil ? "" : curr.getValue();
969             foreach(opt; w.getChildren("opt")) {
970                 append(opts, opt.getNode("name", 1).getValue());
971             }
972             forindex(oi; opts) {
973                 if(opts[oi] == curr) {
974                     var tmp = opts[0];
975                     opts[0] = opts[oi];
976                     opts[oi] = tmp;
977                     break;
978                 }
979             }
980             foreach(opt; opts) {
981                 combo.addChild("value").prop().setValue(opt);
982             }
983
984             combo.setBinding("dialog-apply");
985             combo.setBinding("nasal", "gui.weightChangeHandler()");
986         } else {
987             var slider = tcell(weightTable, "slider", i+1, 1);
988             slider.set("property", wprop ~ "/weight-lb");
989             var min = w.getNode("min-lb", 1).getValue();
990             var max = w.getNode("max-lb", 1).getValue();
991             slider.set("min", min != nil ? min : 0);
992             slider.set("max", max != nil ? max : 100);
993             slider.set("live", 1);
994             slider.setBinding("dialog-apply");
995         }
996
997         var lbs = tcell(weightTable, "text", i+1, 2);
998         lbs.set("property", wprop ~ "/weight-lb");
999         lbs.set("label", "0123456");
1000         lbs.set("format", "%.0f");
1001         lbs.set("live", 1);
1002     }
1003
1004     # All done: pop it up
1005     fgcommand("dialog-new", dialog[name].prop());
1006     showDialog(name);
1007 }
1008
1009
1010
1011
1012 ##
1013 # Dynamically generates a dialog from a help node.
1014 #
1015 # gui.showHelpDialog([<path> [, toggle]])
1016 #
1017 # path   ... path to help node
1018 # toggle ... decides if an already open dialog should be closed
1019 #            (useful when calling the dialog from a key binding; default: 0)
1020 #
1021 # help node
1022 # =========
1023 # each of <title>, <key>, <line>, <text> is optional; uses
1024 # "/sim/description" or "/sim/aircraft" if <title> is omitted;
1025 # only the first <text> is displayed
1026 #
1027 #
1028 # <help>
1029 #     <title>dialog title<title>
1030 #     <key>
1031 #         <name>g/G</name>
1032 #         <desc>gear up/down</desc>
1033 #     </key>
1034 #
1035 #     <line>one line</line>
1036 #     <line>another line</line>
1037 #
1038 #     <text>text in
1039 #           scrollable widget
1040 #     </text>
1041 # </help>
1042 #
1043 showHelpDialog = func {
1044     node = props.globals.getNode(arg[0]);
1045     if (arg[0] == "/sim/help" and size(node.getChildren()) < 4) {
1046         node = node.getChild("common");
1047     }
1048
1049     name = node.getNode("title", 1).getValue();
1050     if (name == nil) {
1051         name = getprop("/sim/description");
1052         if (name == nil) {
1053             name = getprop("/sim/aircraft");
1054         }
1055     }
1056     toggle = size(arg) > 1 and arg[1] != nil and arg[1] > 0;
1057     if (toggle and contains(dialog, name)) {
1058         fgcommand("dialog-close", props.Node.new({ "dialog-name": name }));
1059         delete(dialog, name);
1060         return;
1061     }
1062
1063     dialog[name] = Widget.new();
1064     dialog[name].set("layout", "vbox");
1065     dialog[name].set("default-padding", 0);
1066     dialog[name].set("name", name);
1067
1068     # title bar
1069     titlebar = dialog[name].addChild("group");
1070     titlebar.set("layout", "hbox");
1071     titlebar.addChild("empty").set("stretch", 1);
1072     titlebar.addChild("text").set("label", name);
1073     titlebar.addChild("empty").set("stretch", 1);
1074
1075     w = titlebar.addChild("button");
1076     w.set("pref-width", 16);
1077     w.set("pref-height", 16);
1078     w.set("legend", "");
1079     w.set("default", 1);
1080     w.set("key", "esc");
1081     w.setBinding("nasal", "delete(gui.dialog, \"" ~ name ~ "\")");
1082     w.setBinding("dialog-close");
1083
1084     dialog[name].addChild("hrule");
1085
1086     # key list
1087     keylist = dialog[name].addChild("group");
1088     keylist.set("layout", "table");
1089     keylist.set("default-padding", 2);
1090     keydefs = node.getChildren("key");
1091     n = size(keydefs);
1092     row = col = 0;
1093     foreach (key; keydefs) {
1094         if (n >= 60 and row >= n / 3 or n >= 16 and row >= n / 2) {
1095             col += 1;
1096             row = 0;
1097         }
1098
1099         w = keylist.addChild("text");
1100         w.set("row", row);
1101         w.set("col", 2 * col);
1102         w.set("halign", "right");
1103         w.set("label", " " ~ key.getNode("name").getValue());
1104
1105         w = keylist.addChild("text");
1106         w.set("row", row);
1107         w.set("col", 2 * col + 1);
1108         w.set("halign", "left");
1109         w.set("label", "... " ~ key.getNode("desc").getValue() ~ "  ");
1110         row += 1;
1111     }
1112
1113     # separate lines
1114     lines = node.getChildren("line");
1115     if (size(lines)) {
1116         if (size(keydefs)) {
1117             dialog[name].addChild("empty").set("pref-height", 4);
1118             dialog[name].addChild("hrule");
1119             dialog[name].addChild("empty").set("pref-height", 4);
1120         }
1121
1122         g = dialog[name].addChild("group");
1123         g.set("layout", "vbox");
1124         g.set("default-padding", 1);
1125         foreach (var lin; lines) {
1126             foreach (var l; split("\n", lin.getValue())) {
1127                 w = g.addChild("text");
1128                 w.set("halign", "left");
1129                 w.set("label", " " ~ l ~ " ");
1130             }
1131         }
1132     }
1133
1134     # scrollable text area
1135     if (node.getNode("text") != nil) {
1136         dialog[name].set("resizable", 1);
1137         dialog[name].addChild("empty").set("pref-height", 10);
1138
1139         width = [640, 800, 1152][col];
1140         height = screenHProp.getValue() - (100 + (size(keydefs) / (col + 1) + size(lines)) * 28);
1141         if (height < 200) {
1142             height = 200;
1143         }
1144
1145         w = dialog[name].addChild("textbox");
1146         w.set("padding", 4);
1147         w.set("halign", "fill");
1148         w.set("valign", "fill");
1149         w.set("stretch", "true");
1150         w.set("slider", 20);
1151         w.set("pref-width", width);
1152         w.set("pref-height", height);
1153         w.set("editable", 0);
1154         w.set("property", node.getPath() ~ "/text");
1155     } else {
1156         dialog[name].addChild("empty").set("pref-height", 8);
1157     }
1158     fgcommand("dialog-new", dialog[name].prop());
1159     showDialog(name);
1160 }
1161
1162
1163 var debug_keys = {
1164     title: "Development Keys",
1165     key: [
1166        #{ name: "Ctrl-U",    desc: "add 1000 ft of emergency altitude" },
1167         { name: "Shift-F3",  desc: "load panel" },
1168         { name: "/",         desc: "open property browser" },
1169     ],
1170 };
1171
1172 var basic_keys = {
1173     title: "Basic Keys",
1174     key: [
1175         { name: "?",         desc: "show/hide aircraft help dialog" },
1176        #{ name: "Tab",       desc: "show/hide aircraft config dialog" },
1177         { name: "Esc",       desc: "quit FlightGear" },
1178         { name: "Shift-Esc", desc: "reset FlightGear" },
1179         { name: "a/A",       desc: "increase/decrease speed-up" },
1180         { name: "c",         desc: "toggle 3D/2D cockpit" },
1181         { name: "Ctrl-C",    desc: "toggle clickable panel hotspots" },
1182         { name: "p",         desc: "pause/continue sim" },
1183         { name: "Ctrl-R",    desc: "activate instant replay system" },
1184         { name: "t/T",       desc: "increase/decrease warp delta" },
1185         { name: "v/V",       desc: "cycle views (forward/backward)" },
1186         { name: "Ctrl-V",    desc: "select cockpit view" },
1187         { name: "w/W",       desc: "increase/decrease warp" },
1188         { name: "x/X",       desc: "zoom in/out" },
1189         { name: "Ctrl-X",    desc: "reset zoom to default" },
1190         { name: "z/Z",       desc: "increase/decrease visibility" },
1191         { name: "Ctrl-Z",    desc: "reset visibility to default" },
1192         { name: "'",         desc: "display ATC setting dialog" },
1193         { name: "+",         desc: "let ATC/instructor repeat last message" },
1194         { name: "-",         desc: "open chat dialog" },
1195         { name: "_",         desc: "compose chat message" },
1196         { name: "F3",        desc: "capture screen" },
1197         { name: "F10",       desc: "toggle menubar" },
1198         { name: "Shift-F1",  desc: "load flight" },
1199         { name: "Shift-F2",  desc: "save flight" },
1200         { name: "Shift-F10", desc: "cycle through GUI styles" },
1201     ],
1202 };
1203
1204 var common_aircraft_keys = {
1205     title: "Common Aircraft Keys",
1206     key: [
1207         { name: "Enter",     desc: "move rudder right" },
1208         { name: "0/Insert",  desc: "move rudder left" },
1209         { name: "1/End",     desc: "decrease elevator trim" },
1210         { name: "2/Up",      desc: "increase elevator or AP altitude" },
1211         { name: "3/PgDn",    desc: "decr. throttle or AP autothrottle" },
1212         { name: "4/Left",    desc: "move aileron left or adj. AP hdg." },
1213         { name: "5/KP5",     desc: "center aileron, elev., and rudder" },
1214         { name: "6/Right",   desc: "move aileron right or adj. AP hdg." },
1215         { name: "7/Home",    desc: "increase elevator trim" },
1216         { name: "8/Down",    desc: "decrease elevator or AP altitude" },
1217         { name: "9/PgUp",    desc: "incr. throttle or AP autothrottle" },
1218         { name: "Space",     desc: "PTT - Push To Talk (via VoIP)" },
1219         { name: "!/@/#/$",   desc: "select engine 1/2/3/4" },
1220         { name: "b",         desc: "apply all brakes" },
1221         { name: "B",         desc: "toggle parking brake" },
1222        #{ name: "Ctrl-B",    desc: "toggle speed brake" },
1223         { name: "g/G",       desc: "gear up/down" },
1224         { name: "h",         desc: "cycle HUD (head up display)" },
1225         { name: "H",         desc: "cycle HUD brightness" },
1226         { name: "i/Shift-i", desc: "normal/alternative HUD" },
1227        #{ name: "j",         desc: "decrease spoilers" },
1228        #{ name: "k",         desc: "increase spoilers" },
1229         { name: "l",         desc: "toggle tail-wheel lock" },
1230         { name: "m/M",       desc: "mixture richer/leaner" },
1231         { name: "n/N",       desc: "propeller finer/coarser" },
1232         { name: "P",         desc: "toggle 2D panel" },
1233         { name: "S",         desc: "swap panels" },
1234         { name: "s",         desc: "fire starter on selected eng." },
1235         { name: ", .",       desc: "left/right brake (comma, period)" },
1236         { name: "~",         desc: "select all engines (tilde)" },
1237         { name: "[ ]",       desc: "flaps up/down" },
1238         { name: "{ }",       desc: "decr/incr magneto on sel. eng." },
1239         { name: "Ctrl-A",    desc: "AP: toggle altitude lock" },
1240         { name: "Ctrl-G",    desc: "AP: toggle glide slope lock" },
1241         { name: "Ctrl-H",    desc: "AP: toggle heading lock" },
1242         { name: "Ctrl-N",    desc: "AP: toggle NAV1 lock" },
1243         { name: "Ctrl-P",    desc: "AP: toggle pitch hold" },
1244         { name: "Ctrl-S",    desc: "AP: toggle auto-throttle" },
1245         { name: "Ctrl-T",    desc: "AP: toggle terrain lock" },
1246         { name: "Ctrl-W",    desc: "AP: toggle wing leveler" },
1247         { name: "F6",        desc: "AP: toggle heading mode" },
1248         { name: "F11",       desc: "open autopilot dialog" },
1249         { name: "F12",       desc: "open radio settings dialog" },
1250         { name: "Shift-F5",  desc: "scroll 2D panel down" },
1251         { name: "Shift-F6",  desc: "scroll 2D panel up" },
1252         { name: "Shift-F7",  desc: "scroll 2D panel left" },
1253         { name: "Shift-F8",  desc: "scroll 2D panel right" },
1254     ],
1255 };