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

orion-ecs / keen-eye / 20844606203

09 Jan 2026 07:29AM UTC coverage: 86.975% (+0.01%) from 86.962%
20844606203

Pull #874

github

web-flow
Merge a532eb7b5 into d0672d625
Pull Request #874: feat(ui): Implement image scale modes (Stretch, ScaleToFit, ScaleToFill, Tile, NineSlice)

9230 of 12506 branches covered (73.8%)

Branch coverage included in aggregate %.

547 of 585 new or added lines in 7 files covered. (93.5%)

11 existing lines in 6 files now uncovered.

159598 of 181606 relevant lines covered (87.88%)

1.0 hits per line

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

79.57
/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
83
                renderer2D.ClearClip();
×
84

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

89
                foreach (var (entity, _) in deferredOverlays)
×
90
                {
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
        {
140
            int depthScore = depth * 10000 + rect.LocalZIndex;
×
141
            deferredOverlays?.Add((entity, depthScore));
×
142
            CollectOverlayChildren(entity, depth + 1);
×
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
    {
194
        var children = World.GetChildren(parent);
×
195
        foreach (var child in children)
×
196
        {
197
            if (!World.Has<UIElement>(child) || !World.Has<UIRect>(child))
×
198
            {
199
                continue;
200
            }
201

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

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

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

217
            // Continue collecting grandchildren
218
            CollectOverlayChildren(child, depth + 1);
×
219
        }
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
    {
228
        if (renderer2D is null)
×
229
        {
230
            return;
×
231
        }
232

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

236
        RenderElementVisuals(entity, bounds);
×
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
        // Get texture dimensions
333
        int texWidth = image.Texture.Width;
1✔
334
        int texHeight = image.Texture.Height;
1✔
335

336
        // If texture has no dimensions, fall back to simple stretch
337
        if (texWidth <= 0 || texHeight <= 0)
1✔
338
        {
339
            RenderImageStretched(bounds, image);
1✔
340
            return;
1✔
341
        }
342

343
        // Get source region dimensions (either from SourceRect or full texture)
344
        var sourceRect = image.SourceRect;
1✔
345
        float srcWidth, srcHeight;
346

347
        if (sourceRect.Width > 0 && sourceRect.Height > 0)
1✔
348
        {
349
            // SourceRect is in normalized coordinates (0-1)
NEW
350
            srcWidth = sourceRect.Width * texWidth;
×
NEW
351
            srcHeight = sourceRect.Height * texHeight;
×
352
        }
353
        else
354
        {
355
            srcWidth = texWidth;
1✔
356
            srcHeight = texHeight;
1✔
357
        }
358

359
        // Apply scale mode
360
        switch (image.ScaleMode)
1✔
361
        {
362
            case ImageScaleMode.Stretch:
363
                if (image.PreserveAspect)
1✔
364
                {
365
                    RenderImageScaleToFit(bounds, image, srcWidth, srcHeight);
1✔
366
                }
367
                else
368
                {
369
                    RenderImageStretched(bounds, image);
1✔
370
                }
371
                break;
1✔
372

373
            case ImageScaleMode.ScaleToFit:
374
                RenderImageScaleToFit(bounds, image, srcWidth, srcHeight);
1✔
375
                break;
1✔
376

377
            case ImageScaleMode.ScaleToFill:
378
                RenderImageScaleToFill(bounds, image, srcWidth, srcHeight);
1✔
379
                break;
1✔
380

381
            case ImageScaleMode.Tile:
382
                RenderImageTiled(bounds, image, srcWidth, srcHeight);
1✔
383
                break;
1✔
384

385
            case ImageScaleMode.NineSlice:
386
                RenderImageNineSlice(bounds, image);
1✔
387
                break;
1✔
388

389
            default:
NEW
390
                RenderImageStretched(bounds, image);
×
391
                break;
392
        }
NEW
393
    }
×
394

395
    private void RenderImageStretched(Rectangle bounds, in UIImage image)
396
    {
397
        var sourceRect = image.SourceRect;
1✔
398
        if (sourceRect.Width <= 0 || sourceRect.Height <= 0)
1✔
399
        {
400
            sourceRect = new Rectangle(0, 0, 1, 1); // Normalized coordinates for full texture
1✔
401
        }
402

403
        renderer2D!.DrawTextureRegion(image.Texture, bounds, sourceRect, image.Tint);
1✔
404
    }
1✔
405

406
    private void RenderImageScaleToFit(Rectangle bounds, in UIImage image, float srcWidth, float srcHeight)
407
    {
408
        // Calculate uniform scale to fit within bounds (letterbox)
409
        float scaleX = bounds.Width / srcWidth;
1✔
410
        float scaleY = bounds.Height / srcHeight;
1✔
411
        float scale = Math.Min(scaleX, scaleY);
1✔
412

413
        float destWidth = srcWidth * scale;
1✔
414
        float destHeight = srcHeight * scale;
1✔
415

416
        // Center within bounds
417
        float destX = bounds.X + (bounds.Width - destWidth) / 2;
1✔
418
        float destY = bounds.Y + (bounds.Height - destHeight) / 2;
1✔
419

420
        var destRect = new Rectangle(destX, destY, destWidth, destHeight);
1✔
421
        var sourceRect = image.SourceRect;
1✔
422
        if (sourceRect.Width <= 0 || sourceRect.Height <= 0)
1✔
423
        {
424
            sourceRect = new Rectangle(0, 0, 1, 1);
1✔
425
        }
426

427
        renderer2D!.DrawTextureRegion(image.Texture, destRect, sourceRect, image.Tint);
1✔
428
    }
1✔
429

430
    private void RenderImageScaleToFill(Rectangle bounds, in UIImage image, float srcWidth, float srcHeight)
431
    {
432
        // Calculate uniform scale to fill bounds (may crop)
433
        float scaleX = bounds.Width / srcWidth;
1✔
434
        float scaleY = bounds.Height / srcHeight;
1✔
435
        float scale = Math.Max(scaleX, scaleY);
1✔
436

437
        // Calculate cropped source region (in normalized coordinates)
438
        float visibleWidth = bounds.Width / scale;
1✔
439
        float visibleHeight = bounds.Height / scale;
1✔
440

441
        // Center the crop
442
        float cropX = (srcWidth - visibleWidth) / 2;
1✔
443
        float cropY = (srcHeight - visibleHeight) / 2;
1✔
444

445
        // Convert to normalized coordinates
446
        int texWidth = image.Texture.Width;
1✔
447
        int texHeight = image.Texture.Height;
1✔
448

449
        Rectangle baseSource = image.SourceRect;
1✔
450
        if (baseSource.Width <= 0 || baseSource.Height <= 0)
1✔
451
        {
452
            baseSource = new Rectangle(0, 0, 1, 1);
1✔
453
        }
454

455
        // Apply crop within the source rect
456
        float sourceU = baseSource.X + (cropX / texWidth);
1✔
457
        float sourceV = baseSource.Y + (cropY / texHeight);
1✔
458
        float sourceW = visibleWidth / texWidth;
1✔
459
        float sourceH = visibleHeight / texHeight;
1✔
460

461
        var croppedSource = new Rectangle(sourceU, sourceV, sourceW, sourceH);
1✔
462
        renderer2D!.DrawTextureRegion(image.Texture, bounds, croppedSource, image.Tint);
1✔
463
    }
1✔
464

465
    private void RenderImageTiled(Rectangle bounds, in UIImage image, float srcWidth, float srcHeight)
466
    {
467
        var sourceRect = image.SourceRect;
1✔
468
        if (sourceRect.Width <= 0 || sourceRect.Height <= 0)
1✔
469
        {
470
            sourceRect = new Rectangle(0, 0, 1, 1);
1✔
471
        }
472

473
        // Calculate how many tiles we need
474
        int tilesX = (int)Math.Ceiling(bounds.Width / srcWidth);
1✔
475
        int tilesY = (int)Math.Ceiling(bounds.Height / srcHeight);
1✔
476

477
        for (int ty = 0; ty < tilesY; ty++)
1✔
478
        {
479
            for (int tx = 0; tx < tilesX; tx++)
1✔
480
            {
481
                float tileX = bounds.X + tx * srcWidth;
1✔
482
                float tileY = bounds.Y + ty * srcHeight;
1✔
483
                float tileW = srcWidth;
1✔
484
                float tileH = srcHeight;
1✔
485
                var tileSrc = sourceRect;
1✔
486

487
                // Clip the last column if it extends beyond bounds
488
                if (tileX + tileW > bounds.Right)
1✔
489
                {
490
                    float excess = (tileX + tileW) - bounds.Right;
1✔
491
                    float ratio = 1 - (excess / srcWidth);
1✔
492
                    tileW = bounds.Right - tileX;
1✔
493
                    tileSrc = new Rectangle(sourceRect.X, sourceRect.Y, sourceRect.Width * ratio, sourceRect.Height);
1✔
494
                }
495

496
                // Clip the last row if it extends beyond bounds
497
                if (tileY + tileH > bounds.Bottom)
1✔
498
                {
499
                    float excess = (tileY + tileH) - bounds.Bottom;
1✔
500
                    float ratio = 1 - (excess / srcHeight);
1✔
501
                    tileH = bounds.Bottom - tileY;
1✔
502
                    tileSrc = new Rectangle(tileSrc.X, tileSrc.Y, tileSrc.Width, sourceRect.Height * ratio);
1✔
503
                }
504

505
                var destRect = new Rectangle(tileX, tileY, tileW, tileH);
1✔
506
                renderer2D!.DrawTextureRegion(image.Texture, destRect, tileSrc, image.Tint);
1✔
507
            }
508
        }
509
    }
1✔
510

511
    private void RenderImageNineSlice(Rectangle bounds, in UIImage image)
512
    {
513
        var tex = image.Texture;
1✔
514
        var border = image.SliceBorder;
1✔
515
        float texW = tex.Width;
1✔
516
        float texH = tex.Height;
1✔
517

518
        // Handle case where texture has no dimensions
519
        if (texW <= 0 || texH <= 0)
1✔
520
        {
NEW
521
            RenderImageStretched(bounds, image);
×
NEW
522
            return;
×
523
        }
524

525
        // Destination border sizes (in screen pixels)
526
        float dLeft = border.Left;
1✔
527
        float dRight = border.Right;
1✔
528
        float dTop = border.Top;
1✔
529
        float dBottom = border.Bottom;
1✔
530

531
        // Clamp borders if destination is smaller than combined borders
532
        float totalHorizontal = dLeft + dRight;
1✔
533
        float totalVertical = dTop + dBottom;
1✔
534

535
        if (totalHorizontal > bounds.Width && totalHorizontal > 0)
1✔
536
        {
537
            float scale = bounds.Width / totalHorizontal;
1✔
538
            dLeft *= scale;
1✔
539
            dRight *= scale;
1✔
540
        }
541

542
        if (totalVertical > bounds.Height && totalVertical > 0)
1✔
543
        {
544
            float scale = bounds.Height / totalVertical;
1✔
545
            dTop *= scale;
1✔
546
            dBottom *= scale;
1✔
547
        }
548

549
        // Calculate destination positions
550
        float x0 = bounds.X;
1✔
551
        float x1 = bounds.X + dLeft;
1✔
552
        float x2 = bounds.Right - dRight;
1✔
553

554
        float y0 = bounds.Y;
1✔
555
        float y1 = bounds.Y + dTop;
1✔
556
        float y2 = bounds.Bottom - dBottom;
1✔
557

558
        // Normalized UV border sizes
559
        float uLeft = border.Left / texW;
1✔
560
        float uRight = border.Right / texW;
1✔
561
        float vTop = border.Top / texH;
1✔
562
        float vBottom = border.Bottom / texH;
1✔
563

564
        // UV coordinates
565
        float u0 = 0, u1 = uLeft, u2 = 1 - uRight, u3 = 1;
1✔
566
        float v0 = 0, v1 = vTop, v2 = 1 - vBottom, v3 = 1;
1✔
567

568
        // Define destination rectangles for all 9 regions
569
        var destRects = new Rectangle[9];
1✔
570
        var srcRects = new Rectangle[9];
1✔
571

572
        // Corners (fixed size, no stretch/tile)
573
        destRects[0] = new Rectangle(x0, y0, dLeft, dTop);           // Top-left
1✔
574
        srcRects[0] = new Rectangle(u0, v0, u1 - u0, v1 - v0);
1✔
575

576
        destRects[2] = new Rectangle(x2, y0, dRight, dTop);          // Top-right
1✔
577
        srcRects[2] = new Rectangle(u2, v0, u3 - u2, v1 - v0);
1✔
578

579
        destRects[6] = new Rectangle(x0, y2, dLeft, dBottom);        // Bottom-left
1✔
580
        srcRects[6] = new Rectangle(u0, v2, u1 - u0, v3 - v2);
1✔
581

582
        destRects[8] = new Rectangle(x2, y2, dRight, dBottom);       // Bottom-right
1✔
583
        srcRects[8] = new Rectangle(u2, v2, u3 - u2, v3 - v2);
1✔
584

585
        // Edges (stretch in one axis)
586
        destRects[1] = new Rectangle(x1, y0, x2 - x1, dTop);         // Top edge
1✔
587
        srcRects[1] = new Rectangle(u1, v0, u2 - u1, v1 - v0);
1✔
588

589
        destRects[7] = new Rectangle(x1, y2, x2 - x1, dBottom);      // Bottom edge
1✔
590
        srcRects[7] = new Rectangle(u1, v2, u2 - u1, v3 - v2);
1✔
591

592
        destRects[3] = new Rectangle(x0, y1, dLeft, y2 - y1);        // Left edge
1✔
593
        srcRects[3] = new Rectangle(u0, v1, u1 - u0, v2 - v1);
1✔
594

595
        destRects[5] = new Rectangle(x2, y1, dRight, y2 - y1);       // Right edge
1✔
596
        srcRects[5] = new Rectangle(u2, v1, u3 - u2, v2 - v1);
1✔
597

598
        // Center (stretch in both axes)
599
        destRects[4] = new Rectangle(x1, y1, x2 - x1, y2 - y1);      // Center
1✔
600
        srcRects[4] = new Rectangle(u1, v1, u2 - u1, v2 - v1);
1✔
601

602
        // Draw corners (always stretched, they're fixed size)
603
        int[] corners = [0, 2, 6, 8];
1✔
604
        foreach (int i in corners)
1✔
605
        {
606
            if (destRects[i].Width > 0 && destRects[i].Height > 0)
1✔
607
            {
608
                renderer2D!.DrawTextureRegion(image.Texture, destRects[i], srcRects[i], image.Tint);
1✔
609
            }
610
        }
611

612
        // Draw edges based on EdgeFillMode
613
        int[] edges = [1, 3, 5, 7];
1✔
614
        foreach (int i in edges)
1✔
615
        {
616
            if (destRects[i].Width > 0 && destRects[i].Height > 0)
1✔
617
            {
618
                if (image.EdgeFillMode == SlicedFillMode.Tile)
1✔
619
                {
NEW
620
                    RenderTiledRegion(destRects[i], srcRects[i], image, texW, texH, i);
×
621
                }
622
                else
623
                {
624
                    renderer2D!.DrawTextureRegion(image.Texture, destRects[i], srcRects[i], image.Tint);
1✔
625
                }
626
            }
627
        }
628

629
        // Draw center based on CenterFillMode
630
        if (destRects[4].Width > 0 && destRects[4].Height > 0)
1✔
631
        {
632
            if (image.CenterFillMode == SlicedFillMode.Tile)
1✔
633
            {
NEW
634
                RenderTiledRegion(destRects[4], srcRects[4], image, texW, texH, 4);
×
635
            }
636
            else
637
            {
638
                renderer2D!.DrawTextureRegion(image.Texture, destRects[4], srcRects[4], image.Tint);
1✔
639
            }
640
        }
641
    }
1✔
642

643
    private void RenderTiledRegion(Rectangle destRect, Rectangle srcRect, in UIImage image, float texW, float texH, int regionIndex)
644
    {
645
        // Calculate source region size in pixels
NEW
646
        float srcPixelW = srcRect.Width * texW;
×
NEW
647
        float srcPixelH = srcRect.Height * texH;
×
648

NEW
649
        if (srcPixelW <= 0 || srcPixelH <= 0)
×
650
        {
NEW
651
            return;
×
652
        }
653

654
        // For edge regions, only tile in one direction
NEW
655
        bool isHorizontalEdge = regionIndex == 1 || regionIndex == 7; // Top/bottom edges
×
NEW
656
        bool isVerticalEdge = regionIndex == 3 || regionIndex == 5;   // Left/right edges
×
657

NEW
658
        int tilesX = isVerticalEdge ? 1 : (int)Math.Ceiling(destRect.Width / srcPixelW);
×
NEW
659
        int tilesY = isHorizontalEdge ? 1 : (int)Math.Ceiling(destRect.Height / srcPixelH);
×
660

NEW
661
        for (int ty = 0; ty < tilesY; ty++)
×
662
        {
NEW
663
            for (int tx = 0; tx < tilesX; tx++)
×
664
            {
NEW
665
                float tileX = destRect.X + tx * srcPixelW;
×
NEW
666
                float tileY = destRect.Y + ty * srcPixelH;
×
NEW
667
                float tileW = isVerticalEdge ? destRect.Width : srcPixelW;
×
NEW
668
                float tileH = isHorizontalEdge ? destRect.Height : srcPixelH;
×
NEW
669
                var tileSrc = srcRect;
×
670

671
                // Clip if extends beyond dest rect
NEW
672
                if (tileX + tileW > destRect.Right)
×
673
                {
NEW
674
                    float excess = (tileX + tileW) - destRect.Right;
×
NEW
675
                    float ratio = 1 - (excess / srcPixelW);
×
NEW
676
                    tileW = destRect.Right - tileX;
×
NEW
677
                    tileSrc = new Rectangle(srcRect.X, srcRect.Y, srcRect.Width * ratio, srcRect.Height);
×
678
                }
679

NEW
680
                if (tileY + tileH > destRect.Bottom)
×
681
                {
NEW
682
                    float excess = (tileY + tileH) - destRect.Bottom;
×
NEW
683
                    float ratio = 1 - (excess / srcPixelH);
×
NEW
684
                    tileH = destRect.Bottom - tileY;
×
NEW
685
                    tileSrc = new Rectangle(tileSrc.X, tileSrc.Y, tileSrc.Width, srcRect.Height * ratio);
×
686
                }
687

NEW
688
                var dest = new Rectangle(tileX, tileY, tileW, tileH);
×
NEW
689
                renderer2D!.DrawTextureRegion(image.Texture, dest, tileSrc, image.Tint);
×
690
            }
691
        }
UNCOV
692
    }
×
693

694
    private void RenderText(Rectangle bounds, in UIText text)
695
    {
696
        if (textRenderer is null || string.IsNullOrEmpty(text.Content))
1✔
697
        {
698
            return;
1✔
699
        }
700

701
        // Flush 2D batch first to ensure backgrounds are drawn before text
702
        renderer2D?.Flush();
1✔
703

704
        // Calculate text position based on alignment
705
        float x = text.HorizontalAlign switch
1✔
706
        {
1✔
707
            TextAlignH.Center => bounds.X + bounds.Width / 2,
1✔
708
            TextAlignH.Right => bounds.X + bounds.Width,
1✔
709
            _ => bounds.X
1✔
710
        };
1✔
711

712
        float y = text.VerticalAlign switch
1✔
713
        {
1✔
714
            TextAlignV.Middle => bounds.Y + bounds.Height / 2,
1✔
715
            TextAlignV.Bottom => bounds.Y + bounds.Height,
1✔
716
            _ => bounds.Y
1✔
717
        };
1✔
718

719
        // Use word wrap or simple text rendering
720
        if (text.WordWrap)
1✔
721
        {
722
            textRenderer.DrawTextWrapped(
1✔
723
                text.Font, text.Content.AsSpan(), bounds, text.Color,
1✔
724
                text.HorizontalAlign, text.VerticalAlign);
1✔
725
        }
726
        else
727
        {
728
            textRenderer.DrawText(
1✔
729
                text.Font, text.Content.AsSpan(), x, y, text.Color,
1✔
730
                text.HorizontalAlign, text.VerticalAlign);
1✔
731
        }
732

733
        // Flush text immediately so it renders on top of background
734
        textRenderer.Flush();
1✔
735
    }
1✔
736

737
    private void RenderInteractionState(Rectangle bounds, in UIInteractable interactable)
738
    {
739
        if (renderer2D is null)
1✔
740
        {
741
            return;
×
742
        }
743

744
        // Draw subtle overlay for hover/pressed states
745
        if (interactable.IsPressed)
1✔
746
        {
747
            // Dark overlay when pressed
748
            renderer2D.FillRect(bounds, new Vector4(0, 0, 0, 0.2f));
1✔
749
        }
750
        else if (interactable.IsHovered)
1✔
751
        {
752
            // Light overlay when hovered
753
            renderer2D.FillRect(bounds, new Vector4(1, 1, 1, 0.1f));
1✔
754
        }
755
    }
1✔
756

757
    private void RenderFocusIndicator(Rectangle bounds)
758
    {
759
        if (renderer2D is null)
1✔
760
        {
761
            return;
×
762
        }
763

764
        // Draw focus outline
765
        const float focusOutlineWidth = 2f;
766
        var focusColor = new Vector4(0.3f, 0.6f, 1f, 0.8f); // Blue focus ring
1✔
767

768
        renderer2D.DrawRect(
1✔
769
            bounds.X - focusOutlineWidth,
1✔
770
            bounds.Y - focusOutlineWidth,
1✔
771
            bounds.Width + focusOutlineWidth * 2,
1✔
772
            bounds.Height + focusOutlineWidth * 2,
1✔
773
            focusColor,
1✔
774
            focusOutlineWidth);
1✔
775
    }
1✔
776
}
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