1 ///
2 module glui.node;
3 
4 import raylib;
5 
6 import std.math;
7 import std.traits;
8 import std.string;
9 
10 import glui.style;
11 import glui.utils;
12 import glui.structs;
13 
14 @safe:
15 
16 private interface Styleable {
17 
18     /// Reload styles for the node. Triggered when the theme is changed.
19     ///
20     /// Use `mixin DefineStyles` to generate the styles.
21     final void reloadStyles() {
22 
23         // First load what we're given
24         reloadStylesImpl();
25 
26         // Then load the defaults
27         loadDefaultStyles();
28 
29     }
30 
31     // Internal:
32 
33     protected void reloadStylesImpl();
34     protected void loadDefaultStyles();
35 
36 }
37 
38 /// Represents a Glui node.
39 abstract class GluiNode : Styleable {
40 
41     /// This node defines a single style, `style`, which also works as a default style for all other nodes. However,
42     /// rather than for that, the purpose of this style is to define the convention of `style` being the node's default,
43     /// idle style.
44     ///
45     /// It should be noted the default `style` is the only style that affects a node's sizing — as the tree would have
46     /// to be resized in case they changed and secondary styles are assumed to change frequently (for example, on
47     /// hover). In practice, resizing the tree on those changes usually ends up horrible for the user, so it's advised
48     /// to stick to constant sizing in order to not hurt the accessibility.
49     mixin DefineStyles!(
50         "style", q{ Style.init },
51     );
52 
53     public {
54 
55         /// Tree data for the node. Note: requires at least one draw before this will work.
56         LayoutTree* tree;
57 
58         /// Layout for this node.
59         Layout layout;
60 
61         /// If true, this node will be removed from the tree on the next draw.
62         bool toRemove;
63 
64         /// If true, mouse focus will be disabled for this node, so mouse signals will "go through" to its parents, as
65         /// if the node wasn't there. The mouse will still detect hover like normal.
66         bool ignoreMouse;
67 
68         deprecated("mousePass has been renamed to ignoreMouse and will be removed in 0.6.0")
69         ref inout(bool) mousePass() inout { return ignoreMouse; }
70 
71     }
72 
73     /// Minimum size of the node.
74     protected auto minSize = Vector2(0, 0);
75 
76     private {
77 
78         /// If true, this node must update its size.
79         bool _requiresResize = true;
80 
81         /// If true, this node is hidden and won't be rendered.
82         bool _isHidden;
83 
84         /// If true, this node is currently hovered.
85         bool _isHovered;
86 
87         /// If true, this node is currently disabled.
88         bool _isDisabled;
89 
90         /// Theme of this node.
91         Theme _theme;
92 
93     }
94 
95     @property {
96 
97         /// Get the current theme.
98         pragma(inline)
99         const(Theme) theme() const { return _theme; }
100 
101         /// Set the theme.
102         const(Theme) theme(const Theme value) @trusted {
103 
104             _theme = cast(Theme) value;
105             reloadStyles();
106             return _theme;
107 
108         }
109 
110     }
111 
112     @property {
113 
114         /// Check if the node is hidden.
115         bool isHidden() const return { return _isHidden; }
116 
117         /// Set the visibility
118         bool isHidden(bool value) return {
119 
120             // If changed, trigger resize
121             if (_isHidden != value) updateSize();
122 
123             return _isHidden = value;
124 
125         }
126 
127         deprecated("hidden has been renamed to isHidden and will be removed in 0.6.0") {
128 
129             bool hidden() const { return _isHidden; }
130             bool hidden(bool value) { return _isHidden = value; }
131 
132         }
133 
134     }
135 
136     /// Params:
137     ///     layout = Layout for this node.
138     ///     theme = Theme of this node.
139     this(Layout layout = Layout.init, const Theme theme = null) {
140 
141         this.layout = layout;
142         this.theme  = theme;
143 
144     }
145 
146     /// Ditto
147     this(const Theme theme = null, Layout layout = Layout.init) {
148 
149         this(layout, theme);
150 
151     }
152 
153     /// Ditto
154     this() {
155 
156         this(Layout.init, null);
157 
158     }
159 
160     /// Show the node.
161     This show(this This = GluiNode)() return {
162 
163         // Note: The default value for This is necessary, otherwise virtual calls don't work
164 
165         isHidden = false;
166         return cast(This) this;
167 
168     }
169 
170     /// Hide the node.
171     This hide(this This = GluiNode)() return {
172 
173         isHidden = true;
174         return cast(This) this;
175 
176     }
177 
178     /// Toggle the node's visibility.
179     final void toggleShow() { isHidden = !isHidden; }
180 
181     /// Remove this node from the tree before the next draw.
182     final void remove() {
183 
184         isHidden = true;
185         toRemove = true;
186 
187     }
188 
189     /// Check if this node is hovered.
190     ///
191     /// Returns false if the node or some of its ancestors are disabled.
192     @property
193     bool isHovered() const { return _isHovered && !_isDisabled && !tree.disabledDepth; }
194 
195     deprecated("hovered has been renamed to isHovered and will be removed in 0.6.0.")
196     bool hovered() const { return isHovered; }
197 
198     /// Check if this node is disabled.
199     ref inout(bool) isDisabled() inout { return _isDisabled; }
200 
201     /// Check if this node is disabled.
202     deprecated("disabled has been renamed to isDisabled and will be removed in 0.6.0.")
203     ref inout(bool) disabled() inout { return isDisabled; }
204 
205     /// Checks if the node is disabled, either by self, or by any of its ancestors. Only works while the node is being
206     /// drawn.
207     protected bool isDisabledInherited() const { return tree.disabledDepth != 0; }
208 
209     /// Recalculate the window size before next draw.
210     final void updateSize() scope {
211 
212         if (tree) tree.root._requiresResize = true;
213         // Tree might be null — if so, the node will be resized regardless
214 
215     }
216 
217     /// Draw this node as a root node.
218     final void draw() @trusted {
219 
220         // No tree set
221         if (tree is null) {
222 
223             // Create one
224             tree = new LayoutTree(this);
225 
226             // Workaround for a HiDPI scissors mode glitch, which breaks Glui
227             version (Glui_Raylib3)
228             SetWindowSize(GetScreenWidth, GetScreenHeight);
229 
230         }
231 
232         // No theme set, set the default
233         if (!theme) {
234 
235             import glui.default_theme;
236             theme = gluiDefaultTheme;
237 
238         }
239 
240         version (Glui_Raylib3) const scale = hidpiScale();
241         else                   const scale = Vector2(1, 1);
242 
243         const space = Vector2(GetScreenWidth / scale.x, GetScreenHeight / scale.y);
244 
245         // Clear mouse hover if LMB is up
246         if (!isLMBHeld) tree.hover = null;
247 
248 
249         // Resize if required
250         if (IsWindowResized || _requiresResize) {
251 
252             resize(tree, theme, space);
253             _requiresResize = false;
254 
255         }
256 
257         // Draw this node
258         draw(Rectangle(0, 0, space.x, space.y));
259 
260 
261         // Set mouse cursor to match hovered node
262         if (tree.hover) {
263 
264             if (auto style = tree.hover.pickStyle) {
265 
266                 SetMouseCursor(style.mouseCursor);
267 
268             }
269 
270         }
271 
272 
273         // Note: pressed, not released; released activates input events, pressed activates focus
274         const mousePressed = IsMouseButtonPressed(MouseButton.MOUSE_LEFT_BUTTON);
275 
276         // TODO: remove hover from disabled nodes (specifically to handle edgecase — node disabled while hovered and LMB
277         // down)
278         // TODO: move focus away from disabled nodes into neighbors along with #8
279 
280         // Mouse is hovering an input node
281         if (auto hoverInput = cast(GluiFocusable) tree.hover) {
282 
283             // Pass the input to it
284             hoverInput.mouseImpl();
285 
286             // If the left mouse button is pressed down, let it have focus
287             if (mousePressed && !hoverInput.isFocused) hoverInput.focus();
288 
289         }
290 
291         // Mouse pressed over a non-focusable node, remove focus
292         else if (mousePressed) tree.focus = null;
293 
294 
295         // Pass keyboard input to the currently focused node
296         if (tree.focus && !tree.focus.isDisabled) tree.keyboardHandled = tree.focus.keyboardImpl();
297         else tree.keyboardHandled = false;
298 
299     }
300 
301     /// Draw this node at specified location.
302     final protected void draw(Rectangle space) @trusted {
303 
304         // Given "space" is the amount of space we're given and what we should use at max.
305         // Within this function, we deduce how much of the space we should actually use, and align the node
306         // within the space.
307 
308         import std.algorithm : all, min, max, either;
309 
310         assert(!toRemove, "A toRemove child wasn't removed from container.");
311 
312         // If hidden, don't draw anything
313         if (isHidden) return;
314 
315         const spaceV = Vector2(space.width, space.height);
316 
317         // No style set? Reload styles, the theme might've been set through CTFE
318         if (!style) reloadStyles();
319 
320         // Get parameters
321         const size = Vector2(
322             layout.nodeAlign[0] == NodeAlign.fill ? space.width  : min(space.width,  minSize.x),
323             layout.nodeAlign[1] == NodeAlign.fill ? space.height : min(space.height, minSize.y),
324         );
325         const position = position(space, size);
326 
327         // Calculate the boxes
328         const marginBox  = Rectangle(position.tupleof, size.tupleof);
329         const borderBox  = style ? style.cropBox(marginBox, style.margin)   : marginBox;
330         const paddingBox = style ? style.cropBox(borderBox, style.border)   : borderBox;
331         const contentBox = style ? style.cropBox(paddingBox, style.padding) : paddingBox;
332 
333         // If there's a border active, draw it
334         const currentStyle = pickStyle;
335         if (style && currentStyle && currentStyle.borderStyle) {
336 
337             currentStyle.borderStyle.apply(borderBox, style.border);
338 
339         }
340 
341         // Get the visible part of the padding box — so overflowed content doesn't get mouse focus
342         const visibleBox = tree.intersectScissors(paddingBox);
343 
344         // Check if hovered
345         _isHovered = hoveredImpl(visibleBox, GetMousePosition);
346 
347         // Check if the mouse stroke started this node
348         const heldElsewhere = !IsMouseButtonPressed(MouseButton.MOUSE_LEFT_BUTTON)
349             && isLMBHeld;
350 
351         // Update global hover unless mouse is being held down or mouse focus is disabled for this node
352         if (isHovered && !heldElsewhere && !ignoreMouse) tree.hover = this;
353 
354         assert(
355             [size.tupleof].all!isFinite,
356             format!"Node %s resulting size is invalid: %s; given space = %s, minSize = %s"(
357                 typeid(this), size, space, minSize
358             ),
359         );
360         assert(
361             [paddingBox.tupleof, contentBox.tupleof].all!isFinite,
362             format!"Node %s size is invalid: paddingBox = %s, contentBox = %s"(
363                 typeid(this), paddingBox, contentBox
364             )
365         );
366 
367         // Descending into a disabled tree
368         const incrementDisabled = isDisabled || tree.disabledDepth;
369 
370         // Count if disabled or not
371         if (incrementDisabled) tree.disabledDepth++;
372         scope (exit) if (incrementDisabled) tree.disabledDepth--;
373 
374         // Draw the node cropped
375         // Note: minSize includes margin!
376         if (minSize.x > space.width || minSize.y > space.height) {
377 
378             tree.pushScissors(paddingBox);
379             scope (exit) tree.popScissors();
380 
381             drawImpl(paddingBox, contentBox);
382 
383         }
384 
385         // Draw the node
386         else drawImpl(paddingBox, contentBox);
387 
388     }
389 
390     /// Recalculate the minimum node size and update the `minSize` property.
391     /// Params:
392     ///     tree  = The parent's tree to pass down to this node.
393     ///     theme = Theme to inherit from the parent.
394     ///     space = Available space.
395     protected final void resize(LayoutTree* tree, const Theme theme, Vector2 space)
396     in(tree, "Tree for Node.resize() must not be null.")
397     in(theme, "Theme for Node.resize() must not be null.")
398     do {
399 
400         // Inherit tree and theme
401         this.tree = tree;
402         if (this.theme is null) this.theme = theme;
403 
404 
405         // The node is hidden, reset size
406         if (isHidden) minSize = Vector2(0, 0);
407 
408         // Otherwise perform like normal
409         else {
410 
411             import std.range, std.algorithm;
412 
413             const fullMargin = style.fullMargin;
414             const spacingX = style ? chain(fullMargin.sideX[], style.padding.sideX[]).sum : 0;
415             const spacingY = style ? chain(fullMargin.sideY[], style.padding.sideY[]).sum : 0;
416 
417             // Reduce space by margins
418             space.x = max(0, space.x - spacingX);
419             space.y = max(0, space.y - spacingY);
420 
421             assert(
422                 space.x.isFinite && space.y.isFinite,
423                 format!"Internal error — Node %s was given infinite space: %s; spacing(x = %s, y = %s)"(typeid(this),
424                     space, spacingX, spacingY)
425             );
426 
427             // Resize the node
428             resizeImpl(space);
429 
430             assert(
431                 minSize.x.isFinite && minSize.y.isFinite,
432                 format!"Node %s resizeImpl requested infinite minSize: %s"(typeid(this), minSize)
433             );
434 
435             // Add margins
436             minSize.x = ceil(minSize.x + spacingX);
437             minSize.y = ceil(minSize.y + spacingY);
438 
439         }
440 
441         assert(
442             minSize.x.isFinite && minSize.y.isFinite,
443             format!"Internal error — Node %s returned invalid minSize %s"(typeid(this), minSize)
444         );
445 
446     }
447 
448     /// Ditto
449     ///
450     /// This is the implementation of resizing to be provided by children.
451     ///
452     /// If style margins/paddings are non-zero, they are automatically subtracted from space, so they are handled
453     /// automatically.
454     protected abstract void resizeImpl(Vector2 space);
455 
456     /// Draw this node.
457     ///
458     /// Note: Instead of directly accessing `style`, use `pickStyle` to enable temporarily changing styles as visual
459     /// feedback. `resize` should still use the normal style.
460     ///
461     /// Params:
462     ///     paddingBox = Area which should be used by the node. It should include styling elements such as background,
463     ///         but no content.
464     ///     contentBox = Area which should be filled with content of the node, such as child nodes, text, etc.
465     protected abstract void drawImpl(Rectangle paddingBox, Rectangle contentBox);
466 
467     /// Check if the node is hovered.
468     ///
469     /// This will be called right before drawImpl for each node in order to determine the which node should handle mouse
470     /// input.
471     ///
472     /// If your node fills the rectangle area its given in `drawImpl`, you may use `mixin ImplHoveredRect` to implement
473     /// this automatically.
474     ///
475     /// Params:
476     ///     rect          = Area the node should be drawn in, as provided by drawImpl.
477     ///     mousePosition = Current mouse position within the window.
478     protected abstract bool hoveredImpl(Rectangle rect, Vector2 mousePosition) const;
479 
480     protected mixin template ImplHoveredRect() {
481 
482         private import raylib : Rectangle, Vector2;
483 
484         protected override bool hoveredImpl(Rectangle rect, Vector2 mousePosition) const {
485 
486             import glui.utils : contains;
487 
488             return rect.contains(mousePosition);
489 
490         }
491 
492     }
493 
494     /// Get the current style.
495     protected abstract const(Style) pickStyle() const;
496 
497     /// Get the node's position in its space box.
498     private Vector2 position(Rectangle space, Vector2 usedSpace) const {
499 
500         float positionImpl(NodeAlign align_, lazy float spaceLeft) {
501 
502             with (NodeAlign)
503             final switch (align_) {
504 
505                 case start, fill: return 0;
506                 case center: return spaceLeft / 2;
507                 case end: return spaceLeft;
508 
509             }
510 
511         }
512 
513         return Vector2(
514             round(space.x + positionImpl(layout.nodeAlign[0], space.width  - usedSpace.x)),
515             round(space.y + positionImpl(layout.nodeAlign[1], space.height - usedSpace.y)),
516         );
517 
518     }
519 
520     private bool isLMBHeld() @trusted {
521 
522         const lmb = MouseButton.MOUSE_LEFT_BUTTON;
523         return IsMouseButtonDown(lmb) || IsMouseButtonReleased(lmb);
524 
525     }
526 
527     override string toString() const {
528 
529         return format!"%s(%s)"(typeid(this), layout);
530 
531     }
532 
533 }