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

orion-ecs / keen-eye / 20871273650

10 Jan 2026 02:23AM UTC coverage: 86.895% (-0.07%) from 86.962%
20871273650

push

github

tyevco
fix(ui): Fix UI widget tests for arrows, TabView visibility, and TextInput

- Use Unicode arrows (â–¼/â–¶) instead of ASCII (v/>) for accordion and tree view expand/collapse indicators
- Set visibility, UITabPanel, and UIHiddenTag on both scrollView and scrollContent in CreateTabView
- Fix UITextInput test assertions to expect ShowingPlaceholder=false when no placeholder text is provided

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

9264 of 12568 branches covered (73.71%)

Branch coverage included in aggregate %.

15 of 15 new or added lines in 6 files covered. (100.0%)

321 existing lines in 16 files now uncovered.

159741 of 181925 relevant lines covered (87.81%)

1.0 hits per line

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

96.3
/src/KeenEyes.UI/Systems/UIColorPickerSystem.cs
1
using System.Numerics;
2

3
using KeenEyes.UI.Abstractions;
4

5
namespace KeenEyes.UI;
6

7
/// <summary>
8
/// System that handles color picker interaction and color conversion.
9
/// </summary>
10
/// <remarks>
11
/// <para>
12
/// This system manages:
13
/// <list type="bullet">
14
/// <item>HSV color wheel/area interactions</item>
15
/// <item>RGB slider interactions</item>
16
/// <item>Alpha slider interactions</item>
17
/// <item>HSV to RGB and RGB to HSV conversions</item>
18
/// <item>Color preview updates</item>
19
/// </list>
20
/// </para>
21
/// </remarks>
22
public sealed class UIColorPickerSystem : SystemBase
23
{
24
    /// <inheritdoc />
25
    public override void Initialize(IWorld world)
26
    {
27
        base.Initialize(world);
1✔
28

29
        // Subscribe to drag events for saturation-value area
30
        world.Subscribe<UIDragEvent>(OnDrag);
1✔
31
        world.Subscribe<UIClickEvent>(OnClick);
1✔
32
    }
1✔
33

34
    /// <inheritdoc />
35
    public override void Update(float deltaTime)
36
    {
37
        // Most work is event-driven, but we can update visual state here if needed
38
    }
1✔
39

40
    private void OnClick(UIClickEvent evt)
41
    {
42
        // Handle sat-val area click
43
        if (World.Has<UIColorSatValArea>(evt.Element))
1✔
44
        {
45
            HandleSatValClick(evt.Element, evt.Position);
1✔
46
            return;
1✔
47
        }
48

49
        // Handle color slider click
50
        if (World.Has<UIColorSlider>(evt.Element))
1✔
51
        {
52
            HandleSliderClick(evt.Element, evt.Position);
1✔
53
        }
54
    }
1✔
55

56
    private void OnDrag(UIDragEvent evt)
57
    {
58
        // Handle sat-val area drag
59
        if (World.Has<UIColorSatValArea>(evt.Element))
1✔
60
        {
61
            HandleSatValClick(evt.Element, evt.Position);
1✔
62
            return;
1✔
63
        }
64

65
        // Handle color slider drag
66
        if (World.Has<UIColorSlider>(evt.Element))
×
67
        {
68
            HandleSliderClick(evt.Element, evt.Position);
×
69
        }
70
    }
×
71

72
    private void HandleSatValClick(Entity satValEntity, Vector2 position)
73
    {
74
        ref readonly var satValArea = ref World.Get<UIColorSatValArea>(satValEntity);
1✔
75
        var pickerEntity = satValArea.ColorPicker;
1✔
76

77
        if (!World.IsAlive(pickerEntity) || !World.Has<UIColorPicker>(pickerEntity))
1✔
78
        {
79
            return;
1✔
80
        }
81

82
        // Get the bounds of the sat-val area
83
        if (!World.Has<UIRect>(satValEntity))
1✔
84
        {
85
            return;
1✔
86
        }
87

88
        ref readonly var rect = ref World.Get<UIRect>(satValEntity);
1✔
89
        var bounds = rect.ComputedBounds;
1✔
90

91
        // Calculate saturation and value from position
92
        float saturation = Math.Clamp((position.X - bounds.X) / bounds.Width, 0f, 1f);
1✔
93
        float value = Math.Clamp(1f - (position.Y - bounds.Y) / bounds.Height, 0f, 1f);
1✔
94

95
        ref var picker = ref World.Get<UIColorPicker>(pickerEntity);
1✔
96
        var oldColor = picker.Color;
1✔
97

98
        picker.Saturation = saturation;
1✔
99
        picker.Value = value;
1✔
100
        picker.Color = HsvToRgb(picker.Hue, picker.Saturation, picker.Value, picker.Color.W);
1✔
101

102
        UpdatePreview(pickerEntity, ref picker);
1✔
103
        UpdateSliderVisuals(pickerEntity, ref picker);
1✔
104

105
        if (oldColor != picker.Color)
1✔
106
        {
107
            World.Send(new UIColorChangedEvent(pickerEntity, oldColor, picker.Color));
1✔
108
        }
109
    }
1✔
110

111
    private void HandleSliderClick(Entity sliderEntity, Vector2 position)
112
    {
113
        ref readonly var slider = ref World.Get<UIColorSlider>(sliderEntity);
1✔
114
        var pickerEntity = slider.ColorPicker;
1✔
115

116
        if (!World.IsAlive(pickerEntity) || !World.Has<UIColorPicker>(pickerEntity))
1✔
117
        {
118
            return;
1✔
119
        }
120

121
        // Get the bounds of the slider
122
        if (!World.Has<UIRect>(sliderEntity))
1✔
123
        {
124
            return;
1✔
125
        }
126

127
        ref readonly var rect = ref World.Get<UIRect>(sliderEntity);
1✔
128
        var bounds = rect.ComputedBounds;
1✔
129

130
        // Calculate value from position (horizontal slider assumed)
131
        float normalizedValue = Math.Clamp((position.X - bounds.X) / bounds.Width, 0f, 1f);
1✔
132

133
        ref var picker = ref World.Get<UIColorPicker>(pickerEntity);
1✔
134
        var oldColor = picker.Color;
1✔
135

136
        switch (slider.Channel)
1✔
137
        {
138
            case ColorChannel.Hue:
139
                picker.Hue = normalizedValue * 360f;
1✔
140
                picker.Color = HsvToRgb(picker.Hue, picker.Saturation, picker.Value, picker.Color.W);
1✔
141
                break;
1✔
142

143
            case ColorChannel.Alpha:
144
                picker.Color = new Vector4(picker.Color.X, picker.Color.Y, picker.Color.Z, normalizedValue);
1✔
145
                break;
1✔
146

147
            case ColorChannel.Red:
148
                picker.Color = new Vector4(normalizedValue, picker.Color.Y, picker.Color.Z, picker.Color.W);
1✔
149
                UpdateHsvFromRgb(ref picker);
1✔
150
                break;
1✔
151

152
            case ColorChannel.Green:
153
                picker.Color = new Vector4(picker.Color.X, normalizedValue, picker.Color.Z, picker.Color.W);
1✔
154
                UpdateHsvFromRgb(ref picker);
1✔
155
                break;
1✔
156

157
            case ColorChannel.Blue:
158
                picker.Color = new Vector4(picker.Color.X, picker.Color.Y, normalizedValue, picker.Color.W);
1✔
159
                UpdateHsvFromRgb(ref picker);
1✔
160
                break;
161
        }
162

163
        UpdatePreview(pickerEntity, ref picker);
1✔
164
        UpdateSliderVisuals(pickerEntity, ref picker);
1✔
165

166
        if (oldColor != picker.Color)
1✔
167
        {
168
            World.Send(new UIColorChangedEvent(pickerEntity, oldColor, picker.Color));
1✔
169
        }
170
    }
1✔
171

172
    private void UpdatePreview(Entity pickerEntity, ref UIColorPicker picker)
173
    {
174
        if (World.IsAlive(picker.PreviewEntity) && World.Has<UIStyle>(picker.PreviewEntity))
1✔
175
        {
176
            ref var style = ref World.Get<UIStyle>(picker.PreviewEntity);
1✔
177
            style.BackgroundColor = picker.Color;
1✔
178
        }
179
    }
1✔
180

181
    private void UpdateSliderVisuals(Entity pickerEntity, ref UIColorPicker picker)
182
    {
183
        // Update the saturation-value area background color when hue changes
184
        // The background should show the pure hue at max saturation/value
185
        foreach (var satValEntity in World.Query<UIColorSatValArea>())
1✔
186
        {
187
            ref readonly var satValArea = ref World.Get<UIColorSatValArea>(satValEntity);
1✔
188
            if (satValArea.ColorPicker != pickerEntity)
1✔
189
            {
190
                continue;
191
            }
192

193
            if (World.Has<UIStyle>(satValEntity))
1✔
194
            {
UNCOV
195
                ref var style = ref World.Get<UIStyle>(satValEntity);
×
196
                // Background shows pure hue at full saturation and value
UNCOV
197
                style.BackgroundColor = HsvToRgb(picker.Hue, 1f, 1f);
×
198
            }
199

UNCOV
200
            break;
×
201
        }
202

203
        // Note: Slider thumb positioning would require thumb entities to be created
204
        // and tracked in the widget factory. Currently sliders are simple track
205
        // backgrounds without explicit thumb children.
206
    }
1✔
207

208
    private static void UpdateHsvFromRgb(ref UIColorPicker picker)
209
    {
210
        picker.Hue = RgbToHue(picker.Color);
1✔
211
        picker.Saturation = RgbToSaturation(picker.Color);
1✔
212
        picker.Value = RgbToValue(picker.Color);
1✔
213
    }
1✔
214

215
    /// <summary>
216
    /// Sets the color of a color picker.
217
    /// </summary>
218
    /// <param name="entity">The color picker entity.</param>
219
    /// <param name="color">The new color in RGBA format.</param>
220
    public void SetColor(Entity entity, Vector4 color)
221
    {
222
        if (!World.IsAlive(entity) || !World.Has<UIColorPicker>(entity))
1✔
223
        {
224
            return;
1✔
225
        }
226

227
        ref var picker = ref World.Get<UIColorPicker>(entity);
1✔
228
        var oldColor = picker.Color;
1✔
229

230
        picker.Color = color;
1✔
231
        UpdateHsvFromRgb(ref picker);
1✔
232
        UpdatePreview(entity, ref picker);
1✔
233

234
        if (oldColor != color)
1✔
235
        {
236
            World.Send(new UIColorChangedEvent(entity, oldColor, color));
1✔
237
        }
238
    }
1✔
239

240
    /// <summary>
241
    /// Gets the current color from a color picker.
242
    /// </summary>
243
    /// <param name="entity">The color picker entity.</param>
244
    /// <returns>The current color in RGBA format, or transparent black if invalid.</returns>
245
    public Vector4 GetColor(Entity entity)
246
    {
247
        if (!World.IsAlive(entity) || !World.Has<UIColorPicker>(entity))
1✔
248
        {
249
            return Vector4.Zero;
1✔
250
        }
251

252
        return World.Get<UIColorPicker>(entity).Color;
1✔
253
    }
254

255
    /// <summary>
256
    /// Converts HSV color to RGB.
257
    /// </summary>
258
    /// <param name="hue">Hue in degrees (0-360).</param>
259
    /// <param name="saturation">Saturation (0-1).</param>
260
    /// <param name="value">Value/brightness (0-1).</param>
261
    /// <param name="alpha">Alpha (0-1).</param>
262
    /// <returns>The RGBA color.</returns>
263
    public static Vector4 HsvToRgb(float hue, float saturation, float value, float alpha = 1f)
264
    {
265
        float c = value * saturation;
1✔
266
        float x = c * (1 - MathF.Abs((hue / 60f % 2) - 1));
1✔
267
        float m = value - c;
1✔
268

269
        float r, g, b;
270

271
        if (hue < 60)
1✔
272
        {
273
            r = c; g = x; b = 0;
1✔
274
        }
275
        else if (hue < 120)
1✔
276
        {
277
            r = x; g = c; b = 0;
1✔
278
        }
279
        else if (hue < 180)
1✔
280
        {
281
            r = 0; g = c; b = x;
1✔
282
        }
283
        else if (hue < 240)
1✔
284
        {
285
            r = 0; g = x; b = c;
1✔
286
        }
287
        else if (hue < 300)
1✔
288
        {
289
            r = x; g = 0; b = c;
1✔
290
        }
291
        else
292
        {
293
            r = c; g = 0; b = x;
1✔
294
        }
295

296
        return new Vector4(r + m, g + m, b + m, alpha);
1✔
297
    }
298

299
    /// <summary>
300
    /// Converts RGB color to hue (0-360).
301
    /// </summary>
302
    public static float RgbToHue(Vector4 color)
303
    {
304
        float r = color.X;
1✔
305
        float g = color.Y;
1✔
306
        float b = color.Z;
1✔
307

308
        float max = MathF.Max(r, MathF.Max(g, b));
1✔
309
        float min = MathF.Min(r, MathF.Min(g, b));
1✔
310
        float delta = max - min;
1✔
311

312
        if (delta < 0.0001f)
1✔
313
        {
314
            return 0f;
1✔
315
        }
316

317
        float hue;
318
        if (MathF.Abs(max - r) < 0.0001f)
1✔
319
        {
320
            hue = 60f * (((g - b) / delta) % 6);
1✔
321
        }
322
        else if (MathF.Abs(max - g) < 0.0001f)
1✔
323
        {
324
            hue = 60f * (((b - r) / delta) + 2);
1✔
325
        }
326
        else
327
        {
328
            hue = 60f * (((r - g) / delta) + 4);
1✔
329
        }
330

331
        if (hue < 0)
1✔
332
        {
333
            hue += 360f;
1✔
334
        }
335

336
        return hue;
1✔
337
    }
338

339
    /// <summary>
340
    /// Converts RGB color to saturation (0-1).
341
    /// </summary>
342
    public static float RgbToSaturation(Vector4 color)
343
    {
344
        float max = MathF.Max(color.X, MathF.Max(color.Y, color.Z));
1✔
345
        float min = MathF.Min(color.X, MathF.Min(color.Y, color.Z));
1✔
346

347
        if (max < 0.0001f)
1✔
348
        {
349
            return 0f;
1✔
350
        }
351

352
        return (max - min) / max;
1✔
353
    }
354

355
    /// <summary>
356
    /// Converts RGB color to value/brightness (0-1).
357
    /// </summary>
358
    public static float RgbToValue(Vector4 color)
359
    {
360
        return MathF.Max(color.X, MathF.Max(color.Y, color.Z));
1✔
361
    }
362

363
    /// <summary>
364
    /// Converts a color to hex string format (e.g., "#FF0000" or "#FF0000FF" with alpha).
365
    /// </summary>
366
    /// <param name="color">The color to convert.</param>
367
    /// <param name="includeAlpha">Whether to include the alpha channel.</param>
368
    /// <returns>The hex color string.</returns>
369
    public static string ColorToHex(Vector4 color, bool includeAlpha = false)
370
    {
371
        int r = (int)(color.X * 255);
1✔
372
        int g = (int)(color.Y * 255);
1✔
373
        int b = (int)(color.Z * 255);
1✔
374
        int a = (int)(color.W * 255);
1✔
375

376
        if (includeAlpha)
1✔
377
        {
378
            return $"#{r:X2}{g:X2}{b:X2}{a:X2}";
1✔
379
        }
380
        else
381
        {
382
            return $"#{r:X2}{g:X2}{b:X2}";
1✔
383
        }
384
    }
385

386
    /// <summary>
387
    /// Parses a hex color string to a Vector4 color.
388
    /// </summary>
389
    /// <param name="hex">The hex string (with or without #, 6 or 8 characters).</param>
390
    /// <param name="color">The parsed color.</param>
391
    /// <returns>True if parsing succeeded.</returns>
392
    public static bool TryParseHex(string hex, out Vector4 color)
393
    {
394
        color = Vector4.Zero;
1✔
395

396
        if (string.IsNullOrEmpty(hex))
1✔
397
        {
398
            return false;
1✔
399
        }
400

401
        // Remove # prefix if present
402
        if (hex.StartsWith('#'))
1✔
403
        {
404
            hex = hex[1..];
1✔
405
        }
406

407
        // Validate length
408
        if (hex.Length != 6 && hex.Length != 8)
1✔
409
        {
410
            return false;
1✔
411
        }
412

413
        try
414
        {
415
            int r = Convert.ToInt32(hex[..2], 16);
1✔
416
            int g = Convert.ToInt32(hex[2..4], 16);
1✔
417
            int b = Convert.ToInt32(hex[4..6], 16);
1✔
418
            int a = hex.Length == 8 ? Convert.ToInt32(hex[6..8], 16) : 255;
1✔
419

420
            color = new Vector4(r / 255f, g / 255f, b / 255f, a / 255f);
1✔
421
            return true;
1✔
422
        }
423
        catch
1✔
424
        {
425
            return false;
1✔
426
        }
427
    }
1✔
428
}
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