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 }