• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 20826178755

08 Jan 2026 05:43PM UTC coverage: 86.959% (-0.02%) from 86.977%
20826178755

Pull #860

github

web-flow
Merge af5d55318 into 0dcaa0ace
Pull Request #860: fix(ui): Fix menu and overlay rendering with two-pass system

9201 of 12466 branches covered (73.81%)

Branch coverage included in aggregate %.

7 of 32 new or added lines in 2 files covered. (21.88%)

7 existing lines in 4 files now uncovered.

159063 of 181033 relevant lines covered (87.86%)

1.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.52
/src/KeenEyes.UI/Systems/UIRenderSystem.cs
1
using System.Numerics;
2
using KeenEyes;
3
using KeenEyes.Graphics.Abstractions;
4
using KeenEyes.UI.Abstractions;
5

6
namespace KeenEyes.UI;
7

8
/// <summary>
9
/// System that renders UI elements using 2D rendering primitives.
10
/// </summary>
11
/// <remarks>
12
/// <para>
13
/// The render system traverses UI hierarchies depth-first starting from <see cref="UIRootTag"/>
14
/// entities and renders visible elements using <see cref="I2DRenderer"/> and
15
/// <see cref="ITextRenderer"/>.
16
/// </para>
17
/// <para>
18
/// Render order is determined by hierarchy depth multiplied by 10000 plus the element's
19
/// <see cref="UIRect.LocalZIndex"/>. This ensures children render on top of parents.
20
/// </para>
21
/// <para>
22
/// This system should run in <see cref="SystemPhase.Render"/> phase with a high order
23
/// to ensure UI renders on top of the game world.
24
/// </para>
25
/// </remarks>
26
public sealed class UIRenderSystem : SystemBase
27
{
28
    /// <summary>
29
    /// Elements with LocalZIndex at or above this threshold are rendered in a second pass
30
    /// on top of all other elements. Used for menus, tooltips, modals, etc.
31
    /// </summary>
32
    private const short OverlayZIndexThreshold = 1000;
33

34
    private I2DRenderer? renderer2D;
35
    private ITextRenderer? textRenderer;
36
    private List<(Entity Entity, int DepthScore)>? deferredOverlays;
37

38
    /// <inheritdoc />
39
    public override void Update(float deltaTime)
40
    {
41

42
        // Lazy initialization of renderers - keep trying until we find them
43
        // (renderer may not be available until window loads)
44
        if (renderer2D is null)
1✔
45
        {
46
            TryInitializeRenderers();
1✔
47
            if (renderer2D is null)
1✔
48
            {
49
                return;
1✔
50
            }
51
        }
52

53
        renderer2D.Begin();
1✔
54
        textRenderer?.Begin();
1✔
55

56
        try
57
        {
58
            // Initialize deferred overlay list for two-pass rendering
59
            deferredOverlays = [];
1✔
60

61
            // FIRST PASS: Render normal elements, collect overlay elements for later
62
            foreach (var rootEntity in World.Query<UIElement, UIRect, UIRootTag>())
1✔
63
            {
64
                ref readonly var element = ref World.Get<UIElement>(rootEntity);
1✔
65
                if (!element.Visible)
1✔
66
                {
67
                    continue;
68
                }
69

70
                if (World.Has<UIHiddenTag>(rootEntity))
1✔
71
                {
72
                    continue;
73
                }
74

75
                // Render root and children recursively
76
                RenderElement(rootEntity, 0);
1✔
77
            }
78

79
            // SECOND PASS: Render overlay elements (menus, tooltips, modals) on top
80
            if (deferredOverlays.Count > 0)
1✔
81
            {
82
                // Clear any active clipping so overlays can extend beyond parent bounds
NEW
83
                renderer2D.ClearClip();
×
84

85
                // Sort overlays by depth score to ensure correct stacking order
86
                // (parent overlays render before child overlays)
NEW
87
                deferredOverlays.Sort((a, b) => a.DepthScore.CompareTo(b.DepthScore));
×
88

NEW
89
                foreach (var (entity, _) in deferredOverlays)
×
90
                {
NEW
91
                    RenderOverlayElement(entity);
×
92
                }
93
            }
94
        }
1✔
95
        finally
96
        {
97
            deferredOverlays = null;
1✔
98
            textRenderer?.End();
1✔
99
            renderer2D.End();
1✔
100
        }
1✔
101
    }
1✔
102

103
    private void TryInitializeRenderers()
104
    {
105
        // Try getting I2DRenderer directly as extension first, fall back to provider
106
        if (!World.TryGetExtension(out renderer2D) &&
1✔
107
            World.TryGetExtension<I2DRendererProvider>(out var provider) &&
1✔
108
            provider is not null)
1✔
109
        {
110
            renderer2D = provider.Get2DRenderer();
1✔
111
        }
112

113
        // Try getting ITextRenderer directly as extension first, fall back to provider
114
        if (!World.TryGetExtension(out textRenderer) &&
1✔
115
            World.TryGetExtension<ITextRendererProvider>(out var textProvider) &&
1✔
116
            textProvider is not null)
1✔
117
        {
118
            textRenderer = textProvider.GetTextRenderer();
1✔
119
        }
120
    }
1✔
121

122
    private void RenderElement(Entity entity, int depth)
123
    {
124
        if (renderer2D is null)
1✔
125
        {
126
            return;
×
127
        }
128

129
        ref readonly var element = ref World.Get<UIElement>(entity);
1✔
130
        if (!element.Visible)
1✔
131
        {
132
            return;
1✔
133
        }
134

135
        ref readonly var rect = ref World.Get<UIRect>(entity);
1✔
136

137
        // Check if this element should be deferred to overlay pass
138
        if (rect.LocalZIndex >= OverlayZIndexThreshold)
1✔
139
        {
NEW
140
            int depthScore = depth * 10000 + rect.LocalZIndex;
×
NEW
141
            deferredOverlays?.Add((entity, depthScore));
×
NEW
142
            CollectOverlayChildren(entity, depth + 1);
×
NEW
143
            return; // Don't render now - will be rendered in overlay pass
×
144
        }
145

146
        var bounds = rect.ComputedBounds;
1✔
147

148
        // Push clip if element has clipping
149
        bool hasClip = World.Has<UIClipChildrenTag>(entity);
1✔
150
        if (hasClip)
1✔
151
        {
152
            renderer2D.PushClip(bounds);
1✔
153
        }
154

155
        // TODO: Implement scroll offset for scrollable containers
156
        // The scroll position would be used to translate child rendering
157
        _ = World.Has<UIScrollable>(entity)
1✔
158
            ? World.Get<UIScrollable>(entity).ScrollPosition
1✔
159
            : Vector2.Zero;
1✔
160

161
        // Render this element's visuals
162
        RenderElementVisuals(entity, bounds);
1✔
163

164
        // Render children - using IWorld.GetChildren
165
        var children = World.GetChildren(entity);
1✔
166
        foreach (var child in children)
1✔
167
        {
168
            if (!World.Has<UIElement>(child) || !World.Has<UIRect>(child))
1✔
169
            {
170
                continue;
171
            }
172

173
            if (World.Has<UIHiddenTag>(child))
1✔
174
            {
175
                continue;
176
            }
177

178
            RenderElement(child, depth + 1);
1✔
179
        }
180

181
        // Pop clip
182
        if (hasClip)
1✔
183
        {
184
            renderer2D.PopClip();
1✔
185
        }
186
    }
1✔
187

188
    /// <summary>
189
    /// Recursively collects all visible children of an overlay element.
190
    /// Each child is added with its own depth score for proper stacking order.
191
    /// </summary>
192
    private void CollectOverlayChildren(Entity parent, int depth)
193
    {
NEW
194
        var children = World.GetChildren(parent);
×
NEW
195
        foreach (var child in children)
×
196
        {
NEW
197
            if (!World.Has<UIElement>(child) || !World.Has<UIRect>(child))
×
198
            {
199
                continue;
200
            }
201

NEW
202
            if (World.Has<UIHiddenTag>(child))
×
203
            {
204
                continue;
205
            }
206

NEW
207
            ref readonly var element = ref World.Get<UIElement>(child);
×
NEW
208
            if (!element.Visible)
×
209
            {
210
                continue;
211
            }
212

NEW
213
            ref readonly var rect = ref World.Get<UIRect>(child);
×
NEW
214
            int depthScore = depth * 10000 + rect.LocalZIndex;
×
NEW
215
            deferredOverlays?.Add((child, depthScore));
×
216

217
            // Continue collecting grandchildren
NEW
218
            CollectOverlayChildren(child, depth + 1);
×
219
        }
NEW
220
    }
×
221

222
    /// <summary>
223
    /// Renders a single overlay element without clipping constraints.
224
    /// Called during the overlay pass after all normal elements have been rendered.
225
    /// </summary>
226
    private void RenderOverlayElement(Entity entity)
227
    {
NEW
228
        if (renderer2D is null)
×
229
        {
NEW
230
            return;
×
231
        }
232

NEW
233
        ref readonly var rect = ref World.Get<UIRect>(entity);
×
NEW
234
        var bounds = rect.ComputedBounds;
×
235

NEW
236
        RenderElementVisuals(entity, bounds);
×
NEW
237
    }
×
238

239
    private void RenderElementVisuals(Entity entity, Rectangle bounds)
240
    {
241
        if (renderer2D is null)
1✔
242
        {
243
            return;
×
244
        }
245

246
        // Render background style
247
        if (World.Has<UIStyle>(entity))
1✔
248
        {
249
            ref readonly var style = ref World.Get<UIStyle>(entity);
1✔
250
            RenderStyle(bounds, style);
1✔
251
        }
252

253
        // Render image
254
        if (World.Has<UIImage>(entity))
1✔
255
        {
256
            ref readonly var image = ref World.Get<UIImage>(entity);
1✔
257
            RenderImage(bounds, image);
1✔
258
        }
259

260
        // Render text
261
        if (World.Has<UIText>(entity) && textRenderer is not null)
1✔
262
        {
263
            ref readonly var text = ref World.Get<UIText>(entity);
1✔
264
            RenderText(bounds, text);
1✔
265
        }
266

267
        // Render interaction state overlay (hover/pressed effects)
268
        if (World.Has<UIInteractable>(entity))
1✔
269
        {
270
            ref readonly var interactable = ref World.Get<UIInteractable>(entity);
1✔
271
            RenderInteractionState(bounds, interactable);
1✔
272
        }
273

274
        // Render focus indicator
275
        if (World.Has<UIFocusedTag>(entity))
1✔
276
        {
277
            RenderFocusIndicator(bounds);
1✔
278
        }
279
    }
1✔
280

281
    private void RenderStyle(Rectangle bounds, in UIStyle style)
282
    {
283
        if (renderer2D is null)
1✔
284
        {
285
            return;
×
286
        }
287

288
        // Draw background
289
        if (style.BackgroundColor.W > 0)
1✔
290
        {
291
            if (style.CornerRadius > 0)
1✔
292
            {
293
                renderer2D.FillRoundedRect(
1✔
294
                    bounds.X, bounds.Y, bounds.Width, bounds.Height,
1✔
295
                    style.CornerRadius, style.BackgroundColor);
1✔
296
            }
297
            else
298
            {
299
                renderer2D.FillRect(bounds, style.BackgroundColor);
1✔
300
            }
301
        }
302

303
        // Draw background texture
304
        if (style.BackgroundTexture.IsValid)
1✔
305
        {
306
            renderer2D.DrawTexture(style.BackgroundTexture, bounds.X, bounds.Y, bounds.Width, bounds.Height);
1✔
307
        }
308

309
        // Draw border
310
        if (style.BorderWidth > 0 && style.BorderColor.W > 0)
1✔
311
        {
312
            if (style.CornerRadius > 0)
1✔
313
            {
314
                renderer2D.DrawRoundedRect(
1✔
315
                    bounds.X, bounds.Y, bounds.Width, bounds.Height,
1✔
316
                    style.CornerRadius, style.BorderColor, style.BorderWidth);
1✔
317
            }
318
            else
319
            {
320
                renderer2D.DrawRect(bounds, style.BorderColor, style.BorderWidth);
1✔
321
            }
322
        }
323
    }
1✔
324

325
    private void RenderImage(Rectangle bounds, in UIImage image)
326
    {
327
        if (renderer2D is null || !image.Texture.IsValid)
1✔
328
        {
329
            return;
1✔
330
        }
331

332
        var destRect = bounds;
1✔
333
        var sourceRect = image.SourceRect;
1✔
334

335
        // If no source rect specified, use whole texture
336
        if (sourceRect.Width <= 0 || sourceRect.Height <= 0)
1✔
337
        {
338
            sourceRect = new Rectangle(0, 0, 1, 1); // Normalized coordinates
1✔
339
        }
340

341
        // Apply scale mode
342
        // For now, just draw the texture - more sophisticated scale modes would
343
        // require knowing the texture dimensions
344
        renderer2D.DrawTextureRegion(image.Texture, destRect, sourceRect, image.Tint);
1✔
345
    }
1✔
346

347
    private void RenderText(Rectangle bounds, in UIText text)
348
    {
349
        if (textRenderer is null || string.IsNullOrEmpty(text.Content))
1✔
350
        {
351
            return;
1✔
352
        }
353

354
        // Flush 2D batch first to ensure backgrounds are drawn before text
355
        renderer2D?.Flush();
1✔
356

357
        // Calculate text position based on alignment
358
        float x = text.HorizontalAlign switch
1✔
359
        {
1✔
360
            TextAlignH.Center => bounds.X + bounds.Width / 2,
1✔
361
            TextAlignH.Right => bounds.X + bounds.Width,
1✔
362
            _ => bounds.X
1✔
363
        };
1✔
364

365
        float y = text.VerticalAlign switch
1✔
366
        {
1✔
367
            TextAlignV.Middle => bounds.Y + bounds.Height / 2,
1✔
368
            TextAlignV.Bottom => bounds.Y + bounds.Height,
1✔
369
            _ => bounds.Y
1✔
370
        };
1✔
371

372
        // Use word wrap or simple text rendering
373
        if (text.WordWrap)
1✔
374
        {
375
            textRenderer.DrawTextWrapped(
1✔
376
                text.Font, text.Content.AsSpan(), bounds, text.Color,
1✔
377
                text.HorizontalAlign, text.VerticalAlign);
1✔
378
        }
379
        else
380
        {
381
            textRenderer.DrawText(
1✔
382
                text.Font, text.Content.AsSpan(), x, y, text.Color,
1✔
383
                text.HorizontalAlign, text.VerticalAlign);
1✔
384
        }
385

386
        // Flush text immediately so it renders on top of background
387
        textRenderer.Flush();
1✔
388
    }
1✔
389

390
    private void RenderInteractionState(Rectangle bounds, in UIInteractable interactable)
391
    {
392
        if (renderer2D is null)
1✔
393
        {
394
            return;
×
395
        }
396

397
        // Draw subtle overlay for hover/pressed states
398
        if (interactable.IsPressed)
1✔
399
        {
400
            // Dark overlay when pressed
401
            renderer2D.FillRect(bounds, new Vector4(0, 0, 0, 0.2f));
1✔
402
        }
403
        else if (interactable.IsHovered)
1✔
404
        {
405
            // Light overlay when hovered
406
            renderer2D.FillRect(bounds, new Vector4(1, 1, 1, 0.1f));
1✔
407
        }
408
    }
1✔
409

410
    private void RenderFocusIndicator(Rectangle bounds)
411
    {
412
        if (renderer2D is null)
1✔
413
        {
414
            return;
×
415
        }
416

417
        // Draw focus outline
418
        const float focusOutlineWidth = 2f;
419
        var focusColor = new Vector4(0.3f, 0.6f, 1f, 0.8f); // Blue focus ring
1✔
420

421
        renderer2D.DrawRect(
1✔
422
            bounds.X - focusOutlineWidth,
1✔
423
            bounds.Y - focusOutlineWidth,
1✔
424
            bounds.Width + focusOutlineWidth * 2,
1✔
425
            bounds.Height + focusOutlineWidth * 2,
1✔
426
            focusColor,
1✔
427
            focusOutlineWidth);
1✔
428
    }
1✔
429
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc