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

KSP-CKAN / CKAN / 28623334872

02 Jul 2026 09:40PM UTC coverage: 87.242% (-0.6%) from 87.84%
28623334872

Pull #4687

github

web-flow
Merge 21f0d1a8f into 0daeea8d7
Pull Request #4687: Revert starmap integration

2037 of 2177 branches covered (93.57%)

Branch coverage included in aggregate %.

155 of 210 new or added lines in 11 files covered. (73.81%)

45 existing lines in 8 files now uncovered.

8747 of 10184 relevant lines covered (85.89%)

0.9 hits per line

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

92.59
/GUI/Util.cs
1
using System;
2
using System.Collections.Generic;
3
using System.Collections.Concurrent;
4
using System.Linq;
5
using System.Drawing;
6
using System.Windows.Forms;
7
using System.Runtime.InteropServices;
8
using System.Diagnostics;
9
using System.Diagnostics.CodeAnalysis;
10
using Timer = System.Timers.Timer;
11
#if NET5_0_OR_GREATER
12
using System.Runtime.Versioning;
13
#endif
14

15
using log4net;
16

17
namespace CKAN.GUI
18
{
19
    #if NET6_0_OR_GREATER
20
    using WinReg = Microsoft.Win32.Registry;
21
    #endif
22

23
    #if NET5_0_OR_GREATER
24
    [SupportedOSPlatform("windows")]
25
    #endif
26
    public static class Util
27
    {
28
        #region Threading
29

30
        /// <summary>
31
        /// Invokes an action on the UI thread, or directly if we're
32
        /// on the UI thread.
33
        /// </summary>
34
        [ExcludeFromCodeCoverage]
35
        public static void Invoke<T>(T obj, Action action) where T : Control
36
        {
37
            if (obj.InvokeRequired) // if we're not in the UI thread
38
            {
39
                // enqueue call on UI thread and wait for it to return
40
                obj.Invoke(new MethodInvoker(action));
41
            }
42
            else
43
            {
44
                // we're on the UI thread, execute directly
45
                action();
46
            }
47
        }
48

49
        // utility helper to deal with multi-threading and UI
50
        // async version, doesn't wait for UI thread
51
        // use with caution, when not sure use blocking Invoke()
52
        [ExcludeFromCodeCoverage]
53
        public static void AsyncInvoke<T>(T obj, Action action) where T : Control
54
        {
55
            if (obj.InvokeRequired) // if we're not in the UI thread
56
            {
57
                // enqueue call on UI thread and continue
58
                obj.BeginInvoke(new MethodInvoker(action));
59
            }
60
            else
61
            {
62
                // we're on the UI thread, execute directly
63
                action();
64
            }
65
        }
66

67
        /// <summary>
68
        /// Coalesce multiple events from a busy event source into single delayed reactions
69
        ///
70
        /// See: https://www.freecodecamp.org/news/javascript-debounce-example/
71
        ///
72
        /// Additional convenience features:
73
        ///   - Ability to do something immediately unconditionally
74
        ///   - Execute immediately if a condition is met
75
        ///   - Pass the events to the functions
76
        /// </summary>
77
        /// <param name="startFunc">Called immediately when the event is fired, for fast parts of the handling</param>
78
        /// <param name="immediateFunc">If this returns true for an event, truncate the delay and fire doneFunc immediately</param>
79
        /// <param name="abortFunc">If this returns true for an event, ignore it completely (e.g. for setting text box contents programmatically)</param>
80
        /// <param name="doneFunc">Called after timeoutMs milliseconds, or immediately if immediateFunc returns true</param>
81
        /// <param name="timeoutMs">Number of milliseconds between the last event and when to call doneFunc</param>
82
        /// <typeparam name="EventT">Event type handled</typeparam>
83
        /// <returns>A new event handler that wraps the given functions using the timer</returns>
84
        public static EventHandler<EventT> Debounce<EventT>(
85
            EventHandler<EventT>        startFunc,
86
            Func<object?, EventT, bool> immediateFunc,
87
            Func<object?, EventT, bool> abortFunc,
88
            EventHandler<EventT?>       doneFunc,
89
            int timeoutMs = 500)
90
        {
91
            // Store the most recent event we received
92
            object? receivedFrom = null;
1✔
93
            EventT? received     = default;
1✔
94

95
            // Set up the timer that will track the delay
96
            Timer timer = new Timer() { Interval = timeoutMs };
1✔
97
            timer.Elapsed += (sender, evt) =>
1✔
98
            {
99
                timer.Stop();
1✔
100
                doneFunc(receivedFrom, received);
1✔
101
            };
1✔
102

103
            return (sender, evt) =>
1✔
104
            {
105
                if (!abortFunc(sender, evt))
1✔
106
                {
107
                    timer.Stop();
1✔
108
                    startFunc(sender, evt);
1✔
109
                    if (immediateFunc(sender, evt))
1✔
110
                    {
111
                        doneFunc(sender, evt);
×
112
                        receivedFrom = null;
×
113
                        received     = default;
×
114
                    }
115
                    else
116
                    {
117
                        receivedFrom = sender;
1✔
118
                        received     = evt;
1✔
119
                        timer.Start();
1✔
120
                    }
121
                }
122
            };
1✔
123
        }
124

125
        #endregion
126

127
        #region Link handling
128

129
        /// <summary>
130
        /// Returns true if the string could be a valid http address.
131
        /// DOES NOT ACTUALLY CHECK IF IT EXISTS, just the format.
132
        /// </summary>
133
        public static bool CheckURLValid(string source)
134
            => Uri.TryCreate(source, UriKind.Absolute, out Uri? uri_result)
1✔
135
                && (uri_result.Scheme == Uri.UriSchemeHttp
136
                 || uri_result.Scheme == Uri.UriSchemeHttps);
137

138
        /// <summary>
139
        /// Open a URL, unless it's "N/A"
140
        /// </summary>
141
        /// <param name="url">The URL</param>
142
        [ExcludeFromCodeCoverage]
143
        public static void OpenLinkFromLinkLabel(string url)
144
        {
145
            if (url == Properties.Resources.ModInfoNSlashA)
146
            {
147
                return;
148
            }
149

150
            TryOpenWebPage(url);
151
        }
152

153
        /// <summary>
154
        /// Tries to open an url using the default application.
155
        /// If it fails, it tries again by prepending each prefix before the url before it gives up.
156
        /// </summary>
157
        [ExcludeFromCodeCoverage]
158
        public static bool TryOpenWebPage(string url, IEnumerable<string>? prefixes = null)
159
        {
160
            // Default prefixes to try if not provided
161
            prefixes ??= new string[] { "https://", "http://" };
162

163
            foreach (string fullUrl in new string[] { url }
164
                .Concat(prefixes.Select(p => p + url).Where(CheckURLValid)))
165
            {
166
                if (Utilities.ProcessStartURL(fullUrl))
167
                {
168
                    return true;
169
                }
170
            }
171
            return false;
172
        }
173

174
        /// <summary>
175
        /// React to the user clicking a mouse button on a link.
176
        /// Opens the URL in browser on left click, presents a
177
        /// right click menu on right click.
178
        /// </summary>
179
        /// <param name="url">The link's URL</param>
180
        /// <param name="e">The click event</param>
181
        [ExcludeFromCodeCoverage]
182
        public static void HandleLinkClicked(string url, LinkLabelLinkClickedEventArgs? e)
183
        {
184
            switch (e?.Button)
185
            {
186
                case MouseButtons.Left:
187
                case MouseButtons.Middle:
188
                    OpenLinkFromLinkLabel(url);
189
                    if (e.Link != null)
190
                    {
191
                        e.Link.Visited = true;
192
                    }
193
                    break;
194

195
                case MouseButtons.Right:
196
                    LinkContextMenu(url);
197
                    break;
198
            }
199
        }
200

201
        /// <summary>
202
        /// Show a link right-click menu under a control,
203
        /// meant for keyboard access
204
        /// </summary>
205
        /// <param name="url">The URL of the link</param>
206
        /// <param name="c">The menu will be shown below the bottom of this control</param>
207
        [ExcludeFromCodeCoverage]
208
        public static void LinkContextMenu(string url, Control c)
209
            => LinkContextMenu(url, c.PointToScreen(new Point(0, c.Height)));
210

211
        /// <summary>
212
        /// Show a context menu when the user right clicks a link
213
        /// </summary>
214
        /// <param name="url">The URL of the link</param>
215
        /// <param name="where">Screen coordinates for the menu</param>
216
        [ExcludeFromCodeCoverage]
217
        public static void LinkContextMenu(string url, Point? where = null)
218
        {
219
            var copyLink = new ToolStripMenuItem(Properties.Resources.UtilCopyLink);
220
            copyLink.Click += (sender, ev) => Clipboard.SetText(url);
221

222
            var menu = new ContextMenuStrip
223
            {
224
                Renderer = new FlatToolStripRenderer(),
225
            };
226
            menu.Items.Add(copyLink);
227
            menu.ScaleFonts();
228
            menu.Show(where ?? Cursor.Position);
229
        }
230

231
        #endregion
232

233
        #region Window positioning
234

235
        /// <summary>
236
        /// Find a screen that the given box overlaps
237
        /// </summary>
238
        /// <param name="location">Upper left corner of box</param>
239
        /// <param name="size">Width and height of box</param>
240
        /// <returns>
241
        /// The first screen that overlaps the box if any, otherwise null
242
        /// </returns>
243
        [ExcludeFromCodeCoverage]
244
        public static Screen? FindScreen(Point location,
245
                                         Size  size)
246
            => FindScreen(new Rectangle(location, size));
247

248
        [ExcludeFromCodeCoverage]
249
        private static Screen? FindScreen(Rectangle rect)
250
            => Screen.AllScreens.FirstOrDefault(sc => sc.WorkingArea.IntersectsWith(rect));
251

252
        /// <summary>
253
        /// Adjust position of a box so it fits entirely on one screen
254
        /// </summary>
255
        /// <param name="location">Top left corner of box</param>
256
        /// <param name="size">Width and height of box</param>
257
        /// <param name="screen">The screen to which to clamp, null for any screen</param>
258
        /// <returns>
259
        /// Original location if already fully on-screen, otherwise
260
        /// a position representing sliding it onto the screen
261
        /// </returns>
262
        [ExcludeFromCodeCoverage]
263
        public static Point ClampedLocation(Point   location,
264
                                            Size    size,
265
                                            Screen? screen = null)
266
        {
267
            screen ??= FindScreen(location, size);
268
            if (screen != null)
269
            {
270
                log.DebugFormat("Found screen: {0}", screen.WorkingArea);
271
                ClampTo(ref location, size, screen.WorkingArea);
272
                log.DebugFormat("Clamped location: {0}", location);
273
            }
274
            return location;
275
        }
276

277
        public static void ClampTo(ref Point location,
278
                                   Size      size,
279
                                   Rectangle workingArea)
280
        {
281
            // Slide the whole rectangle fully onto the screen
282
            if (location.X < workingArea.Left)
1✔
283
            {
284
                location.X = workingArea.Left;
1✔
285
            }
286

287
            if (location.Y < workingArea.Top)
1✔
288
            {
289
                location.Y = workingArea.Top;
1✔
290
            }
291

292
            if (location.X + size.Width > workingArea.Right)
1✔
293
            {
294
                location.X = workingArea.Right - size.Width;
1✔
295
            }
296

297
            if (location.Y + size.Height > workingArea.Bottom)
1✔
298
            {
299
                location.Y = workingArea.Bottom - size.Height;
1✔
300
            }
301
        }
1✔
302

303
        /// <summary>
304
        /// Adjust position of a box so it fits on one screen with a margin around it
305
        /// </summary>
306
        /// <param name="location">Top left corner of box</param>
307
        /// <param name="size">Width and height of box</param>
308
        /// <param name="topLeftMargin">Size of space between window and top left edge of screen</param>
309
        /// <param name="bottomRightMargin">Size of space between window and bottom right edge of screen</param>
310
        /// <param name="screen">The screen to which to clamp, null for any screen</param>
311
        /// <returns>
312
        /// Original location if already fully on-screen plus margins, otherwise
313
        /// a position representing sliding it onto the screen
314
        /// </returns>
315
        public static Point ClampedLocationWithMargins(Point location, Size size, Size topLeftMargin, Size bottomRightMargin, Screen? screen = null)
316
        {
317
            // Imagine drawing a larger box around the window, the size of the desired margin.
318
            // We pass that box to ClampedLocation to make sure it fits on screen,
319
            // then place our window at an offset within the box
320
            return ClampedLocation(location - topLeftMargin, size + topLeftMargin + bottomRightMargin, screen) + topLeftMargin;
×
321
        }
322

323
        #endregion
324

325
        #region Color manipulation
326

327
        public static Color BlendColors(params Color[] colors)
328
            => colors.Length <  1 ? Color.Empty
1✔
329
             //: colors is [var c] ? c
330
             : colors.Length == 1 && colors[0] is var c ? c
331
             : Color.FromArgb(colors.Sum(c => c.A) / colors.Length,
1✔
332
                              colors.Sum(c => c.R) / colors.Length,
1✔
333
                              colors.Sum(c => c.G) / colors.Length,
1✔
334
                              colors.Sum(c => c.B) / colors.Length);
1✔
335

336
        public static Color AlphaBlendWith(this Color c1, float alpha, Color c2)
337
            => AddColors(c1.MultiplyBy(alpha),
1✔
338
                         c2.MultiplyBy(1f - alpha));
339

340
        private static Color MultiplyBy(this Color c, float f)
341
            => Color.FromArgb((int)Math.Round(f * c.A),
1✔
342
                              (int)Math.Round(f * c.R),
343
                              (int)Math.Round(f * c.G),
344
                              (int)Math.Round(f * c.B));
345

346
        private static Color AddColors(Color a, Color b)
347
            => Color.FromArgb(Math.Min(255, a.A + b.A),
1✔
348
                              Math.Min(255, a.R + b.R),
349
                              Math.Min(255, a.G + b.G),
350
                              Math.Min(255, a.B + b.B));
351

352
        public static Color? ForeColorForBackColor(this Color backColor)
353
            => backColor == Color.Transparent || backColor == Color.Empty ? null
1✔
354
             : backColor == SystemColors.Window  ? SystemColors.WindowText
355
             : backColor == SystemColors.Control ? SystemColors.ControlText
356
             : foreColorCache.GetOrAdd(backColor, c => c.IsLight()
1✔
357
                                                           ? Color.Black
358
                                                           : Color.White);
359

360
        private static readonly ConcurrentDictionary<Color, Color> foreColorCache = new ConcurrentDictionary<Color, Color>();
1✔
361

362
        public static Color LinkColorForBackColor(this Color backColor)
363
            => backColor == Color.Transparent || backColor == Color.Empty ? Color.Blue
1✔
364
             : linkColorCache.GetOrAdd(backColor, c => c.IsLight()
1✔
365
                                                           ? Color.Blue
366
                                                           : BlendColors(Color.Blue, Color.White));
367

368
        private static readonly ConcurrentDictionary<Color, Color> linkColorCache = new ConcurrentDictionary<Color, Color>();
1✔
369

370
        public static void NormalizeForeColor(this Control control)
371
        {
372
            control.ForeColor = control.BackColor.ForeColorForBackColor()
1✔
373
                                ?? control.ForeColor;
374
        }
1✔
375

376
        public static string ToHex(this Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
1✔
377

378
        public static bool IsLight(this Color c) => c.GetLuminance() >= luminanceThreshold;
1✔
379

380
        /// <summary>
381
        /// Below this is considered "dark," above is considered "light"
382
        /// </summary>
383
        private const double luminanceThreshold = 0.179128785;
384

385
        /// <summary>
386
        /// https://www.w3.org/WAI/GL/wiki/Relative_luminance
387
        /// </summary>
388
        /// <param name="color">The color for which to calculate the relative luminance</param>
389
        /// <returns>Relative luminance (basically a better version of brightness) of the color</returns>
390
        public static double GetLuminance(this Color color)
391
            => GetLuminance(color.R, color.G, color.B);
1✔
392

393
        private static double GetLuminance(byte R, byte G, byte B)
394
            => GetLuminance(LuminanceTransform(R),
1✔
395
                            LuminanceTransform(G),
396
                            LuminanceTransform(B));
397

398
        private static double GetLuminance(double Rs, double Gs, double Bs)
399
            => (0.2126 * Rs) + (0.7152 * Gs) + (0.0722 * Bs);
1✔
400

401
        private static double LuminanceTransform(byte value)
402
            => LuminanceTransform(value / 255.0);
1✔
403

404
        private static double LuminanceTransform(double value)
405
            => value <= 0.03928 ? value / 12.92
1✔
406
                                : Math.Pow((value + 0.055) / 1.055, 2.4);
407

408
        #endregion
409

410
        #region Bitmap manipulation
411

412
        public static Bitmap LerpBitmaps(Bitmap a, Bitmap b,
413
                                         float amount)
414
            => amount <= 0 ? a
1✔
415
             : amount >= 1 ? b
416
             : MergeBitmaps(a, b,
417
                            // Note pixA and pixB are swapped because our blend function
418
                            // pretends 'amount' is the first argument's alpha channel,
419
                            // so 0 -> third param by itself
420
                            (pixA, pixB) => AlphaBlendWith(pixB, amount, pixA));
1✔
421

422
        private static Bitmap MergeBitmaps(Bitmap a, Bitmap b,
423
                                           Func<Color, Color, Color> how)
424
        {
425
            var c = new Bitmap(a);
1✔
426
            foreach (var y in Enumerable.Range(0, c.Height))
3✔
427
            {
428
                foreach (var x in Enumerable.Range(0, c.Width))
3✔
429
                {
430
                    c.SetPixel(x, y, how(a.GetPixel(x, y),
1✔
431
                                         b.GetPixel(x, y)));
432
                }
433
            }
434
            return c;
1✔
435
        }
436

437
        #endregion
438

439
        #region Text sizing
440

441
        /// <summary>
442
        /// Simple syntactic sugar around Graphics.MeasureString
443
        /// </summary>
444
        /// <param name="g">The graphics context</param>
445
        /// <param name="font">The font to be used for the text</param>
446
        /// <param name="text">String to measure size of</param>
447
        /// <param name="maxWidth">Number of pixels allowed horizontally</param>
448
        /// <returns>
449
        /// Number of pixels needed vertically to fit the string
450
        /// </returns>
451
        [ExcludeFromCodeCoverage]
452
        public static int StringHeight(this Graphics g, string text, Font font, int maxWidth)
453
            => Platform.IsMono ? (int)g.MeasureString(text, font, (int)(maxWidth / XScale(g))).Height
454
                               : (int)g.MeasureString(text, font, maxWidth).Height;
455

456
        [ExcludeFromCodeCoverage]
457
        public static int StringHeight<T>(this T c, int maxWidth)
458
            where T : Control
459
            => c.CreateGraphics().StringHeight(c.Text, c.Font, maxWidth);
460

461
        /// <summary>
462
        /// Calculate how much vertical space is needed to display a label's text
463
        /// </summary>
464
        /// <param name="g">The graphics context</param>
465
        /// <param name="lbl">The label</param>
466
        /// <returns>
467
        /// Number of pixels needed vertically to show the label's full text
468
        /// </returns>
469
        [ExcludeFromCodeCoverage]
470
        public static int LabelStringHeight(this Graphics g, Label lbl)
471
            => (int)(YScale(g) * (lbl.Margin.Vertical + lbl.Padding.Vertical
472
                                  + g.StringHeight(lbl.Text, lbl.Font,
473
                                                   (lbl.Width - lbl.Margin.Horizontal
474
                                                              - lbl.Padding.Horizontal))));
475

476
        #endregion
477

478
        #pragma warning disable IDE0075
479
        public static bool DarkMode => Platform.IsWindows
1✔
480
                                           #if NET10_0_OR_GREATER
481
                                           ? Platform.IsWindows11
482
                                             && WinReg.GetValue(DarkModeKey, "AppsUseLightTheme", 1) is not 1
483
                                           #else
484
                                           ? false
485
                                           #endif
486
                                     : Platform.IsUnix
487
                                           ? (CommandOutputContains("gsettings",
488
                                                                    "get org.gnome.desktop.interface color-scheme",
489
                                                                    "prefer-dark")
490
                                              ?? CommandOutputContains("kreadconfig5",
491
                                                                       "--group Colors --key ColorScheme",
492
                                                                       "Dark")
493
                                              ?? false)
494
                                     : Platform.IsMac
495
                                           && (CommandOutputContains("defaults",
496
                                                                     "read -g AppleInterfaceStyle",
497
                                                                     "Dark")
498
                                               ?? false);
499
        #pragma warning restore IDE0075
500

501
        [ExcludeFromCodeCoverage]
502
        public static float TextScaleFactor
503
            #if NET6_0_OR_GREATER
504
            => Platform.IsWindows
505
               && WinReg.GetValue(TextScaleFactorKey, "TextScaleFactor", 100)
506
                  is >= 100 and <= 300 and int f
507
                      ? f / 100f : 1;
508
            #else
509
            => 1;
510
            #endif
511

512
        private static bool? CommandOutputContains(string command, string args, string checkFor)
UNCOV
513
            => Utilities.DefaultIfThrows(() => Process.Start(new ProcessStartInfo()
×
514
                                                             {
515
                                                                 FileName               = command,
516
                                                                 Arguments              = args,
517
                                                                 UseShellExecute        = false,
518
                                                                 RedirectStandardOutput = true,
519
                                                                 CreateNoWindow         = true,
520
                                                             }))
521
                        ?.StandardOutput.ReadToEnd().Contains(checkFor);
522

523
        #if NET10_0_OR_GREATER
524
        private const string DarkModeKey = @"HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize";
525
        #endif
526

527
        #if NET6_0_OR_GREATER
528
        private const string TextScaleFactorKey = @"HKEY_CURRENT_USER\Software\Microsoft\Accessibility";
529
        #endif
530

531
        // Hides the console window on Windows
532
        // useful when running the GUI
533
        [DllImport("kernel32.dll", SetLastError=true)]
534
        private static extern int FreeConsole();
535

536
        [ExcludeFromCodeCoverage]
537
        public static void HideConsoleWindow()
538
        {
539
            if (Platform.IsWindows)
540
            {
541
                FreeConsole();
542
            }
543
        }
544

545
        [ExcludeFromCodeCoverage]
546
        private static float XScale(Graphics g) => g.DpiX / 96f;
547

548
        [ExcludeFromCodeCoverage]
549
        private static float YScale(Graphics g) => g.DpiY / 96f;
550

551
        private static readonly ILog log = LogManager.GetLogger(typeof(Util));
1✔
552
    }
553
}
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