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