Skip to content

Commit e26dece

Browse files
committed
Add CSS theme engine
Styles a virtual DOM using CSS and applies supported styles to the overlay. 1. Reads themes\custom.css using AngleSharp.CSS 2. Styles a virtual DOM template 3. Extracts styles using custom markup extension 4. Converts supported properties to WPF equivalents 5. Renders the result to the screeen
1 parent 57920f9 commit e26dece

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2176
-61
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using System.Linq;
2+
using System.Windows;
3+
using System.Windows.Media;
4+
using System.Windows.Media.Effects;
5+
6+
using FancyWM.ThemeEngine.Wpf;
7+
8+
using Microsoft.VisualStudio.TestTools.UnitTesting;
9+
10+
namespace FancyWM.ThemeEngine.Tests
11+
{
12+
[TestClass]
13+
public class CssToWpfResourceConverterTest
14+
{
15+
[TestMethod]
16+
public void TestSpecificity()
17+
{
18+
var converter = new CssToWpfResourceConverter();
19+
var htmlTemplate = "<a><button></button><button class='primary'></button></a>";
20+
var cssText = @"
21+
button { color: red; }
22+
button.primary { color: blue; }
23+
button:hover { color: lime; }
24+
";
25+
26+
var resources = converter.Convert(htmlTemplate, cssText);
27+
28+
Assert.AreEqual(Colors.Red, resources["button/color"].As<Color>());
29+
Assert.AreEqual(Colors.Lime, resources["button:hover/color"].As<Color>());
30+
Assert.AreEqual(Colors.Blue, resources["button.primary/color"].As<Color>());
31+
Assert.AreEqual(Colors.Lime, resources["button.primary:hover/color"].As<Color>());
32+
}
33+
34+
[TestMethod]
35+
public void TestPrecedence()
36+
{
37+
var converter = new CssToWpfResourceConverter();
38+
var htmlTemplate = "<button></button>";
39+
var cssText = @"
40+
a, button { color: blue; }
41+
button { color: red; }
42+
";
43+
44+
var resources = converter.Convert(htmlTemplate, cssText);
45+
Assert.AreEqual(Colors.Red, resources["button/color"].As<Color>());
46+
}
47+
48+
[TestMethod]
49+
public void TestColorProperty()
50+
{
51+
var converter = new CssToWpfResourceConverter();
52+
var htmlTemplate = "<button></button>";
53+
var cssText = "button { color: #AABBCC; }";
54+
55+
var resources = converter.Convert(htmlTemplate, cssText);
56+
Assert.AreEqual(Color.FromRgb(0xAA, 0xBB, 0xCC), resources["button/color"].As<Color>());
57+
}
58+
59+
[TestMethod]
60+
public void TestBackgroundColorProperty()
61+
{
62+
var converter = new CssToWpfResourceConverter();
63+
var htmlTemplate = "<button></button>";
64+
var cssText = "button { background-color: rgba(10, 20, 30, 0.5); }";
65+
66+
var resources = converter.Convert(htmlTemplate, cssText);
67+
Assert.AreEqual(Color.FromArgb(128, 10, 20, 30), resources["button/background-color"].As<Color>());
68+
}
69+
70+
[TestMethod]
71+
public void TestBackgroundImageProperty()
72+
{
73+
var converter = new CssToWpfResourceConverter();
74+
var htmlTemplate = "<button></button>";
75+
var cssText = "button { background-image: url('file:///C:/Windows/Web/Wallpaper/Windows/img0.jpg'); }";
76+
77+
var resources = converter.Convert(htmlTemplate, cssText);
78+
Assert.IsInstanceOfType(resources["button/background-image"].As<Brush>(), typeof(ImageBrush));
79+
}
80+
81+
[TestMethod]
82+
public void TestBackgroundProperty()
83+
{
84+
var converter = new CssToWpfResourceConverter();
85+
var htmlTemplate = "<button></button>";
86+
var cssText = "button { background: rgba(10, 20, 30, 0.5); }";
87+
88+
var resources = converter.Convert(htmlTemplate, cssText);
89+
Assert.AreEqual(Color.FromArgb(128, 10, 20, 30), resources["button/background-color"].As<Color>());
90+
}
91+
92+
[TestMethod]
93+
public void TestBorderColorProperty()
94+
{
95+
var converter = new CssToWpfResourceConverter();
96+
var htmlTemplate = "<button></button>";
97+
var cssText = "button { border-color: red; }";
98+
99+
var resources = converter.Convert(htmlTemplate, cssText);
100+
Assert.AreEqual(Colors.Red, resources["button/border-top-color"].As<Color>());
101+
}
102+
103+
[TestMethod]
104+
public void TestBorderWidthProperty()
105+
{
106+
var converter = new CssToWpfResourceConverter();
107+
var htmlTemplate = "<button></button>";
108+
var cssText = "button { border-width: 1px 2px 3px 4px; }";
109+
110+
var resources = converter.Convert(htmlTemplate, cssText);
111+
Assert.AreEqual(new Thickness(4, 1, 2, 3), resources["button/border-width"].As<Thickness>());
112+
Assert.AreEqual(1, resources["button/border-top-width"].As<double>());
113+
}
114+
115+
116+
[TestMethod]
117+
public void TestBorderRadiusProperty()
118+
{
119+
var converter = new CssToWpfResourceConverter();
120+
var htmlTemplate = "<button></button>";
121+
var cssText = "button { border-radius: 1px 2px 3px 4px; }";
122+
123+
var resources = converter.Convert(htmlTemplate, cssText);
124+
Assert.AreEqual(new CornerRadius(1, 2, 3, 4), resources["button/border-radius"].As<CornerRadius>());
125+
}
126+
127+
[TestMethod]
128+
public void TestFilterDropShadowProperty()
129+
{
130+
var converter = new CssToWpfResourceConverter();
131+
var htmlTemplate = "<button></button>";
132+
var cssText = "button { filter: drop-shadow(2px 2px rgba(0,0,0,.2)); }";
133+
134+
var resources = converter.Convert(htmlTemplate, cssText);
135+
var filter = resources["button/filter"].As<Effect>();
136+
Assert.IsInstanceOfType(filter, typeof(DropShadowEffect));
137+
}
138+
139+
[TestMethod]
140+
public void TestFontWeightProperty()
141+
{
142+
var converter = new CssToWpfResourceConverter();
143+
var htmlTemplate = "<button></button>";
144+
var cssText = "button { font-weight: 800; }";
145+
146+
var resources = converter.Convert(htmlTemplate, cssText);
147+
Assert.AreEqual(FontWeight.FromOpenTypeWeight(800), resources["button/font-weight"].As<FontWeight>());
148+
}
149+
150+
[TestMethod]
151+
public void TestFontFamilityProperty()
152+
{
153+
var converter = new CssToWpfResourceConverter();
154+
var htmlTemplate = "<button></button>";
155+
var cssText = "button { font-family: 'Segoe UI'; }";
156+
157+
var resources = converter.Convert(htmlTemplate, cssText);
158+
Assert.AreEqual("Segoe UI", (resources["button/font-family"].As<FontFamily>())?.FamilyNames.First().Value);
159+
}
160+
}
161+
}
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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Windows.Media;
2+
3+
using FancyWM.ThemeEngine.Wpf;
4+
5+
using Microsoft.VisualStudio.TestTools.UnitTesting;
6+
7+
namespace FancyWM.ThemeEngine.Tests
8+
{
9+
[TestClass]
10+
public class WpfGradientTest
11+
{
12+
private static Brush Background(string cssValue)
13+
{
14+
var converter = new CssToWpfResourceConverter();
15+
var htmlTemplate = "<a></a>";
16+
var cssText = $"a {{ background-image: {cssValue}; }}";
17+
18+
var resources = converter.Convert(htmlTemplate, cssText);
19+
return resources["a/background"].As<Brush>();
20+
}
21+
22+
private static LinearGradientBrush LinearGradient(string cssValue)
23+
{
24+
var brush = Background(cssValue);
25+
Assert.IsInstanceOfType(brush, typeof(DrawingBrush));
26+
return GetInnerBrush(brush) as LinearGradientBrush;
27+
}
28+
29+
private static RadialGradientBrush RadialGradient(string cssValue)
30+
{
31+
var brush = Background(cssValue);
32+
return GetInnerBrush(brush) as RadialGradientBrush;
33+
}
34+
35+
private static double GetAngle(LinearGradientBrush brush)
36+
{
37+
return (brush.RelativeTransform as RotateTransform).Angle;
38+
}
39+
40+
private static Brush GetInnerBrush(Brush brush)
41+
{
42+
if (brush is DrawingBrush)
43+
{
44+
return ((brush as DrawingBrush).Drawing as GeometryDrawing).Brush;
45+
}
46+
return brush;
47+
}
48+
49+
[TestMethod]
50+
public void TestSimpleLinearGradient()
51+
{
52+
var gradient = LinearGradient("linear-gradient(to right, blue, red)");
53+
Assert.AreEqual(2, gradient.GradientStops.Count);
54+
Assert.AreEqual(0.0, gradient.GradientStops[0].Offset);
55+
Assert.AreEqual(1.0, gradient.GradientStops[1].Offset);
56+
Assert.AreEqual(90, GetAngle(gradient));
57+
}
58+
59+
[TestMethod]
60+
public void TestAngle()
61+
{
62+
var gradient = LinearGradient("linear-gradient(45deg, blue, red)");
63+
Assert.AreEqual(45, GetAngle(gradient));
64+
}
65+
66+
[TestMethod]
67+
public void TestIndeterminate()
68+
{
69+
var gradient = LinearGradient("linear-gradient(blue 10%, green, yellow, red 80%)");
70+
Assert.AreEqual(4, gradient.GradientStops.Count);
71+
Assert.AreEqual(0.1, gradient.GradientStops[0].Offset);
72+
Assert.AreEqual(0.33, gradient.GradientStops[1].Offset, 0.01);
73+
Assert.AreEqual(0.56, gradient.GradientStops[2].Offset, 0.01);
74+
Assert.AreEqual(0.8, gradient.GradientStops[3].Offset);
75+
}
76+
77+
[TestMethod]
78+
public void TestSimpleRadialGradient()
79+
{
80+
var gradient = RadialGradient("radial-gradient(blue, red)");
81+
Assert.AreEqual(2, gradient.GradientStops.Count);
82+
Assert.AreEqual(0.0, gradient.GradientStops[0].Offset);
83+
Assert.AreEqual(1.0, gradient.GradientStops[1].Offset);
84+
}
85+
86+
}
87+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Diagnostics;
2+
3+
using AngleSharp;
4+
using AngleSharp.Css;
5+
using AngleSharp.Css.Converters;
6+
using AngleSharp.Css.Values;
7+
8+
namespace FancyWM.ThemeEngine.Extensions
9+
{
10+
internal static class CustomDeclarationFactory
11+
{
12+
public static IConfiguration WithCustomDeclarationFactory(this IConfiguration config)
13+
{
14+
var factory = config.Services
15+
.OfType<DefaultDeclarationFactory>()
16+
.First();
17+
18+
try { factory.Unregister(PropertyNames.Filter); } catch { }
19+
20+
var converter = ValueConverters.Or(
21+
new DropShadowConverter(),
22+
new StandardValueConverter(new Constant<object>(CssKeywords.None, null!))
23+
);
24+
25+
factory.Register(PropertyNames.Filter, new DeclarationInfo(
26+
PropertyNames.Filter,
27+
converter,
28+
PropertyFlags.Animatable,
29+
new Constant<object>(CssKeywords.None, null!)
30+
));
31+
32+
Debug.Assert(factory.Create(PropertyNames.Filter).Flags == PropertyFlags.Animatable);
33+
return config;
34+
}
35+
}
36+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using AngleSharp.Css;
2+
using AngleSharp.Css.Dom;
3+
using AngleSharp.Css.Parser;
4+
using AngleSharp.Css.Values;
5+
using AngleSharp.Text;
6+
7+
namespace FancyWM.ThemeEngine.Extensions
8+
{
9+
public sealed class CssDropShadowFilterValue : ICssValue
10+
{
11+
public const string FunctionName = "drop-shadow";
12+
public required string CssText { get; init; }
13+
public ICssValue? OffsetX { get; init; }
14+
public ICssValue? OffsetY { get; init; }
15+
public ICssValue? BlurRadius { get; init; }
16+
public Color? Color { get; init; }
17+
}
18+
19+
public sealed class DropShadowConverter : IValueConverter
20+
{
21+
public ICssValue? Convert(StringSource source)
22+
{
23+
var pos = source.Index;
24+
25+
var ident = source.ParseIdent();
26+
if (ident == null || !ident.Isi(CssDropShadowFilterValue.FunctionName) || source.Current != Symbols.RoundBracketOpen)
27+
{
28+
source.BackTo(pos);
29+
return null;
30+
}
31+
32+
source.SkipCurrentAndSpaces();
33+
34+
var offsetX = source.ParseLength();
35+
if (offsetX == null) { source.BackTo(pos); return null; }
36+
source.SkipSpacesAndComments();
37+
38+
var offsetY = source.ParseLength();
39+
if (offsetY == null) { source.BackTo(pos); return null; }
40+
source.SkipSpacesAndComments();
41+
42+
ICssValue? blur = source.ParseLength();
43+
if (blur != null) source.SkipSpacesAndComments();
44+
45+
Color? color = null;
46+
if (source.Current != Symbols.RoundBracketClose)
47+
{
48+
color = source.ParseColor();
49+
source.SkipSpacesAndComments();
50+
}
51+
52+
if (source.Current != Symbols.RoundBracketClose)
53+
{
54+
source.BackTo(pos);
55+
return null;
56+
}
57+
58+
source.SkipCurrentAndSpaces();
59+
return new CssDropShadowFilterValue
60+
{
61+
CssText = source.Content,
62+
OffsetX = offsetX,
63+
OffsetY = offsetY,
64+
BlurRadius = blur,
65+
Color = color,
66+
};
67+
}
68+
69+
public ICssValue? Collect(IEnumerable<ICssProperty> properties) => null;
70+
}
71+
}

0 commit comments

Comments
 (0)