Skip to content

Commit 060a96f

Browse files
committed
WIP theme engine
1 parent 5e296ab commit 060a96f

25 files changed

+1149
-40
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
using System.Linq;
2+
using System.Windows;
3+
using System.Windows.Media;
4+
5+
using Microsoft.VisualStudio.TestTools.UnitTesting;
6+
7+
namespace FancyWM.ThemeEngine.Tests
8+
{
9+
[TestClass]
10+
public class CssToWpfResourceConverterTest
11+
{
12+
[TestMethod]
13+
public void TestSpecificity()
14+
{
15+
var converter = new CssToWpfResourceConverter();
16+
var htmlTemplate = "<a><button></button><button class='primary'></button></a>";
17+
var cssText = @"
18+
button { color: red; }
19+
button.primary { color: blue; }
20+
button:hover { color: lime; }
21+
";
22+
23+
var resources = converter.Convert(htmlTemplate, cssText);
24+
25+
Assert.AreEqual(Colors.Red, resources["Button_Color"]);
26+
Assert.AreEqual(Colors.Lime, resources["Button_Hover_Color"]);
27+
Assert.AreEqual(Colors.Blue, resources["Button_Primary_Color"]);
28+
Assert.AreEqual(Colors.Lime, resources["Button_Primary_Hover_Color"]);
29+
}
30+
31+
[TestMethod]
32+
public void TestColorProperty()
33+
{
34+
var converter = new CssToWpfResourceConverter();
35+
var htmlTemplate = "<button></button>";
36+
var cssText = "button { color: #AABBCC; }";
37+
38+
var resources = converter.Convert(htmlTemplate, cssText);
39+
Assert.AreEqual(Color.FromRgb(0xAA, 0xBB, 0xCC), resources["Button_Color"]);
40+
}
41+
42+
[TestMethod]
43+
public void TestBackgroundColorProperty()
44+
{
45+
var converter = new CssToWpfResourceConverter();
46+
var htmlTemplate = "<button></button>";
47+
var cssText = "button { background-color: rgba(10, 20, 30, 0.5); }";
48+
49+
var resources = converter.Convert(htmlTemplate, cssText);
50+
Assert.AreEqual(Color.FromArgb(128, 10, 20, 30), resources["Button_BackgroundColor"]);
51+
}
52+
53+
[TestMethod]
54+
public void TestBackgroundImageProperty()
55+
{
56+
var converter = new CssToWpfResourceConverter();
57+
var htmlTemplate = "<button></button>";
58+
var cssText = "button { background-image: url('file:///C:/Windows/Web/Wallpaper/Windows/img0.jpg'); }";
59+
60+
var resources = converter.Convert(htmlTemplate, cssText);
61+
Assert.IsInstanceOfType(resources["Button_BackgroundImage"], typeof(ImageBrush));
62+
}
63+
64+
[TestMethod]
65+
public void TestBackgroundProperty()
66+
{
67+
var converter = new CssToWpfResourceConverter();
68+
var htmlTemplate = "<button></button>";
69+
var cssText = "button { background: rgba(10, 20, 30, 0.5); }";
70+
71+
var resources = converter.Convert(htmlTemplate, cssText);
72+
Assert.AreEqual(Color.FromArgb(128, 10, 20, 30), resources["Button_BackgroundColor"]);
73+
}
74+
75+
[TestMethod]
76+
public void TestBorderColorProperty()
77+
{
78+
var converter = new CssToWpfResourceConverter();
79+
var htmlTemplate = "<button></button>";
80+
var cssText = "button { border-color: red; }";
81+
82+
var resources = converter.Convert(htmlTemplate, cssText);
83+
Assert.AreEqual(Colors.Red, resources["Button_BorderColor"]);
84+
}
85+
86+
[TestMethod]
87+
public void TestBorderWidthProperty()
88+
{
89+
var converter = new CssToWpfResourceConverter();
90+
var htmlTemplate = "<button></button>";
91+
var cssText = "button { border-width: 1px 2px 3px 4px; }";
92+
93+
var resources = converter.Convert(htmlTemplate, cssText);
94+
Assert.AreEqual(new Thickness(4, 1, 2, 3), resources["Button_BorderWidth"]);
95+
}
96+
97+
98+
[TestMethod]
99+
public void TestBorderRadiusProperty()
100+
{
101+
var converter = new CssToWpfResourceConverter();
102+
var htmlTemplate = "<button></button>";
103+
var cssText = "button { border-radius: 1px 2px 3px 4px; }";
104+
105+
var resources = converter.Convert(htmlTemplate, cssText);
106+
Assert.AreEqual(new CornerRadius(1, 2, 3, 4), resources["Button_BorderRadius"]);
107+
}
108+
109+
[TestMethod]
110+
public void TestFontWeightProperty()
111+
{
112+
var converter = new CssToWpfResourceConverter();
113+
var htmlTemplate = "<button></button>";
114+
var cssText = "button { font-weight: 800; }";
115+
116+
var resources = converter.Convert(htmlTemplate, cssText);
117+
Assert.AreEqual(FontWeight.FromOpenTypeWeight(800), resources["Button_FontWeight"]);
118+
}
119+
120+
[TestMethod]
121+
public void TestFontFamilityProperty()
122+
{
123+
var converter = new CssToWpfResourceConverter();
124+
var htmlTemplate = "<button></button>";
125+
var cssText = "button { font-family: 'Segoe UI'; }";
126+
127+
var resources = converter.Convert(htmlTemplate, cssText);
128+
Assert.AreEqual("Segoe UI", (resources["Button_FontFamily"] as FontFamily)?.FamilyNames.First().Value);
129+
}
130+
}
131+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0-windows10.0.18362.0</TargetFramework>
5+
6+
<IsPackable>false</IsPackable>
7+
<CetCompat>false</CetCompat>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
12+
<PackageReference Include="Moq" Version="4.16.1" />
13+
<PackageReference Include="MSTest.TestAdapter" Version="2.1.1" />
14+
<PackageReference Include="MSTest.TestFramework" Version="2.1.1" />
15+
<PackageReference Include="coverlet.collector" Version="1.3.0" />
16+
</ItemGroup>
17+
18+
<ItemGroup>
19+
<ProjectReference Include="..\FancyWM.ThemeEngine\FancyWM.ThemeEngine.csproj" />
20+
</ItemGroup>
21+
22+
</Project>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System.Text.RegularExpressions;
2+
using System.Windows.Media;
3+
using System.Windows.Media.Imaging;
4+
5+
namespace FancyWM.ThemeEngine.Converters
6+
{
7+
public class BackgroundImageConverter : ICssPropertyConverter
8+
{
9+
public object Convert(string cssValue)
10+
{
11+
var match = Regex.Match(cssValue, @"url\(['""]?([^'""\)\s]+)['""]?\)");
12+
13+
if (!match.Success)
14+
throw new ArgumentException($"Invalid background-image: '{cssValue}'");
15+
16+
var imageUri = match.Groups[1].Value;
17+
18+
try
19+
{
20+
var bitmap = new BitmapImage(new Uri(imageUri, UriKind.RelativeOrAbsolute));
21+
return new ImageBrush(bitmap) { Stretch = Stretch.UniformToFill };
22+
}
23+
catch (Exception ex)
24+
{
25+
return Brushes.Transparent;
26+
}
27+
}
28+
}
29+
30+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System.Windows;
2+
3+
namespace FancyWM.ThemeEngine.Converters
4+
{
5+
public class BorderRadiusConverter : ICssPropertyConverter
6+
{
7+
public object Convert(string cssValue)
8+
{
9+
var cleaned = cssValue.Replace("px", "").Trim().Split(' ').Select(double.Parse).ToList();
10+
if (cleaned.Count == 1)
11+
{
12+
return new CornerRadius(cleaned[0]);
13+
}
14+
else if (cleaned.Count == 2)
15+
{
16+
return new CornerRadius(cleaned[0], cleaned[1], cleaned[0], cleaned[1]);
17+
}
18+
else if (cleaned.Count == 3)
19+
{
20+
return new CornerRadius(cleaned[0], cleaned[1], cleaned[2], cleaned[1]);
21+
}
22+
else if (cleaned.Count == 4)
23+
{
24+
return new CornerRadius(cleaned[0], cleaned[1], cleaned[2], cleaned[3]);
25+
}
26+
else
27+
{
28+
throw new ArgumentException($"Invalid border width: '{cssValue}'");
29+
}
30+
}
31+
}
32+
33+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System.Windows;
2+
3+
namespace FancyWM.ThemeEngine.Converters
4+
{
5+
public class BorderWidthConverter : ICssPropertyConverter
6+
{
7+
public object Convert(string cssValue)
8+
{
9+
var cleaned = cssValue.Replace("px", "").Trim().Split(' ').Select(double.Parse).ToList();
10+
if (cleaned.Count == 1)
11+
{
12+
return new Thickness(cleaned[0]);
13+
}
14+
else if (cleaned.Count == 2)
15+
{
16+
return new Thickness(cleaned[1], cleaned[0], cleaned[1], cleaned[0]);
17+
}
18+
else if (cleaned.Count == 3)
19+
{
20+
return new Thickness(cleaned[1], cleaned[0], cleaned[1], cleaned[2]);
21+
}
22+
else if (cleaned.Count == 4)
23+
{
24+
return new Thickness(cleaned[3], cleaned[0], cleaned[1], cleaned[2]);
25+
}
26+
else
27+
{
28+
throw new ArgumentException($"Invalid border width: '{cssValue}'");
29+
}
30+
}
31+
}
32+
33+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Text.RegularExpressions;
2+
using System.Windows.Media;
3+
4+
namespace FancyWM.ThemeEngine.Converters
5+
{
6+
public class ColorConverter : ICssPropertyConverter
7+
{
8+
private static readonly Regex RgbaRegex = new(
9+
@"rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\)",
10+
RegexOptions.Compiled);
11+
12+
public object Convert(string cssValue)
13+
{
14+
if (string.IsNullOrWhiteSpace(cssValue) || cssValue == "transparent")
15+
return Colors.Transparent;
16+
17+
var match = RgbaRegex.Match(cssValue);
18+
if (match.Success)
19+
{
20+
byte r = byte.Parse(match.Groups[1].Value);
21+
byte g = byte.Parse(match.Groups[2].Value);
22+
byte b = byte.Parse(match.Groups[3].Value);
23+
24+
float a = match.Groups[4].Success ? float.Parse(match.Groups[4].Value) : 1.0f;
25+
byte alphaByte = (byte)Math.Round(a * 255);
26+
27+
return Color.FromArgb(alphaByte, r, g, b);
28+
}
29+
30+
try
31+
{
32+
return (Color)System.Windows.Media.ColorConverter.ConvertFromString(cssValue);
33+
}
34+
catch (FormatException)
35+
{
36+
return Colors.Black;
37+
}
38+
}
39+
}
40+
41+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace FancyWM.ThemeEngine.Converters
2+
{
3+
public interface ICssPropertyConverter
4+
{
5+
/// <summary>
6+
/// Convert a CSS property value string to a WPF object.
7+
/// Throws or returns null on unsupported/invalid input.
8+
/// </summary>
9+
object Convert(string cssValue);
10+
}
11+
12+
public static class CssConverters
13+
{
14+
private static readonly Dictionary<string, ICssPropertyConverter> Registry =
15+
new()
16+
{
17+
["color"] = new ColorConverter(),
18+
["background-color"] = new ColorConverter(),
19+
["background-image"] = new BackgroundImageConverter(),
20+
["border-color"] = new ColorConverter(),
21+
["border-radius"] = new BorderRadiusConverter(),
22+
["border-width"] = new BorderWidthConverter(),
23+
["font-size"] = new FontSizeConverter(),
24+
["font-family"] = new FontFamilyConverter(),
25+
["font-weight"] = new FontWeightConverter(),
26+
};
27+
28+
public static ICssPropertyConverter GetConverter(string cssProperty)
29+
=> Registry.TryGetValue(cssProperty, out var converter)
30+
? converter
31+
: throw new NotSupportedException($"CSS property '{cssProperty}' is not supported");
32+
33+
public static bool IsSupported(string cssProperty)
34+
=> Registry.ContainsKey(cssProperty);
35+
}
36+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System.Windows;
2+
using System.Windows.Media;
3+
4+
namespace FancyWM.ThemeEngine.Converters
5+
{
6+
public class FontFamilyConverter : ICssPropertyConverter
7+
{
8+
public object Convert(string cssValue)
9+
{
10+
// Split by comma, take first, trim quotes
11+
var fontName = cssValue.Split(',')[0].Trim('\'', '"', ' ');
12+
13+
if (string.IsNullOrWhiteSpace(fontName))
14+
return SystemFonts.MessageFontFamily;
15+
16+
return new FontFamily(fontName);
17+
}
18+
}
19+
20+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace FancyWM.ThemeEngine.Converters
2+
{
3+
public class FontSizeConverter : ICssPropertyConverter
4+
{
5+
public object Convert(string cssValue)
6+
{
7+
// Only accept px; no em/rem/% allowed
8+
// Expected format: "16px" or "16"
9+
10+
var cleaned = cssValue.Replace("px", "").Trim();
11+
12+
if (!double.TryParse(cleaned, out var size) || size < 0)
13+
throw new ArgumentException($"Invalid font-size: '{cssValue}'");
14+
15+
return size;
16+
}
17+
}
18+
19+
}

0 commit comments

Comments
 (0)