/ src / modules / cmdpal / Microsoft.CmdPal.UI / Services / ColorfulThemeProvider.cs
ColorfulThemeProvider.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using CommunityToolkit.WinUI.Helpers;
  6  using Microsoft.CmdPal.UI.Helpers;
  7  using Microsoft.CmdPal.UI.ViewModels.Services;
  8  using Microsoft.UI.Xaml;
  9  using Windows.UI;
 10  using Windows.UI.ViewManagement;
 11  
 12  namespace Microsoft.CmdPal.UI.Services;
 13  
 14  /// <summary>
 15  /// Provides theme appropriate for colorful (accented) appearance.
 16  /// </summary>
 17  internal sealed class ColorfulThemeProvider : IThemeProvider
 18  {
 19      // Fluent dark:  #202020
 20      private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32);
 21  
 22      // Fluent light: #F3F3F3
 23      private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243);
 24  
 25      private readonly UISettings _uiSettings;
 26  
 27      public string ThemeKey => "colorful";
 28  
 29      public string ResourcePath => "ms-appx:///Styles/Theme.Colorful.xaml";
 30  
 31      public ColorfulThemeProvider(UISettings uiSettings)
 32      {
 33          ArgumentNullException.ThrowIfNull(uiSettings);
 34          _uiSettings = uiSettings;
 35      }
 36  
 37      public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context)
 38      {
 39          var isLight = context.Theme == ElementTheme.Light ||
 40                        (context.Theme == ElementTheme.Default &&
 41                         _uiSettings.GetColorValue(UIColorType.Background).R > 128);
 42  
 43          var baseColor = isLight ? LightBaseColor : DarkBaseColor;
 44  
 45          // Windows is warping the hue of accent colors and running it through some curves to produce their accent shades.
 46          // This will attempt to mimic that behavior.
 47          var accentShades = AccentShades.Compute(context.Tint.LerpHsv(WindowsAccentHueWarpTransform.Transform(context.Tint), 0.5f));
 48          var blended = isLight ? accentShades.Light3 : accentShades.Dark2;
 49          var colorIntensityUser = (context.ColorIntensity ?? 100) / 100f;
 50  
 51          // For light theme, we want to reduce intensity a bit, and also we need to keep the color fairly light,
 52          // to avoid issues with text box caret.
 53          var colorIntensity = isLight ? 0.6f * colorIntensityUser : colorIntensityUser;
 54          var effectiveBgColor = ColorBlender.Blend(baseColor, blended, colorIntensity);
 55  
 56          return new AcrylicBackdropParameters(effectiveBgColor, effectiveBgColor, 0.8f, 0.8f);
 57      }
 58  
 59      private static class ColorBlender
 60      {
 61          /// <summary>
 62          /// Blends a semitransparent tint color over an opaque base color using alpha compositing.
 63          /// </summary>
 64          /// <param name="baseColor">The opaque base color (background)</param>
 65          /// <param name="tintColor">The semitransparent tint color (foreground)</param>
 66          /// <param name="intensity">The intensity of the tint (0.0 - 1.0)</param>
 67          /// <returns>The resulting blended color</returns>
 68          public static Color Blend(Color baseColor, Color tintColor, float intensity)
 69          {
 70              // Normalize alpha to 0.0 - 1.0 range
 71              intensity = Math.Clamp(intensity, 0f, 1f);
 72  
 73              // Alpha compositing formula: result = tint * alpha + base * (1 - alpha)
 74              var r = (byte)((tintColor.R * intensity) + (baseColor.R * (1 - intensity)));
 75              var g = (byte)((tintColor.G * intensity) + (baseColor.G * (1 - intensity)));
 76              var b = (byte)((tintColor.B * intensity) + (baseColor.B * (1 - intensity)));
 77  
 78              // Result is fully opaque since base is opaque
 79              return Color.FromArgb(255, r, g, b);
 80          }
 81      }
 82  
 83      private static class WindowsAccentHueWarpTransform
 84      {
 85          private static readonly (double HIn, double HOut)[] HueMap =
 86          [
 87              (0, 0),
 88              (10, 1),
 89              (20, 6),
 90              (30, 10),
 91              (40, 14),
 92              (50, 19),
 93              (60, 36),
 94              (70, 94),
 95              (80, 112),
 96              (90, 120),
 97              (100, 120),
 98              (110, 120),
 99              (120, 120),
100              (130, 120),
101              (140, 120),
102              (150, 125),
103              (160, 135),
104              (170, 142),
105              (180, 178),
106              (190, 205),
107              (200, 220),
108              (210, 229),
109              (220, 237),
110              (230, 241),
111              (240, 243),
112              (250, 244),
113              (260, 245),
114              (270, 248),
115              (280, 252),
116              (290, 276),
117              (300, 293),
118              (310, 313),
119              (320, 330),
120              (330, 349),
121              (340, 353),
122              (350, 357)
123          ];
124  
125          public static Color Transform(Color input, Options? opt = null)
126          {
127              opt ??= new Options();
128              var hsv = input.ToHsv();
129              return ColorHelper.FromHsv(
130                  RemapHueLut(hsv.H),
131                  Clamp01(Math.Pow(hsv.S, opt.SaturationGamma) * opt.SaturationGain),
132                  Clamp01((opt.ValueScaleA * hsv.V) + opt.ValueBiasB),
133                  input.A);
134          }
135  
136          // Hue LUT remap (piecewise-linear with cyclic wrap)
137          private static double RemapHueLut(double hDeg)
138          {
139              // Normalize to [0,360)
140              hDeg = Mod(hDeg, 360.0);
141  
142              // Handle wrap-around case: hDeg is between last entry (350°) and 360°
143              var last = HueMap[^1];
144              var first = HueMap[0];
145              if (hDeg >= last.HIn)
146              {
147                  // Interpolate between last entry and first entry (wrapped by 360°)
148                  var t = (hDeg - last.HIn) / (first.HIn + 360.0 - last.HIn + 1e-12);
149                  var ho = Lerp(last.HOut, first.HOut + 360.0, t);
150                  return Mod(ho, 360.0);
151              }
152  
153              // Find segment [i, i+1] where HueMap[i].HIn <= hDeg < HueMap[i+1].HIn
154              for (var i = 0; i < HueMap.Length - 1; i++)
155              {
156                  var a = HueMap[i];
157                  var b = HueMap[i + 1];
158  
159                  if (hDeg >= a.HIn && hDeg < b.HIn)
160                  {
161                      var t = (hDeg - a.HIn) / (b.HIn - a.HIn + 1e-12);
162                      return Lerp(a.HOut, b.HOut, t);
163                  }
164              }
165  
166              // Fallback (shouldn't happen)
167              return hDeg;
168          }
169  
170          private static double Lerp(double a, double b, double t) => a + ((b - a) * t);
171  
172          private static double Mod(double x, double m) => ((x % m) + m) % m;
173  
174          private static double Clamp01(double x) => x < 0 ? 0 : (x > 1 ? 1 : x);
175  
176          public sealed class Options
177          {
178              // Saturation boost (1.0 = no change). Typical: 1.3–1.8
179              public double SaturationGain { get; init; } = 1.0;
180  
181              // Optional saturation gamma (1.0 = linear). <1.0 raises low S a bit; >1.0 preserves low S.
182              public double SaturationGamma { get; init; } = 1.0;
183  
184              // Value (V) remap: V' = a*V + b   (tone curve; clamp applied)
185              // Example that lifts blacks & compresses whites slightly: a=0.50, b=0.08
186              public double ValueScaleA { get; init; } = 0.6;
187  
188              public double ValueBiasB { get; init; } = 0.01;
189          }
190      }
191  
192      private static class AccentShades
193      {
194          public static (Color Light3, Color Light2, Color Light1, Color Dark1, Color Dark2, Color Dark3) Compute(Color accent)
195          {
196              var light1 = accent.Update(brightnessFactor: 0.15, saturationFactor: -0.12);
197              var light2 = accent.Update(brightnessFactor: 0.30, saturationFactor: -0.24);
198              var light3 = accent.Update(brightnessFactor: 0.45, saturationFactor: -0.36);
199  
200              var dark1 = accent.UpdateBrightness(brightnessFactor: -0.05f);
201              var dark2 = accent.UpdateBrightness(brightnessFactor: -0.01f);
202              var dark3 = accent.UpdateBrightness(brightnessFactor: -0.015f);
203  
204              return (light3, light2, light1, dark1, dark2, dark3);
205          }
206      }
207  }