Phi: nicer scroll animation for METAR widget
[fg:fgdata.git] / Nasal / canvas / gui.nas
1 var gui = {
2   widgets: {},
3   focused_window: nil,
4   region_highlight: nil,
5
6   # Window/dialog stacking order
7   STACK_INDEX: {
8     "default": 0,
9     "always-on-top": 1,
10     "tooltip": 2
11   }
12 };
13
14 var gui_dir = getprop("/sim/fg-root") ~ "/Nasal/canvas/gui/";
15 var loadGUIFile = func(file) io.load_nasal(gui_dir ~ file, "canvas");
16 var loadWidget = func(name) loadGUIFile("widgets/" ~ name ~ ".nas");
17 var loadDialog = func(name) loadGUIFile("dialogs/" ~ name ~ ".nas");
18
19 loadGUIFile("Config.nas");
20 loadGUIFile("Style.nas");
21 loadGUIFile("Widget.nas");
22 loadGUIFile("styles/DefaultStyle.nas");
23 loadWidget("Button");
24 loadWidget("CheckBox");
25 loadWidget("Label");
26 loadWidget("LineEdit");
27 loadWidget("ScrollArea");
28 loadDialog("InputDialog");
29 loadDialog("MessageBox");
30
31 var style = DefaultStyle.new("AmbianceClassic", "Humanity");
32 var WindowButton = {
33   new: func(parent, name)
34   {
35     var m = {
36       parents: [WindowButton, gui.widgets.Button.new(parent, nil, {"flat": 1})],
37       _name: name
38     };
39     m._focus_policy = m.NoFocus;
40     m._setView({_root: parent.createChild("image", "WindowButton-" ~ name)});
41     return m;
42   },
43 # protected:
44   _onStateChange: func
45   {
46     var file = style._dir_decoration ~ "/" ~ me._name;
47     var window_focus = me._windowFocus();
48     file ~= window_focus ? "_focused" : "_unfocused";
49
50     if( me._down )
51       file ~= "_pressed";
52     else if( me._hover )
53       file ~= "_prelight";
54     else if( window_focus )
55       file ~= "_normal";
56
57     me._view._root.set("src", file ~ ".png");
58   }
59 };
60
61 var Window = {
62   # Constructor
63   #
64   # @param size ([width, height])
65   new: func(size, type = nil, id = nil)
66   {
67     var ghost = _newWindowGhost(id);
68     var m = {
69       parents: [Window, PropertyElement, ghost],
70       _ghost: ghost,
71       _node: props.wrapNode(ghost._node_ghost),
72       _focused: 0,
73       _widgets: []
74     };
75
76     m.setInt("content-size[0]", size[0]);
77     m.setInt("content-size[1]", size[1]);
78
79     # TODO better default position
80     m.move(0,0);
81     m.setFocus();
82
83     # arg = [child, listener_node, mode, is_child_event]
84     setlistener(m._node, func m._propCallback(arg[0], arg[2]), 0, 2);
85     if( type )
86       m.set("type", type);
87
88     return m;
89   },
90   # Destructor
91   del: func
92   {
93     me.clearFocus();
94
95     if( me["_canvas"] != nil )
96     {
97       var placements = me._canvas._node.getChildren("placement");
98       # Do not remove canvas if other placements exist
99       if( size(placements) > 1 )
100         foreach(var p; placements)
101         {
102           if(     p.getValue("type") == "window"
103               and p.getValue("id") == me.get("id") )
104             p.remove();
105         }
106       else
107         me._canvas.del();
108       me._canvas = nil;
109     }
110
111     me._node.remove();
112     me._node = nil;
113   },
114   setTitle: func(title)
115   {
116     return me.set("title", title);
117   },
118   # Create the canvas to be used for this Window
119   #
120   # @return The new canvas
121   createCanvas: func()
122   {
123     var size = [
124       me.get("content-size[0]"),
125       me.get("content-size[1]")
126     ];
127
128     me._canvas = new({
129       size: [size[0], size[1]],
130       view: size,
131       placement: {
132         type: "window",
133         id: me.get("id")
134       },
135
136       # Standard alpha blending
137       "blend-source-rgb": "src-alpha",
138       "blend-destination-rgb": "one-minus-src-alpha",
139
140       # Just keep current alpha (TODO allow using rgb textures instead of rgba?)
141       "blend-source-alpha": "zero",
142       "blend-destination-alpha": "one"
143     });
144
145     me._canvas._focused_widget = nil;
146     me._canvas.data("focused", me._focused);
147     me._canvas.addEventListener("mousedown", func me.raise());
148
149     return me._canvas;
150   },
151   # Set an existing canvas to be used for this Window
152   setCanvas: func(canvas_)
153   {
154     if( ghosttype(canvas_) != "Canvas" )
155       return debug.warn("Not a Canvas");
156
157     canvas_.addPlacement({type: "window", "id": me.get("id")});
158     me['_canvas'] = canvas_;
159
160     canvas_._focused_widget = nil;
161     canvas_.data("focused", me._focused);
162
163     # prevent resizing if canvas is placed from somewhere else
164     me.onResize = nil;
165   },
166   # Get the displayed canvas
167   getCanvas: func(create = 0)
168   {
169     if( me['_canvas'] == nil and create )
170       me.createCanvas();
171
172     return me['_canvas'];
173   },
174   getCanvasDecoration: func()
175   {
176     return wrapCanvas(me._getCanvasDecoration());
177   },
178   setLayout: func(l)
179   {
180     if( me['_canvas'] == nil )
181       me.createCanvas();
182
183     me._canvas.update(); # Ensure placement is applied
184     me._ghost.setLayout(l);
185     return me;
186   },
187   #
188   setFocus: func
189   {
190     if( me._focused )
191       return me;
192
193     if( gui.focused_window != nil )
194       gui.focused_window.clearFocus();
195
196     me._focused = 1;
197 #    me.onFocusIn();
198     me._onStateChange();
199     gui.focused_window = me;
200     setInputFocus(me);
201     return me;
202   },
203   #
204   clearFocus: func
205   {
206     if( !me._focused )
207       return me;
208
209     me._focused = 0;
210 #    me.onFocusOut();
211     me._onStateChange();
212     gui.focused_window = nil;
213     setInputFocus(nil);
214     return me;
215   },
216   setPosition: func
217   {
218     if( size(arg) == 1 )
219       var arg = arg[0];
220     var (x, y) = arg;
221
222     me.setInt("tf/t[0]", x);
223     me.setInt("tf/t[1]", y);
224   },
225   setSize: func
226   {
227     if( size(arg) == 1 )
228       var arg = arg[0];
229     var (w, h) = arg;
230
231     me.set("content-size[0]", w);
232     me.set("content-size[1]", h);
233
234     if( me.onResize != nil )
235       me.onResize();
236
237     return me;
238   },
239   move: func
240   {
241     if( size(arg) == 1 )
242       var arg = arg[0];
243     var (x, y) = arg;
244
245     me.setInt("tf/t[0]", me.get("tf/t[0]", 10) + x);
246     me.setInt("tf/t[1]", me.get("tf/t[1]", 30) + y);
247   },
248   # Raise to top of window stack
249   raise: func()
250   {
251     # on writing the z-index the window always is moved to the top of all other
252     # windows with the same z-index.
253     me.setInt("z-index", me.get("z-index", gui.STACK_INDEX["default"]));
254
255     me.setFocus();
256   },
257   onResize: func()
258   {
259     if( me['_canvas'] == nil )
260       return;
261
262     for(var i = 0; i < 2; i += 1)
263     {
264       var size = me.get("content-size[" ~ i ~ "]");
265       me._canvas.set("size[" ~ i ~ "]", size);
266       me._canvas.set("view[" ~ i ~ "]", size);
267     }
268   },
269 # protected:
270   _onStateChange: func
271   {
272     var event = canvas.CustomEvent.new("wm.focus-" ~ (me._focused ? "in" : "out"));
273
274     if( me._getCanvasDecoration() != nil )
275     {
276       # Stronger shadow for focused windows
277       me.getCanvasDecoration()
278         .set("image[1]/fill", me._focused ? "#000000" : "rgba(0,0,0,0.5)");
279
280       var suffix = me._focused ? "" : "-unfocused";
281       me._title_bar_bg.set("fill", style.getColor("title" ~ suffix));
282       me._title.set(       "fill", style.getColor("title-text" ~ suffix));
283       me._top_line.set(  "stroke", style.getColor("title-highlight" ~ suffix));
284
285       me.getCanvasDecoration()
286         .data("focused", me._focused)
287         .dispatchEvent(event);
288     }
289
290     if( me.getCanvas() != nil )
291       me.getCanvas()
292         .data("focused", me._focused)
293         .dispatchEvent(event);
294   },
295 # private:
296   _propCallback: func(child, mode)
297   {
298     if( !me._node.equals(child.getParent()) )
299       return;
300     var name = child.getName();
301
302     # support for CSS like position: absolute; with right and/or bottom margin
303     if( name == "right" )
304       me._handlePositionAbsolute(child, mode, name, 0);
305     else if( name == "bottom" )
306       me._handlePositionAbsolute(child, mode, name, 1);
307
308     # update decoration on type change
309     else if( name == "type" )
310     {
311       if( mode == 0 )
312         settimer(func me._updateDecoration(), 0, 1);
313     }
314
315     else if( name.starts_with("resize-") )
316     {
317       if( mode == 0 )
318         me._handleResize(child, name);
319     }
320     else if( name == "size" )
321     {
322       if( mode == 0 )
323         me._resizeDecoration();
324     }
325   },
326   _handlePositionAbsolute: func(child, mode, name, index)
327   {
328     # mode
329     #   -1 child removed
330     #    0 value changed
331     #    1 child added
332
333     if( mode == 0 )
334       me._updatePos(index, name);
335     else if( mode == 1 )
336       me["_listener_" ~ name] = [
337         setlistener
338         (
339           "/sim/gui/canvas/size[" ~ index ~ "]",
340           func me._updatePos(index, name)
341         ),
342         setlistener
343         (
344           me._node.getNode("content-size[" ~ index ~ "]"),
345           func me._updatePos(index, name)
346         )
347       ];
348     else if( mode == -1 )
349       for(var i = 0; i < 2; i += 1)
350         removelistener(me["_listener_" ~ name][i]);
351   },
352   _updatePos: func(index, name)
353   {
354     me.setInt
355     (
356       "tf/t[" ~ index ~ "]",
357       getprop("/sim/gui/canvas/size[" ~ index ~ "]")
358       - me.get(name)
359       - me.get("content-size[" ~ index ~ "]")
360     );
361   },
362   _handleResize: func(child, name)
363   {
364     var is_status = name == "resize-status";
365     if( !is_status and !me["_resize"] )
366       return;
367
368     var min_size = [75, 100];
369
370     var x = me.get("tf/t[0]");
371     var y = me.get("tf/t[1]");
372     var old_size = [me.get("size[0]"), me.get("size[1]")];
373
374     var l = x + math.min(me.get("resize-left"), old_size[0] - min_size[0]);
375     var t = y + math.min(me.get("resize-top"), old_size[1] - min_size[1]);
376     var r = x + math.max(me.get("resize-right"), min_size[0]);
377     var b = y + math.max(me.get("resize-bottom"), min_size[1]);
378
379     if( is_status )
380     {
381       me._resize = child.getValue();
382
383       if( me._resize and gui.region_highlight == nil )
384         gui.region_highlight =
385           getDesktop().createChild("path", "highlight")
386                       .set("stroke", "#ffa500")
387                       .set("stroke-width", 2)
388                       .set("fill", "rgba(255, 165, 0, 0.15)")
389                       .set("z-index", 100);
390       else if( !me._resize and gui.region_highlight != nil )
391       {
392         gui.region_highlight.hide();
393         me.setPosition(l, t);
394         me.setSize
395         (
396           me.get("content-size[0]") + (r - l) - old_size[0],
397           me.get("content-size[1]") + (b - t) - old_size[1],
398         );
399         if( me.onResize != nil )
400           me.onResize();
401         return;
402       }
403     }
404     else if( !me["_resize"] )
405       return;
406
407     gui.region_highlight.reset()
408                         .moveTo(l, t)
409                         .horizTo(r)
410                         .vertTo(b)
411                         .horizTo(l)
412                         .close()
413                         .update()
414                         .show();
415   },
416   _updateDecoration: func()
417   {
418     var border_radius = 9;
419     me.set("decoration-border", "25 1 1");
420     me.set("shadow-inset", int((1 - math.cos(45 * D2R)) * border_radius + 0.5));
421     me.set("shadow-radius", 5);
422     me.setBool("update", 1);
423
424     var canvas_deco = me.getCanvasDecoration();
425     canvas_deco.addEventListener("mousedown", func me.raise());
426     canvas_deco.set("blend-source-rgb", "src-alpha");
427     canvas_deco.set("blend-destination-rgb", "one-minus-src-alpha");
428     canvas_deco.set("blend-source-alpha", "one");
429     canvas_deco.set("blend-destination-alpha", "one");
430
431     var group_deco = canvas_deco.getGroup("decoration");
432     var title_bar = group_deco.createChild("group", "title_bar");
433     me._title_bar_bg = title_bar.createChild("path");
434     me._top_line = title_bar.createChild("path", "top-line");
435
436     # close icon
437     var x = 10;
438     var y = 3;
439     var w = 19;
440     var h = 19;
441
442     var button_close = WindowButton.new(title_bar, "close")
443                                    .move(x, y);
444     button_close.listen("clicked", func me.del());
445
446     # title
447     me._title = title_bar.createChild("text", "title")
448                          .set("alignment", "left-center")
449                          .set("character-size", 14)
450                          .set("font", "LiberationFonts/LiberationSans-Bold.ttf")
451                          .setTranslation( int(x + 1.5 * w + 0.5),
452                                           int(y + 0.5 * h + 0.5) );
453
454     var title = me.get("title", "Canvas Dialog");
455     me._node.getNode("title", 1).alias(me._title._node.getPath() ~ "/text");
456     me.set("title", title);
457
458     title_bar.addEventListener("drag", func(e) me.move(e.deltaX, e.deltaY));
459
460     me._resizeDecoration();
461     me._onStateChange();
462   },
463   _resizeDecoration: func()
464   {
465     if( me["_title_bar_bg"] == nil )
466       return;
467
468     var border_radius = 9;
469     me._title_bar_bg
470         .reset()
471         .rect( 0, 0,
472                me.get("size[0]"), me.get("size[1]"),
473                {"border-top-radius": border_radius} );
474
475     me._top_line
476         .reset()
477         .moveTo(border_radius - 2, 2)
478         .lineTo(me.get("size[0]") - border_radius + 2, 2);
479   }
480 };
481
482 # Clear focus on click outside any window
483 getDesktop().addEventListener("mousedown", func {
484   if( gui.focused_window != nil )
485     gui.focused_window.clearFocus();
486 });
487
488 # Provide old 'Dialog' for backwards compatiblity (should be removed for 3.0)
489 var Dialog = {
490   new: func(size, type = nil, id = nil)
491   {
492     debug.warn("'canvas.Dialog' is deprectated! (use canvas.Window instead)");
493     return Window.new(size, type, id);
494   }
495 };