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

orion-ecs / keen-eye / 30113831081

24 Jul 2026 05:39PM UTC coverage: 64.224% (-0.07%) from 64.291%
30113831081

push

github

tyevco
fix(audio): Fix non-looping replay, WAV validation, paused-source recycling, double master volume (#1185,#1186,#1188,#1189)

- #1185: AudioSourceSystem now restarts a stopped backend source when State is set back to Playing (distinguishes a replay request from a natural finish via CurrentSound validity), so non-looping sources are replayable per the AudioSource.State contract.
- #1186: WavDecoder validates each chunk size against the remaining bytes and throws AudioLoadException on negative/oversized sizes, preventing the ArgumentOutOfRangeException slice crash and the negative-size infinite loop on untrusted files.
- #1188: SourcePool.Update only recycles Stopped sources (Paused treated as still-active), so Pause/PauseAll no longer kills pooled one-shots on the next frame.
- #1189: SilkAudioContext applies master + Master-channel volume only via the listener gain; ComputeEffectiveVolume no longer re-multiplies them into the per-source gain, eliminating the squared master volume and the new-vs-playing inconsistency.

Adds KeenEyes.Audio.Tests (AudioSourceSystem replay/finish, via fake IAudioDevice/IAudioContext) and KeenEyes.Audio.Silk.Tests (WavDecoder malformed-input validation). #1188/#1189 are OpenAL-device-bound and verified by code trace.

Closes #1185
Closes #1186
Closes #1188
Closes #1189

8612 of 12731 branches covered (67.65%)

Branch coverage included in aggregate %.

10 of 15 new or added lines in 4 files covered. (66.67%)

368 existing lines in 26 files now uncovered.

51312 of 80574 relevant lines covered (63.68%)

0.99 hits per line

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

68.46
/src/KeenEyes.UI/Systems/UIInputSystem.cs
1
using System.Numerics;
2
using KeenEyes.Common;
3
using KeenEyes.Input.Abstractions;
4
using KeenEyes.UI.Abstractions;
5

6
namespace KeenEyes.UI;
7

8
/// <summary>
9
/// System that processes input events and updates UI interaction states.
10
/// </summary>
11
/// <remarks>
12
/// <para>
13
/// The input system performs hit testing to determine which UI element is under the cursor,
14
/// updates hover/pressed states on <see cref="UIInteractable"/> components, and fires
15
/// appropriate events.
16
/// </para>
17
/// <para>
18
/// This system should run in <see cref="SystemPhase.EarlyUpdate"/> phase to process
19
/// input before other systems.
20
/// </para>
21
/// </remarks>
22
public sealed class UIInputSystem : SystemBase
23
{
24
    private UIHitTester? hitTester;
25
    private UIContext? uiContext;
26
    private IInputContext? inputContext;
27
    private Entity hoveredEntity = Entity.Null;
1✔
28
    private Entity pressedEntity = Entity.Null;
1✔
29
    private Vector2 dragStartPosition;
30
    private Vector2 lastDragPosition;
31
    private bool isDragging;
32
    private double lastClickTime;
33
    private const double DoubleClickTime = 0.3; // seconds
34
    private bool scrollSubscribed;
35

36
    /// <inheritdoc />
37
    protected override void OnInitialize()
38
    {
39
        hitTester = new UIHitTester(World);
1✔
40
    }
1✔
41

42
    /// <inheritdoc />
43
    protected override void OnBeforeUpdate(float deltaTime)
44
    {
45
        // Clear pending events from previous frame
46
        foreach (var entity in World.Query<UIInteractable>())
×
47
        {
48
            ref var interactable = ref World.Get<UIInteractable>(entity);
×
49
            interactable.PendingEvents = UIEventType.None;
×
50
        }
51
    }
×
52

53
    /// <inheritdoc />
54
    public override void Update(float deltaTime)
55
    {
56
        // Lazy initialization
57
        if (inputContext is null && !World.TryGetExtension<IInputContext>(out inputContext))
1✔
58
        {
59
            return;
1✔
60
        }
61

62
        if (uiContext is null && !World.TryGetExtension<UIContext>(out uiContext))
1✔
63
        {
64
            return;
1✔
65
        }
66

67
        if (hitTester is null)
1✔
68
        {
69
            return;
×
70
        }
71

72
        // Local copies for null safety - fields are verified non-null above
73
        var input = inputContext!;
1✔
74
        var mouse = input.Mouse;
1✔
75
        var mousePos = mouse.Position;
1✔
76

77
        // Subscribe to scroll events once we have input context
78
        if (!scrollSubscribed)
1✔
79
        {
80
            mouse.OnScroll += OnMouseScroll;
1✔
81
            scrollSubscribed = true;
1✔
82
        }
83

84
        // Hit test
85
        var hitEntity = hitTester.HitTest(mousePos);
1✔
86

87
        // Handle hover changes
88
        ProcessHover(hitEntity, mousePos);
1✔
89

90
        // Handle mouse button input
91
        ProcessMouseInput(mouse, mousePos, hitEntity);
1✔
92
    }
1✔
93

94
    private void ProcessHover(Entity hitEntity, Vector2 mousePos)
95
    {
96
        // Hover exit
97
        if (hoveredEntity.IsValid &&
1✔
98
            hoveredEntity != hitEntity &&
1✔
99
            World.IsAlive(hoveredEntity) &&
1✔
100
            World.Has<UIInteractable>(hoveredEntity))
1✔
101
        {
102
            ref var interactable = ref World.Get<UIInteractable>(hoveredEntity);
1✔
103
            interactable.State &= ~UIInteractionState.Hovered;
1✔
104
            interactable.PendingEvents |= UIEventType.PointerExit;
1✔
105

106
            World.Send(new UIPointerExitEvent(hoveredEntity));
1✔
107
        }
108

109
        // Hover enter
110
        if (hitEntity.IsValid &&
1✔
111
            hitEntity != hoveredEntity &&
1✔
112
            World.Has<UIInteractable>(hitEntity))
1✔
113
        {
114
            ref var interactable = ref World.Get<UIInteractable>(hitEntity);
1✔
115
            interactable.State |= UIInteractionState.Hovered;
1✔
116
            interactable.PendingEvents |= UIEventType.PointerEnter;
1✔
117

118
            World.Send(new UIPointerEnterEvent(hitEntity, mousePos));
1✔
119
        }
120

121
        hoveredEntity = hitEntity;
1✔
122
    }
1✔
123

124
    private void ProcessMouseInput(IMouse mouse, Vector2 mousePos, Entity hitEntity)
125
    {
126
        // Check for mouse button down
127
        if (mouse.IsButtonDown(MouseButton.Left))
1✔
128
        {
129
            if (!pressedEntity.IsValid && hitEntity.IsValid && World.Has<UIInteractable>(hitEntity))
1✔
130
            {
131
                ref var interactable = ref World.Get<UIInteractable>(hitEntity);
1✔
132

133
                if (interactable.CanClick || interactable.CanDrag)
1✔
134
                {
135
                    pressedEntity = hitEntity;
1✔
136
                    interactable.State |= UIInteractionState.Pressed;
1✔
137
                    interactable.PendingEvents |= UIEventType.PointerDown;
1✔
138
                    dragStartPosition = mousePos;
1✔
139
                    lastDragPosition = mousePos;
1✔
140

141
                    // Request focus if element is focusable
142
                    if (interactable.CanFocus && uiContext is not null)
1✔
143
                    {
144
                        uiContext.RequestFocus(hitEntity);
1✔
145
                    }
146
                }
147
            }
148

149
            // Handle dragging
150
            if (pressedEntity.IsValid && World.Has<UIInteractable>(pressedEntity))
1✔
151
            {
152
                ref var interactable = ref World.Get<UIInteractable>(pressedEntity);
1✔
153

154
                if (interactable.CanDrag && !isDragging)
1✔
155
                {
156
                    // Start drag if moved enough
157
                    var delta = mousePos - dragStartPosition;
1✔
158
                    if (delta.LengthSquared() > 25) // 5 pixel threshold squared
1✔
159
                    {
160
                        isDragging = true;
1✔
161
                        interactable.State |= UIInteractionState.Dragging;
1✔
162
                        interactable.PendingEvents |= UIEventType.DragStart;
1✔
163

164
                        World.Send(new UIDragStartEvent(pressedEntity, dragStartPosition));
1✔
165
                    }
166
                }
167

168
                if (isDragging)
1✔
169
                {
170
                    var delta = mousePos - lastDragPosition;
1✔
171
                    lastDragPosition = mousePos;
1✔
172
                    World.Send(new UIDragEvent(pressedEntity, mousePos, delta));
1✔
173
                }
174
            }
175
        }
176
        else
177
        {
178
            // Mouse button released
179
            if (pressedEntity.IsValid && World.IsAlive(pressedEntity) && World.Has<UIInteractable>(pressedEntity))
1✔
180
            {
181
                ref var interactable = ref World.Get<UIInteractable>(pressedEntity);
1✔
182
                interactable.State &= ~UIInteractionState.Pressed;
1✔
183
                interactable.PendingEvents |= UIEventType.PointerUp;
1✔
184

185
                if (isDragging)
1✔
186
                {
187
                    // End drag
188
                    interactable.State &= ~UIInteractionState.Dragging;
1✔
189
                    interactable.PendingEvents |= UIEventType.DragEnd;
1✔
190

191
                    World.Send(new UIDragEndEvent(pressedEntity, mousePos));
1✔
192
                }
193
                else if (interactable.CanClick && pressedEntity == hitEntity)
1✔
194
                {
195
                    // Click occurred
196
                    interactable.PendingEvents |= UIEventType.Click;
1✔
197

198
                    // Check for double-click
199
                    var currentTime = Environment.TickCount / 1000.0;
1✔
200
                    if (currentTime - lastClickTime < DoubleClickTime)
1✔
201
                    {
202
                        interactable.PendingEvents |= UIEventType.DoubleClick;
1✔
203
                    }
204
                    lastClickTime = currentTime;
1✔
205

206
                    World.Send(new UIClickEvent(pressedEntity, mousePos, MouseButton.Left));
1✔
207
                }
208
            }
209

210
            // Reset drag/press state unconditionally. If the pressed entity died
211
            // mid-drag, the block above is skipped, but the drag flag must still be
212
            // cleared or it leaks into the next press and swallows the next click by
213
            // routing the release down the DragEnd path instead of Click (see #1196).
214
            pressedEntity = Entity.Null;
1✔
215
            isDragging = false;
1✔
216
        }
217
    }
1✔
218

219
    private void OnMouseScroll(MouseScrollEventArgs args)
220
    {
UNCOV
221
        if (hitTester is null)
×
222
        {
223
            return;
×
224
        }
225

226
        // Hit test at the scroll position
227
        var hitEntity = hitTester.HitTest(args.Position);
×
228

229
        // Walk up hierarchy to find nearest UIScrollable
UNCOV
230
        var scrollableEntity = FindScrollable(hitEntity);
×
UNCOV
231
        if (!scrollableEntity.IsValid)
×
232
        {
UNCOV
233
            return;
×
234
        }
235

236
        ref var scrollable = ref World.Get<UIScrollable>(scrollableEntity);
×
237

238
        // Get viewport size for clamping
239
        Vector2 viewportSize = Vector2.Zero;
×
UNCOV
240
        if (World.Has<UIRect>(scrollableEntity))
×
241
        {
242
            ref readonly var rect = ref World.Get<UIRect>(scrollableEntity);
×
243
            viewportSize = rect.ComputedBounds.Size;
×
244
        }
245

246
        var maxScroll = scrollable.GetMaxScroll(viewportSize);
×
UNCOV
247
        var sensitivity = scrollable.ScrollSensitivity > 0 ? scrollable.ScrollSensitivity : 20f;
×
248

249
        // Apply scroll delta (negative deltaY = scroll down = increase scroll position)
250
        if (scrollable.VerticalScroll && !args.DeltaY.IsApproximatelyZero())
×
251
        {
UNCOV
252
            var newY = scrollable.ScrollPosition.Y - args.DeltaY * sensitivity;
×
UNCOV
253
            scrollable.ScrollPosition = new Vector2(
×
254
                scrollable.ScrollPosition.X,
×
UNCOV
255
                Math.Clamp(newY, 0, maxScroll.Y));
×
256
        }
257

258
        if (scrollable.HorizontalScroll && !args.DeltaX.IsApproximatelyZero())
×
259
        {
UNCOV
260
            var newX = scrollable.ScrollPosition.X - args.DeltaX * sensitivity;
×
261
            scrollable.ScrollPosition = new Vector2(
×
UNCOV
262
                Math.Clamp(newX, 0, maxScroll.X),
×
UNCOV
263
                scrollable.ScrollPosition.Y);
×
264
        }
265
    }
×
266

267
    private Entity FindScrollable(Entity entity)
268
    {
UNCOV
269
        var current = entity;
×
270
        while (current.IsValid && World.IsAlive(current))
×
271
        {
UNCOV
272
            if (World.Has<UIScrollable>(current))
×
273
            {
UNCOV
274
                ref readonly var scrollable = ref World.Get<UIScrollable>(current);
×
UNCOV
275
                if (scrollable.VerticalScroll || scrollable.HorizontalScroll)
×
276
                {
277
                    return current;
×
278
                }
279
            }
280

UNCOV
281
            current = World.GetParent(current);
×
282
        }
283

UNCOV
284
        return Entity.Null;
×
285
    }
286

287
    /// <inheritdoc />
288
    protected override void Dispose(bool disposing)
289
    {
290
        if (disposing && scrollSubscribed && inputContext is not null)
1✔
291
        {
292
            inputContext.Mouse.OnScroll -= OnMouseScroll;
1✔
293
            scrollSubscribed = false;
1✔
294
        }
295

296
        base.Dispose(disposing);
1✔
297
    }
1✔
298
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc