Phi: nicer scroll animation for METAR widget
[fg:fgdata.git] / Nasal / screen.nas
1 # on-screen displays
2 #==============================================================================
3
4
5 ##
6 # convert string for output; replaces tabs by spaces, and skips
7 # delimiters and the voice part in "{text|voice}" constructions
8 #
9 var sanitize = func(s, newline = 0) {
10         var r = "";
11         var skip = 0;
12         s ~= "";
13         for (var i = 0; i < size(s); i += 1) {
14                 var c = s[i];
15                 if (c == `\t`)
16                         r ~= ' ';
17                 elsif (c == `{`)
18                         nil;
19                 elsif (c == `|`)
20                         skip = 1;
21                 elsif (c == `}`)
22                         skip = 0;
23                 elsif (c == `\n` and newline)
24                         r ~= "\\n";
25                 elsif (!skip)
26                         r ~= chr(c);
27         }
28         return r;
29 }
30
31
32
33 var theme_font = nil;
34
35
36
37 # screen.window
38 #------------------------------------------------------------------------------
39 # Class that manages a dialog with fixed number of lines, where you can push in
40 # text at the bottom, which then (optionally) scrolls up after some time.
41 #
42 # simple use:
43 #
44 #     var window = screen.window.new();
45 #     window.write("message in the middle of the screen");
46 #
47 #
48 # advanced use:
49 #
50 #     var window = screen.window.new(nil, -100, 3, 10);
51 #     window.fg = [1, 1, 1, 1];    # choose white default color
52 #     window.align = "left";
53 #
54 #     window.write("first line");
55 #     window.write("second line (red)", 1, 0, 0);
56 #
57 #
58 #
59 # arguments:
60 #            x ... x coordinate
61 #            y ... y coordinate
62 #                  positive coords position relative to the left/lower corner,
63 #                  negative coords from the right/upper corner, nil centers
64 #     maxlines ... max number of displayed lines; if more are pushed into the
65 #                  screen, then the ones on top fall off
66 #   autoscroll ... seconds that each line should be shown; can be less if
67 #                  a message falls off; if 0 then don't scroll at all
68 #
69 var window = {
70         id : 0,
71         new : func(x = nil, y = nil, maxlines = 10, autoscroll = 10) {
72                 var m = { parents: [window] };
73                 #
74                 # "public"
75                 m.x = x;
76                 m.y = y;
77                 m.maxlines = maxlines;
78                 m.autoscroll = autoscroll;      # display time in seconds
79                 m.sticky = 0;                   # reopens on old place
80                 m.font = nil;
81                 m.bg = [0, 0, 0, 0];            # background color
82                 m.fg = [0.9, 0.4, 0.2, 1];      # default foreground color
83                 m.align = "center";             # "left", "right", "center"
84                 #
85                 # "private"
86                 m.name = "__screen_window_" ~ (window.id += 1) ~ "__";
87                 m.lines = [];
88                 m.skiptimer = 0;
89                 m.dialog = nil;
90                 m.namenode = props.Node.new({ "dialog-name": m.name });
91                 m.writebuffer = [];
92                 m.MAX_BUFFER_SIZE = 50;
93                 setlistener("/sim/startup/xsize", func m._redraw_());
94                 setlistener("/sim/startup/ysize", func m._redraw_());
95                 return m;
96         },
97         write : func(msg, r = nil, g = nil, b = nil, a = nil) {
98                 if (me.namenode == nil)
99                         return;
100                 if (size(me.writebuffer) > me.MAX_BUFFER_SIZE)
101                         return;
102                 if (r == nil)
103                         r = me.fg[0];
104                 if (g == nil)
105                         g = me.fg[1];
106                 if (b == nil)
107                         b = me.fg[2];
108                 if (a == nil)
109                         a = me.fg[3];
110                 var lines = [];
111                 foreach (var line; split("\n", string.trim(msg ~ ""))) {
112                         line = sanitize(string.trim(line));
113                         append(lines, [line, r, g, b, a]);
114                 }
115                 if (size(me.writebuffer) == 0)
116                         settimer(func { me._write_(); } , 0, 1);
117                 append(me.writebuffer, lines);
118         },
119         clear : func() {
120           me.lines = [];
121           me.writebuffer = [];
122           me.show();
123         },
124         show : func {
125                 if (me.dialog != nil)
126                         me.close();
127
128                 me.dialog = gui.Widget.new();
129                 me.dialog.set("name", me.name);
130                 if (me.x != nil)
131                         me.dialog.set("x", me.x);
132                 if (me.y != nil)
133                         me.dialog.set("y", me.y);
134                 me.dialog.set("layout", "vbox");
135                 me.dialog.set("default-padding", 2);
136                 if (me.font != nil)
137                         me.dialog.setFont(me.font);
138                 elsif (theme_font != nil)
139                         me.dialog.setFont(theme_font);
140
141                 me.dialog.setColor(me.bg[0], me.bg[1], me.bg[2], me.bg[3]);
142
143                 foreach (var line; me.lines) {
144                         var w = me.dialog.addChild("text");
145                         w.set("halign", me.align);
146                         w.set("label", line[0]);
147                         w.setColor(line[1], line[2], line[3], line[4]);
148                 }
149
150                 fgcommand("dialog-new", me.dialog.prop());
151                 fgcommand("dialog-show", me.namenode);
152         },
153         close : func {
154                 fgcommand("dialog-close", me.namenode);
155                 if (me.dialog != nil and me.sticky) {
156                         me.x = me.dialog.prop().getNode("lastx").getValue();
157                         me.y = me.dialog.prop().getNode("lasty").getValue();
158                 }
159         },
160         _write_ : func() {
161                 if (size(me.writebuffer) == 0)
162                         return;
163                 foreach (var msg; me.writebuffer) {
164                         foreach (var line; msg) {
165                                 append(me.lines, line);
166                                 if (size(me.lines) > me.maxlines) {
167                                         me.lines = subvec(me.lines, 1);
168                                         if (me.autoscroll)
169                                                 me.skiptimer += 1;
170                                 }
171                                 if (me.autoscroll)
172                                         settimer(func me._timeout_(), me.autoscroll, 1);
173                         }
174                 }
175                 me.writebuffer = [];
176                 me.show();
177         },
178         _timeout_ : func {
179                 if (me.skiptimer > 0) {
180                         me.skiptimer -= 1;
181                         return;
182                 }
183                 if (size(me.lines) > 1) {
184                         me.lines = subvec(me.lines, 1);
185                         me.show();
186                 } else {
187                         me.close();
188                         me.dialog = nil;
189                         me.lines = [];
190                 }
191         },
192         _redraw_ : func {
193                 if (me.dialog != nil) {
194                         me.close();
195                         me.show();
196                 }
197         },
198 };
199
200
201
202 # screen.display
203 #------------------------------------------------------------------------------
204 # Class that manages a dialog, which displays an arbitrary number of properties
205 # periodically updating the values. Property names are abbreviated to the
206 # shortest possible unique part.
207 #
208 # Example:
209 #
210 #     var dpy = screen.display.new(20, 10);    # x/y coordinate
211 #     dpy.setcolor(1, 0, 1);                   # magenta (default: white)
212 #     dpy.setfont("SANS_12B");                 # see $FG_ROOT/gui/styles/*.xml
213 #
214 #     dpy.add("/position/latitude-deg", "/position/longitude-deg");
215 #     dpy.add(props.globals.getNode("/orientation").getChildren());
216 #
217 #
218 # The add() method takes one or more property paths or props.Nodes, or a vector
219 # containing those, or a hash with properties, or vectors with properties, etc.
220 # Internal "public" parameters may be set directly:
221 #
222 #     dpy.interval = 0;                        # update every frame
223 #     dpy.format = "%.3g";                     # max. 3 digits fractional part
224 #     dpy.tagformat = "%-12s";                 # align prop names to 12 spaces
225 #     dpy.redraw();                            # pick up new settings
226 #
227 #
228 # The open() method should only be used to undo a close() call. In all other
229 # cases this is done implicitly. redraw() is automatically called by an add(),
230 # but can be used to let the dialog pick up new settings of internal variables.
231 #
232 #
233 # Methods add(), setfont() and setcolor() can be appended to the new()
234 # constructor (-> show big yellow frame rate counter in upper right corner):
235 #
236 #     screen.display.new(-15, -5, 0).setfont("TIMES_24").setcolor(1, 0.9, 0).add("/sim/frame-rate");
237 #
238 var display = {
239         id : 0,
240         new : func(x, y, show_tags = 1) {
241                 var m = { parents: [display] };
242                 #
243                 # "public"
244                 m.x = x;
245                 m.y = y;
246                 m.tags = show_tags;
247                 m.font = "HELVETICA_14";
248                 m.color = [1, 1, 1, 1];
249                 m.tagformat = "%s";
250                 m.format = "%.12g";
251                 m.interval = 0.1;
252                 #
253                 # "private"
254                 m.loopid = 0;
255                 m.dialog = nil;
256                 m.name = "__screen_display_" ~ (display.id += 1) ~ "__";
257                 m.base = props.globals.getNode("/sim/gui/dialogs/property-display-" ~ display.id, 1);
258                 m.namenode = props.Node.new({ "dialog-name": m.name });
259                 setlistener("/sim/startup/xsize", func m.redraw());
260                 setlistener("/sim/startup/ysize", func m.redraw());
261                 m.reset();
262                 return m;
263         },
264         setcolor : func(r, g, b, a = 1) {
265                 me.color = [r, g, b, a];
266                 me.redraw();
267                 me;
268         },
269         setfont : func(font) {
270                 me.font = font;
271                 me.redraw();
272                 me;
273         },
274         _create_ : func {
275                 me.dialog = gui.Widget.new();
276                 me.dialog.set("name", me.name);
277                 me.dialog.set("x", me.x);
278                 me.dialog.set("y", me.y);
279                 me.dialog.set("layout", "vbox");
280                 me.dialog.set("default-padding", 2);
281                 me.dialog.setFont(me.font);
282                 me.dialog.setColor(0, 0, 0, 0);
283
284                 foreach (var e; me.entries) {
285                         var w = me.dialog.addChild("text");
286                         w.set("halign", "left");
287                         w.set("label", "M");    # mouse-grab sensitive area
288                         w.set("property", e.target.getPath());
289                         w.set("format", me.tags ? e.tag ~ " = %s" : "%s");
290                         w.set("live", 1);
291                         w.setColor(me.color[0], me.color[1], me.color[2], me.color[3]);
292                 }
293                 fgcommand("dialog-new", me.dialog.prop());
294         },
295         # add() opens already, so call open() explicitly only after close()!
296         open : func {
297                 if (me.dialog != nil) {
298                         fgcommand("dialog-show", me.namenode);
299                         me._loop_(me.loopid += 1);
300                 }
301         },
302         close : func {
303                 if (me.dialog != nil) {
304                         fgcommand("dialog-close", me.namenode);
305                         me.loopid += 1;
306                         me.dialog = nil;
307                 }
308         },
309         toggle : func {
310                 me.dialog == nil ? me.redraw() : me.close();
311         },
312         reset : func {
313                 me.close();
314                 me.loopid += 1;
315                 me.entries = [];
316         },
317         redraw : func {
318                 me.close();
319                 me._create_();
320                 me.open();
321         },
322         add : func(p...) {
323                 foreach (nextprop; var n; props.nodeList(p)) {
324                         var path = n.getPath();
325                         foreach (var e; me.entries) {
326                                 if (e.node.getPath() == path)
327                                         continue nextprop;
328                                 e.parent = e.node;
329                                 e.tag = sprintf(me.tagformat, me.nameof(e.node));
330                         }
331                         append(me.entries, { node: n, parent: n,
332                                         tag: sprintf(me.tagformat, me.nameof(n)),
333                                         target: me.base.getChild("entry", size(me.entries), 1) });
334                 }
335
336                 # extend names to the left until they are unique
337                 while (me.tags) {
338                         var uniq = {};
339                         foreach (var e; me.entries) {
340                                 if (contains(uniq, e.tag))
341                                         append(uniq[e.tag], e);
342                                 else
343                                         uniq[e.tag] = [e];
344                         }
345
346                         var done = 1;
347                         foreach (var u; keys(uniq)) {
348                                 if (size(uniq[u]) == 1)
349                                         continue;
350                                 done = 0;
351                                 foreach (var e; uniq[u]) {
352                                         e.parent = e.parent.getParent();
353                                         if (e.parent != nil)
354                                                 e.tag = me.nameof(e.parent) ~ '/' ~ e.tag;
355                                 }
356                         }
357                         if (done)
358                                 break;
359                 }
360                 me.redraw();
361                 me;
362         },
363         update : func {
364                 foreach (var e; me.entries) {
365                         var type = e.node.getType();
366                         if (type == "NONE")
367                                 var val = "nil";
368                         elsif (type == "BOOL")
369                                 var val = e.node.getValue() ? "true" : "false";
370                         elsif (type == "STRING" or type == "UNSPECIFIED")
371                                 var val = "'" ~ sanitize(e.node.getValue(), 1) ~ "'";
372                         else
373                                 var val = sprintf(me.format, e.node.getValue());
374                         e.target.setValue(val);
375                 }
376         },
377         _loop_ : func(id) {
378                 id != me.loopid and return;
379                 me.update();
380                 settimer(func me._loop_(id), me.interval);
381         },
382         nameof : func(n) {
383                 var name = n.getName();
384                 if (var i = n.getIndex())
385                         name ~= '[' ~ i ~ ']';
386                 return name;
387         },
388 };
389
390
391
392
393 var listener = {};
394 var log = nil;
395 var property_display = nil;
396 var controls = nil;
397
398
399 # Shift-click       in the property browser adds the selected property to the property display
400 # Shift-Alt-click   adds all children of the selected property to the property display
401 # Shift-Ctrl-click  removes all properties from the display
402 #
403 _setlistener("/sim/signals/nasal-dir-initialized", func {
404         property_display = display.new(5, -25);
405         listener.display = setlistener("/sim/gui/dialogs/property-browser/selected", func(n) {
406                 var n = n.getValue();
407                 if (n != "" and getprop("/devices/status/keyboard/shift")) {
408                         if (getprop("/devices/status/keyboard/ctrl"))
409                                 return property_display.reset();
410                         n = props.globals.getNode(n);
411                         if (!n.getAttribute("children"))
412                                 property_display.add(n);
413                         elsif (getprop("/devices/status/keyboard/alt"))
414                                 property_display.add(n.getChildren());
415                 }
416         });
417
418         setlistener("/sim/gui/current-style", func {
419                 var theme = getprop("/sim/gui/current-style");
420                 theme_font = getprop("/sim/gui/style[" ~ theme ~ "]/fonts/message-display/name");
421         }, 1);
422
423         log = window.new(nil, -30, 10, 10);
424         log.sticky = 0;  # don't turn on; makes scrolling up messages jump left and right
425
426         var b = "/sim/screen/";
427         setlistener(b ~ "black",   func(n) log.write(n.getValue(), 0,   0,   0));
428         setlistener(b ~ "white",   func(n) log.write(n.getValue(), 1,   1,   1));
429         setlistener(b ~ "red",     func(n) log.write(n.getValue(), 0.8, 0,   0));
430         setlistener(b ~ "green",   func(n) log.write(n.getValue(), 0,   0.6, 0));
431         setlistener(b ~ "blue",    func(n) log.write(n.getValue(), 0,   0,   0.8));
432         setlistener(b ~ "yellow",  func(n) log.write(n.getValue(), 0.8, 0.8, 0));
433         setlistener(b ~ "magenta", func(n) log.write(n.getValue(), 0.7, 0,   0.7));
434         setlistener(b ~ "cyan",    func(n) log.write(n.getValue(), 0,   0.6, 0.6));
435 });
436
437
438
439 # --prop:display=sim/frame-rate         ... adds this property to the property display
440 # --prop:display=position/              ... adds all properties under /position/  (ends with slash!)
441 # --prop:display=position/,orientation/ ... separate multiple properties with comma
442 #
443 var fdm_init_listener = _setlistener("/sim/signals/fdm-initialized", func {
444         removelistener(fdm_init_listener); # uninstall, so we're only called once
445         foreach (var n; props.globals.getChildren("display")) {
446                 foreach (var p; split(",", n.getValue())) {
447                         if (!size(p))
448                                 continue;
449                         if (find('%', p) >= 0)
450                                 property_display.format = p;
451                         elsif (p[-1] == `/`)
452                                 property_display.add(props.globals.getNode(p, 1).getChildren());
453                         else
454                                 property_display.add(p);
455                 }
456         }
457         props.globals.removeChildren("display");
458 });
459
460
461
462 var search_name_in_msg = func(msg, call) {
463         var matching = 0;
464         var found = 0;
465         for(var i = 0; i < size(msg); i = i + 1) {
466                 if (msg[i] == ` ` or msg[i] == `,` or msg[i] == `.` or msg[i] == `;` or msg[i] == `:` or msg[i] == `>`) {
467                         if (matching == size(call)) {
468                                 found = 1;
469                                 break;
470                         }
471                         matching = 0;
472                         continue;
473                 }
474                 if (matching >= size(call)) {
475                         matching = matching + 1;
476                         continue;
477                 }
478                 if (call[matching] == msg[i]) {
479                         matching = matching + 1;
480                 } else {
481                         matching = 0;
482                 }
483         }
484         if (found == 1 or matching == size(call))
485                 return 1;
486         else
487                 return 0;
488 }
489 ##############################################################################
490 # functions that make use of the window class (and don't belong anywhere else)
491 ##############################################################################
492
493 # highlights messages with the multiplayer callsign in the text
494 var msg_mp = func (n) {
495         if (!getprop("/sim/multiplay/chat-display"))
496                 return;
497         var msg = string.lc(n.getValue());
498         var call = string.lc(getprop("/sim/multiplay/callsign"));
499         var highlight = getprop("/sim/multiplay/chat_highlight");
500         if (search_name_in_msg(msg, call) or (highlight != nil and search_name_in_msg(msg, string.lc(highlight))))
501                 screen.log.write(n.getValue(), 1.0, 0.5, 0.5);
502         else
503                 screen.log.write(n.getValue(), 0.5, 0.0, 0.8);
504 }
505
506 var msg_repeat = func {
507         if (getprop("/sim/tutorials/running")) {
508                 var last = getprop("/sim/tutorials/last-message");
509                 if (last == nil)
510                         return;
511
512                 setprop("/sim/messages/pilot", "Say again ...");
513                 settimer(func setprop("/sim/messages/copilot", last), 1.5);
514
515         } else {
516                 var last = atc.getValue();
517                 if (last == nil)
518                         return;
519
520                 setprop("/sim/messages/pilot", "This is " ~ callsign.getValue() ~ ". Say again, over.");
521                 settimer(func atc.setValue(atclast.getValue()), 6);
522         }
523 }
524
525
526 var atc = nil;
527 var callsign = nil;
528 var atclast = nil;
529
530 _setlistener("/sim/signals/nasal-dir-initialized", func {
531         # set /sim/screen/nomap=true to prevent default message mapping
532         var nomap = getprop("/sim/screen/nomap");
533         if (nomap != nil and nomap)
534                 return;
535
536         callsign = props.globals.getNode("/sim/user/callsign", 1);
537         atc = props.globals.getNode("/sim/messages/atc", 1);
538         atclast = props.globals.getNode("/sim/messages/atc-last", 1);
539         atclast.setValue("");
540
541         # let ATC tell which runway was automatically chosen after startup/teleportation
542         settimer(func {
543                 setlistener("/sim/atc/runway", func(n) { # set in src/Main/fg_init.cxx
544                         var rwy = n.getValue();
545                         if (rwy == nil)
546                                 return;
547                         if (getprop("/sim/presets/airport-id") == "KSFO" and rwy == "28R")
548                                 return;
549                         if ((var agl = getprop("/position/altitude-agl-ft")) != nil and agl > 100)
550                                 return;
551                         screen.log.write("You are on runway " ~ rwy, 0.7, 1.0, 0.7);
552                 }, 1);
553         }, 5);
554
555         setlistener("/gear/launchbar/state", func(n) {
556                 if (n.getValue() == "Engaged")
557                         setprop("/sim/messages/copilot", "Engaged!");
558         }, 0, 0);
559
560         # map ATC messages to the screen log and to the voice subsystem
561         var map = func(type, msg, r, g, b, cond = nil) {
562                 printlog("info", "{", type, "} ", msg);
563                 setprop("/sim/sound/voices/" ~ type, msg);
564
565                 if (cond == nil or cond())
566                         screen.log.write(msg, r, g, b);
567
568                 # save last ATC message for user callsign, unless this was already
569                 # a repetition; insert "I say again" appropriately
570                 if (type == "atc") {
571                         var cs = callsign.getValue();
572                         if (find(", I say again: ", atc.getValue()) < 0
573                                         and (var pos = find(cs, msg)) >= 0) {
574                                 var m = substr(msg, 0, pos + size(cs));
575                                 msg = substr(msg, pos + size(cs));
576
577                                 if ((pos = find("Tower, ", msg)) >= 0) {
578                                         m ~= substr(msg, 0, pos + 7);
579                                         msg = substr(msg, pos + 7);
580                                 } else {
581                                         m ~= ", ";
582                                 }
583                                 m ~= "I say again: " ~ msg;
584                                 atclast.setValue(m);
585                                 printlog("debug", "ATC_LAST_MESSAGE: ", m);
586                         }
587                 }
588         }
589
590         var m = "/sim/messages/";
591         listener.atc = setlistener(m ~ "atc",
592                         func(n) map("atc",      n.getValue(), 0.7, 1.0, 0.7));
593         listener.approach = setlistener(m ~ "approach",
594                         func(n) map("approach", n.getValue(), 0.7, 1.0, 0.7));
595         listener.ground = setlistener(m ~ "ground",
596                         func(n) map("ground",   n.getValue(), 0.7, 1.0, 0.7));
597
598         listener.pilot = setlistener(m ~ "pilot",
599                         func(n) map("pilot",    n.getValue(), 1.0, 0.8, 0.0));
600         listener.copilot = setlistener(m ~ "copilot",
601                         func(n) map("copilot",  n.getValue(), 1.0, 1.0, 1.0));
602         listener.ai_plane = setlistener(m ~ "ai-plane",
603                         func(n) map("ai-plane", n.getValue(), 0.9, 0.4, 0.2));
604         listener.mp_plane = setlistener(m ~ "mp-plane", msg_mp);
605 });
606
607