diff --git a/Bloxstrap/App.xaml b/Bloxstrap/App.xaml
index ae4bfac..2029b34 100644
--- a/Bloxstrap/App.xaml
+++ b/Bloxstrap/App.xaml
@@ -11,6 +11,8 @@
+
+
pack://application:,,,/Resources/Fonts/#Rubik Light
diff --git a/Bloxstrap/Bloxstrap.csproj b/Bloxstrap/Bloxstrap.csproj
index 8e88aa5..bb30ff6 100644
--- a/Bloxstrap/Bloxstrap.csproj
+++ b/Bloxstrap/Bloxstrap.csproj
@@ -25,9 +25,14 @@
+
+
+
+
+
@@ -49,6 +54,7 @@
+
@@ -59,6 +65,7 @@
+
diff --git a/Bloxstrap/Enums/BootstrapperStyle.cs b/Bloxstrap/Enums/BootstrapperStyle.cs
index 5c5f6fd..c638858 100644
--- a/Bloxstrap/Enums/BootstrapperStyle.cs
+++ b/Bloxstrap/Enums/BootstrapperStyle.cs
@@ -10,6 +10,7 @@
ByfronDialog,
[EnumName(StaticName = "Bloxstrap")]
FluentDialog,
- FluentAeroDialog
+ FluentAeroDialog,
+ CustomDialog
}
}
diff --git a/Bloxstrap/Enums/CustomThemeTemplate.cs b/Bloxstrap/Enums/CustomThemeTemplate.cs
new file mode 100644
index 0000000..6497907
--- /dev/null
+++ b/Bloxstrap/Enums/CustomThemeTemplate.cs
@@ -0,0 +1,8 @@
+namespace Bloxstrap.Enums
+{
+ public enum CustomThemeTemplate
+ {
+ Blank,
+ Simple
+ }
+}
diff --git a/Bloxstrap/Exceptions/CustomThemeException.cs b/Bloxstrap/Exceptions/CustomThemeException.cs
new file mode 100644
index 0000000..68ad0a1
--- /dev/null
+++ b/Bloxstrap/Exceptions/CustomThemeException.cs
@@ -0,0 +1,60 @@
+using Bloxstrap.Extensions;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Bloxstrap.Exceptions
+{
+ internal class CustomThemeException : Exception
+ {
+ ///
+ /// The exception message in English (for logging)
+ ///
+ public string EnglishMessage { get; } = null!;
+
+ public CustomThemeException(string translationString)
+ : base(Strings.ResourceManager.GetStringSafe(translationString))
+ {
+ EnglishMessage = Strings.ResourceManager.GetStringSafe(translationString, new CultureInfo("en-GB"));
+ }
+
+ public CustomThemeException(Exception innerException, string translationString)
+ : base(Strings.ResourceManager.GetStringSafe(translationString), innerException)
+ {
+ EnglishMessage = Strings.ResourceManager.GetStringSafe(translationString, new CultureInfo("en-GB"));
+ }
+
+ public CustomThemeException(string translationString, params object?[] args)
+ : base(string.Format(Strings.ResourceManager.GetStringSafe(translationString), args))
+ {
+ EnglishMessage = string.Format(Strings.ResourceManager.GetStringSafe(translationString, new CultureInfo("en-GB")), args);
+ }
+
+ public CustomThemeException(Exception innerException, string translationString, params object?[] args)
+ : base(string.Format(Strings.ResourceManager.GetStringSafe(translationString), args), innerException)
+ {
+ EnglishMessage = string.Format(Strings.ResourceManager.GetStringSafe(translationString, new CultureInfo("en-GB")), args);
+ }
+
+ public override string ToString()
+ {
+ StringBuilder sb = new StringBuilder(GetType().ToString());
+
+ if (!string.IsNullOrEmpty(Message))
+ sb.Append($": {Message}");
+
+ if (!string.IsNullOrEmpty(EnglishMessage) && Message != EnglishMessage)
+ sb.Append($" ({EnglishMessage})");
+
+ if (InnerException != null)
+ sb.Append($"\r\n ---> {InnerException}\r\n ");
+
+ if (StackTrace != null)
+ sb.Append($"\r\n{StackTrace}");
+
+ return sb.ToString();
+ }
+ }
+}
diff --git a/Bloxstrap/Extensions/BootstrapperStyleEx.cs b/Bloxstrap/Extensions/BootstrapperStyleEx.cs
index 3802264..693c78e 100644
--- a/Bloxstrap/Extensions/BootstrapperStyleEx.cs
+++ b/Bloxstrap/Extensions/BootstrapperStyleEx.cs
@@ -13,7 +13,8 @@
BootstrapperStyle.ProgressDialog,
BootstrapperStyle.LegacyDialog2011,
BootstrapperStyle.LegacyDialog2008,
- BootstrapperStyle.VistaDialog
+ BootstrapperStyle.VistaDialog,
+ BootstrapperStyle.CustomDialog
};
}
}
diff --git a/Bloxstrap/Extensions/CustomThemeTemplateEx.cs b/Bloxstrap/Extensions/CustomThemeTemplateEx.cs
new file mode 100644
index 0000000..4088f02
--- /dev/null
+++ b/Bloxstrap/Extensions/CustomThemeTemplateEx.cs
@@ -0,0 +1,10 @@
+namespace Bloxstrap.Extensions
+{
+ static class CustomThemeTemplateEx
+ {
+ public static string GetFileName(this CustomThemeTemplate template)
+ {
+ return $"CustomBootstrapperTemplate_{template}.xml";
+ }
+ }
+}
diff --git a/Bloxstrap/Models/Persistable/Settings.cs b/Bloxstrap/Models/Persistable/Settings.cs
index d640fc3..a239f31 100644
--- a/Bloxstrap/Models/Persistable/Settings.cs
+++ b/Bloxstrap/Models/Persistable/Settings.cs
@@ -17,6 +17,7 @@ namespace Bloxstrap.Models.Persistable
public bool UseFastFlagManager { get; set; } = true;
public bool WPFSoftwareRender { get; set; } = false;
public bool EnableAnalytics { get; set; } = true;
+ public string? SelectedCustomTheme { get; set; } = null;
// integration configuration
public bool EnableActivityTracking { get; set; } = true;
diff --git a/Bloxstrap/Paths.cs b/Bloxstrap/Paths.cs
index 43d4a1c..1205d67 100644
--- a/Bloxstrap/Paths.cs
+++ b/Bloxstrap/Paths.cs
@@ -22,6 +22,7 @@
public static string Integrations { get; private set; } = "";
public static string Versions { get; private set; } = "";
public static string Modifications { get; private set; } = "";
+ public static string CustomThemes { get; private set; } = "";
public static string Application { get; private set; } = "";
@@ -37,6 +38,7 @@
Integrations = Path.Combine(Base, "Integrations");
Versions = Path.Combine(Base, "Versions");
Modifications = Path.Combine(Base, "Modifications");
+ CustomThemes = Path.Combine(Base, "CustomThemes");
Application = Path.Combine(Base, $"{App.ProjectName}.exe");
}
diff --git a/Bloxstrap/Resource.cs b/Bloxstrap/Resource.cs
index 46c8e82..612d846 100644
--- a/Bloxstrap/Resource.cs
+++ b/Bloxstrap/Resource.cs
@@ -21,5 +21,10 @@ namespace Bloxstrap
await stream.CopyToAsync(memoryStream);
return memoryStream.ToArray();
}
+
+ public static async Task GetString(string name)
+ {
+ return Encoding.UTF8.GetString(await Get(name));
+ }
}
}
diff --git a/Bloxstrap/Resources/CustomBootstrapperSchema.json b/Bloxstrap/Resources/CustomBootstrapperSchema.json
new file mode 100644
index 0000000..44b6631
--- /dev/null
+++ b/Bloxstrap/Resources/CustomBootstrapperSchema.json
@@ -0,0 +1,521 @@
+{
+ "Elements": {
+ "FrameworkElement": {
+ "IsCreatable": false,
+ "Attributes": {
+ "Name": "string",
+ "Visibility": "Visibility",
+ "IsEnabled": "bool",
+ "Margin": "Thickness",
+ "Height": "double",
+ "Width": "double",
+ "HorizontalAlignment": "HorizontalAlignment",
+ "VerticalAlignment": "VerticalAlignment",
+ "RenderTransform": "Transform",
+ "LayoutTransform": "Transform",
+ "Opacity": "double",
+ "OpacityMask": "Brush",
+ "RenderTransformOrigin": "Point",
+ "Panel.ZIndex": "int",
+ "Grid.Row": "int",
+ "Grid.RowSpan": "int",
+ "Grid.Column": "int",
+ "Grid.ColumnSpan": "int"
+ }
+ },
+ "Control": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": false,
+ "Attributes": {
+ "Padding": "Thickness",
+ "BorderThickness": "Thickness",
+ "Foreground": "Brush",
+ "Background": "Brush",
+ "BorderBrush": "Brush",
+ "FontSize": "double",
+ "FontWeight": "FontWeight",
+ "FontStyle": "FontStyle",
+ "FontFamily": "FontFamily"
+ }
+ },
+ "BloxstrapCustomBootstrapper": {
+ "SuperClass": "Control",
+ "IsCreatable": true,
+ "Attributes": {
+ "Version": "int",
+ "Theme": "Theme",
+ "Title": "string",
+ "IgnoreTitleBarInset": "bool",
+ "WindowCornerPreference": "WindowCornerPreference"
+ }
+ },
+ "TitleBar": {
+ "SuperClass": "Control",
+ "IsCreatable": true,
+ "Attributes": {
+ "ShowMinimize": "bool",
+ "ShowClose": "bool",
+ "Title": "string"
+ }
+ },
+ "Button": {
+ "SuperClass": "Control",
+ "IsCreatable": true,
+ "Attributes": {
+ "Content": "object"
+ }
+ },
+ "RangeBase": {
+ "SuperClass": "Control",
+ "IsCreatable": false,
+ "Attributes": {
+ "Value": "double",
+ "Maximum": "double"
+ }
+ },
+ "ProgressBar": {
+ "SuperClass": "RangeBase",
+ "IsCreatable": true,
+ "Attributes": {
+ "IsIndeterminate": "bool",
+ "CornerRadius": "CornerRadius",
+ "IndicatorCornerRadius": "CornerRadius"
+ }
+ },
+ "ProgressRing": {
+ "SuperClass": "RangeBase",
+ "IsCreatable": true,
+ "Attributes": {
+ "IsIndeterminate": "bool"
+ }
+ },
+ "TextBlock": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "Text": "string",
+ "Foreground": "Brush",
+ "Background": "Brush",
+ "FontSize": "double",
+ "FontWeight": "FontWeight",
+ "FontStyle": "FontStyle",
+ "FontFamily": "FontFamily",
+ "LineHeight": "double",
+ "LineStackingStrategy": "LineStackingStrategy",
+ "TextAlignment": "TextAlignment",
+ "TextTrimming": "TextTrimming",
+ "TextWrapping": "TextWrapping",
+ "TextDecorations": "TextDecorations",
+ "IsHyphenationEnabled": "bool",
+ "BaselineOffset": "double",
+ "Padding": "Thickness"
+ }
+ },
+ "MarkdownTextBlock": {
+ "SuperClass": "TextBlock",
+ "IsCreatable": true,
+ "Attributes": {}
+ },
+ "Image": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "Stretch": "Stretch",
+ "StretchDirection": "StretchDirection",
+ "Source": "ImageSource",
+ "IsAnimated": "bool"
+ }
+ },
+ "Grid": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "RowDefinitions": "object",
+ "ColumnDefinitions": "object"
+ }
+ },
+ "StackPanel": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "Orientation": "Orientation"
+ }
+ },
+ "Border": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "Background": "Brush",
+ "BorderBrush": "Brush",
+ "BorderThickness": "Thickness",
+ "Padding": "Thickness",
+ "CornerRadius": "CornerRadius"
+ }
+ },
+ "RowDefinition": {
+ "IsCreatable": true,
+ "Attributes": {
+ "Height": "GridLength",
+ "MinHeight": "double",
+ "MaxHeight": "double"
+ }
+ },
+ "ColumnDefinition": {
+ "IsCreatable": true,
+ "Attributes": {
+ "Width": "GridLength",
+ "MinWidth": "double",
+ "MaxWidth": "double"
+ }
+ },
+ "ScaleTransform": {
+ "IsCreatable": true,
+ "Attributes": {
+ "ScaleX": "double",
+ "ScaleY": "double",
+ "CenterX": "double",
+ "CenterY": "double"
+ }
+ },
+ "SkewTransform": {
+ "IsCreatable": true,
+ "Attributes": {
+ "AngleX": "double",
+ "AngleY": "double",
+ "CenterX": "double",
+ "CenterY": "double"
+ }
+ },
+ "RotateTransform": {
+ "IsCreatable": true,
+ "Attributes": {
+ "Angle": "double",
+ "CenterX": "double",
+ "CenterY": "double"
+ }
+ },
+ "TranslateTransform": {
+ "IsCreatable": true,
+ "Attributes": {
+ "X": "double",
+ "Y": "double"
+ }
+ },
+ "Brush": {
+ "IsCreatable": false,
+ "Attributes": {
+ "Opacity": "double"
+ }
+ },
+ "SolidColorBrush": {
+ "SuperClass": "Brush",
+ "IsCreatable": true,
+ "Attributes": {
+ "Color": "Color"
+ }
+ },
+ "ImageBrush": {
+ "SuperClass": "Brush",
+ "IsCreatable": true,
+ "Attributes": {
+ "AlignmentX": "AlignmentX",
+ "AlignmentY": "AlignmentY",
+ "Stretch": "Stretch",
+ "TileMode": "TileMode",
+ "ViewboxUnits": "BrushMappingMode",
+ "ViewportUnits": "BrushMappingMode",
+ "Viewbox": "Rect",
+ "Viewport": "Rect",
+ "ImageSource": "ImageSource"
+ }
+ },
+ "LinearGradientBrush": {
+ "SuperClass": "Brush",
+ "IsCreatable": true,
+ "Attributes": {
+ "StartPoint": "Point",
+ "EndPoint": "Point",
+ "ColorInterpolationMode": "ColorInterpolationMode",
+ "MappingMode": "BrushMappingMode",
+ "SpreadMethod": "GradientSpreadMethod"
+ }
+ },
+ "GradientStop": {
+ "IsCreatable": true,
+ "Attributes": {
+ "Color": "Color",
+ "Offset": "double"
+ }
+ },
+ "Shape": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": false,
+ "Attributes": {
+ "Fill": "Brush",
+ "Stroke": "Brush",
+ "Stretch": "Stretch",
+ "StrokeDashCap": "PenLineCap",
+ "StrokeDashOffset": "double",
+ "StrokeEndLineCap": "PenLineCap",
+ "StrokeLineJoin": "PenLineJoin",
+ "StrokeMiterLimit": "double",
+ "StrokeStartLineCap": "PenLineCap",
+ "StrokeThickness": "double"
+ }
+ },
+ "Ellipse": {
+ "SuperClass": "Shape",
+ "IsCreatable": true,
+ "Attributes": {}
+ },
+ "Line": {
+ "SuperClass": "Shape",
+ "IsCreatable": true,
+ "Attributes": {
+ "X1": "double",
+ "X2": "double",
+ "Y1": "double",
+ "Y2": "double"
+ }
+ },
+ "Rectangle": {
+ "SuperClass": "Shape",
+ "IsCreatable": true,
+ "Attributes": {
+ "RadiusX": "double",
+ "RadiusY": "double"
+ }
+ },
+ "BlurEffect": {
+ "IsCreatable": true,
+ "Attributes": {
+ "KernelType": "KernelType",
+ "Radius": "double",
+ "RenderingBias": "RenderingBias"
+ }
+ },
+ "DropShadowEffect": {
+ "IsCreatable": true,
+ "Attributes": {
+ "BlurRadius": "double",
+ "Direction": "double",
+ "Opacity": "double",
+ "ShadowDepth": "double",
+ "RenderingBias": "RenderingBias",
+ "Color": "Color"
+ }
+ }
+ },
+ "Types": {
+ "string": {},
+ "bool": {
+ "Values": [
+ "True",
+ "False"
+ ]
+ },
+ "int": {},
+ "double": {},
+ "object": { "CanHaveElement": true },
+ "Thickness": {},
+ "Rect": {},
+ "Point": {},
+ "CornerRadius": {},
+ "Brush": { "CanHaveElement": true },
+ "Color": {},
+ "ImageSource": {},
+ "Transform": { "CanHaveElement": true },
+ "FontFamily": {},
+ "GridLength": {},
+ "Visibility": {
+ "Values": [
+ "Visible",
+ "Hidden",
+ "Collapsed"
+ ]
+ },
+ "HorizontalAlignment": {
+ "Values": [
+ "Left",
+ "Center",
+ "Right",
+ "Stretch"
+ ]
+ },
+ "VerticalAlignment": {
+ "Values": [
+ "Top",
+ "Center",
+ "Bottom",
+ "Stretch"
+ ]
+ },
+ "Theme": {
+ "Values": [
+ "Default",
+ "Dark",
+ "Light"
+ ]
+ },
+ "FontWeight": {
+ "Values": [
+ "Thin",
+ "ExtraLight",
+ "UltraLight",
+ "Medium",
+ "Normal",
+ "Regular",
+ "DemiBold",
+ "SemiBold",
+ "Bold",
+ "ExtraBold",
+ "UltraBold",
+ "Black",
+ "Heavy",
+ "ExtraBlack",
+ "ExtraHeavy"
+ ]
+ },
+ "FontStyle": {
+ "Values": [
+ "Normal",
+ "Italic",
+ "Oblique"
+ ]
+ },
+ "LineStackingStrategy": {
+ "Values": [
+ "BlockLineHeight",
+ "MaxHeight"
+ ]
+ },
+ "TextAlignment": {
+ "Values": [
+ "Left",
+ "Right",
+ "Center",
+ "Justify"
+ ]
+ },
+ "TextTrimming": {
+ "Values": [
+ "None",
+ "CharacterEllipsis",
+ "WordEllipsis"
+ ]
+ },
+ "TextWrapping": {
+ "Values": [
+ "WrapWithOverflow",
+ "NoWrap",
+ "Wrap"
+ ]
+ },
+ "TextDecorations": {
+ "Values": [
+ "Baseline",
+ "OverLine",
+ "Strikethrough",
+ "Underline"
+ ]
+ },
+ "Stretch": {
+ "Values": [
+ "None",
+ "Fill",
+ "Uniform",
+ "UniformToFill"
+ ]
+ },
+ "StretchDirection": {
+ "Values": [
+ "UpOnly",
+ "DownOnly",
+ "Both"
+ ]
+ },
+ "AlignmentX": {
+ "Values": [
+ "Left",
+ "Center",
+ "Right"
+ ]
+ },
+ "AlignmentY": {
+ "Values": [
+ "Top",
+ "Center",
+ "Bottom"
+ ]
+ },
+ "TileMode": {
+ "Values": [
+ "None",
+ "FlipX",
+ "FlipY",
+ "FlipXY",
+ "Tile"
+ ]
+ },
+ "BrushMappingMode": {
+ "Values": [
+ "Absolute",
+ "RelativeToBoundingBox"
+ ]
+ },
+ "ColorInterpolationMode": {
+ "Values": [
+ "ScRgbLinearInterpolation",
+ "SRgbLinearInterpolation"
+ ]
+ },
+ "GradientSpreadMethod": {
+ "Values": [
+ "Pad",
+ "Reflect",
+ "Repeat"
+ ]
+ },
+ "PenLineCap": {
+ "Values": [
+ "Flat",
+ "Square",
+ "Round",
+ "Triangle"
+ ]
+ },
+ "PenLineJoin": {
+ "Values": [
+ "Miter",
+ "Bevel",
+ "Round"
+ ]
+ },
+ "KernelType": {
+ "Values": [
+ "Gaussian",
+ "Box"
+ ]
+ },
+ "RenderingBias": {
+ "Values": [
+ "Performance",
+ "Quality"
+ ]
+ },
+ "Orientation": {
+ "Values": [
+ "Horizontal",
+ "Vertical"
+ ]
+ },
+ "WindowCornerPreference": {
+ "Values": [
+ "Default",
+ "DoNotRound",
+ "Round",
+ "RoundSmall"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml b/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml
new file mode 100644
index 0000000..99efce9
--- /dev/null
+++ b/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml b/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml
new file mode 100644
index 0000000..7664806
--- /dev/null
+++ b/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/Resources/Strings.Designer.cs b/Bloxstrap/Resources/Strings.Designer.cs
index e80603d..e84f7f9 100644
--- a/Bloxstrap/Resources/Strings.Designer.cs
+++ b/Bloxstrap/Resources/Strings.Designer.cs
@@ -441,6 +441,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Create New.
+ ///
+ public static string Common_CreateNew {
+ get {
+ return ResourceManager.GetString("Common.CreateNew", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Custom.
///
@@ -477,6 +486,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Edit.
+ ///
+ public static string Common_Edit {
+ get {
+ return ResourceManager.GetString("Common.Edit", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Export.
///
@@ -495,6 +513,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Import.
+ ///
+ public static string Common_Import {
+ get {
+ return ResourceManager.GetString("Common.Import", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Import from file.
///
@@ -630,6 +657,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Rename.
+ ///
+ public static string Common_Rename {
+ get {
+ return ResourceManager.GetString("Common.Rename", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Reset.
///
@@ -684,6 +720,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Template.
+ ///
+ public static string Common_Template {
+ get {
+ return ResourceManager.GetString("Common.Template", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Value.
///
@@ -847,6 +892,385 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to File must be a ZIP.
+ ///
+ public static string CustomTheme_Add_Errors_FileNotZip {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.FileNotZip", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Name cannot be empty.
+ ///
+ public static string CustomTheme_Add_Errors_NameEmpty {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.NameEmpty", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Name contains illegal characters.
+ ///
+ public static string CustomTheme_Add_Errors_NameIllegalCharacters {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.NameIllegalCharacters", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Name cannot be used.
+ ///
+ public static string CustomTheme_Add_Errors_NameReserved {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.NameReserved", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Name is already in use.
+ ///
+ public static string CustomTheme_Add_Errors_NameTaken {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.NameTaken", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Unknown error.
+ ///
+ public static string CustomTheme_Add_Errors_Unknown {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.Unknown", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Invalid or corrupted ZIP file.
+ ///
+ public static string CustomTheme_Add_Errors_ZipInvalidData {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.ZipInvalidData", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Theme file could not be found in the ZIP file.
+ ///
+ public static string CustomTheme_Add_Errors_ZipMissingThemeFile {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.ZipMissingThemeFile", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Add Custom Theme.
+ ///
+ public static string CustomTheme_Add_Title {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Title", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Save changes to {0}?.
+ ///
+ public static string CustomTheme_Editor_ConfirmSave {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.ConfirmSave", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Open Theme Directory.
+ ///
+ public static string CustomTheme_Editor_OpenThemeDirectory {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.OpenThemeDirectory", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Preview.
+ ///
+ public static string CustomTheme_Editor_Preview {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Preview", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Save.
+ ///
+ public static string CustomTheme_Editor_Save {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Save", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to An error occurred while saving your theme..
+ ///
+ public static string CustomTheme_Editor_Save_Error {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Save.Error", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Theme successfully saved!.
+ ///
+ public static string CustomTheme_Editor_Save_Success {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Save.Success", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Editing "{0}".
+ ///
+ public static string CustomTheme_Editor_Title {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Title", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Custom dialog has already been initialised.
+ ///
+ public static string CustomTheme_Errors_DialogAlreadyInitialised {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.DialogAlreadyInitialised", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} uses blacklisted scheme {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeBlacklistedUriScheme {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeBlacklistedUriScheme", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} has invalid {1}: {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeConversionError {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeConversionError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} {1} is not a valid {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeInvalidType {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeInvalidType", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Element {0} is missing the {1} attribute.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMissing {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMissing", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} is missing it's child.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMissingChild {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMissingChild", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} can only have one child.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMultipleChildren {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMultipleChildren", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} can only have one {1} defined.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMultipleDefinitions {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMultipleDefinitions", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} {1} must be larger than {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMustBeLargerThanMin {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMustBeLargerThanMin", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} {1} must be smaller than {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMustBeSmallerThanMax {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMustBeSmallerThanMax", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} could not be parsed into a {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeParseError {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeParseError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} {2} is null.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeParseErrorNull {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeParseErrorNull", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} cannot have a child of {1}.
+ ///
+ public static string CustomTheme_Errors_ElementInvalidChild {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementInvalidChild", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} can only have one child.
+ ///
+ public static string CustomTheme_Errors_ElementMultipleChildren {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementMultipleChildren", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} failed to create {1}: {2}.
+ ///
+ public static string CustomTheme_Errors_ElementTypeCreationFailed {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementTypeCreationFailed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Theme XML root is not {0}.
+ ///
+ public static string CustomTheme_Errors_InvalidRoot {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.InvalidRoot", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No custom theme selected.
+ ///
+ public static string CustomTheme_Errors_NoThemeSelected {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.NoThemeSelected", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to setup custom bootstrapper: {0}.
+ ///Defaulting to Fluent..
+ ///
+ public static string CustomTheme_Errors_SetupFailed {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.SetupFailed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Custom bootstrappers can only have a maximum of {0} elements, got {1}..
+ ///
+ public static string CustomTheme_Errors_TooManyElements {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.TooManyElements", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Unknown element {0}.
+ ///
+ public static string CustomTheme_Errors_UnknownElement {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.UnknownElement", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} Unknown {1} {2}.
+ ///
+ public static string CustomTheme_Errors_UnknownEnumValue {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.UnknownEnumValue", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} version is not a number.
+ ///
+ public static string CustomTheme_Errors_VersionNotNumber {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.VersionNotNumber", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} version {1} is not recognised.
+ ///
+ public static string CustomTheme_Errors_VersionNotRecognised {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.VersionNotRecognised", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} version is not set.
+ ///
+ public static string CustomTheme_Errors_VersionNotSet {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.VersionNotSet", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} version {1} is no longer supported.
+ ///
+ public static string CustomTheme_Errors_VersionNotSupported {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.VersionNotSupported", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to parse the theme file: {0}.
+ ///
+ public static string CustomTheme_Errors_XMLParseFailed {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.XMLParseFailed", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Add Fast Flag.
///
@@ -1143,6 +1567,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Custom.
+ ///
+ public static string Enums_BootstrapperStyle_CustomDialog {
+ get {
+ return ResourceManager.GetString("Enums.BootstrapperStyle.CustomDialog", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Bloxstrap (Glass).
///
@@ -1206,6 +1639,24 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Blank.
+ ///
+ public static string Enums_CustomThemeTemplate_Blank {
+ get {
+ return ResourceManager.GetString("Enums.CustomThemeTemplate.Blank", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Simple.
+ ///
+ public static string Enums_CustomThemeTemplate_Simple {
+ get {
+ return ResourceManager.GetString("Enums.CustomThemeTemplate.Simple", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Catmoji.
///
@@ -1806,6 +2257,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Apache License 2.0.
+ ///
+ public static string Menu_About_Licenses_Apache {
+ get {
+ return ResourceManager.GetString("Menu.About.Licenses.Apache", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to BSD 2-Clause License.
///
@@ -1950,6 +2410,33 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Failed to delete custom theme {0}: {1}.
+ ///
+ public static string Menu_Appearance_CustomThemes_DeleteFailed {
+ get {
+ return ResourceManager.GetString("Menu.Appearance.CustomThemes.DeleteFailed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No custom theme selected..
+ ///
+ public static string Menu_Appearance_CustomThemes_NoneSelected {
+ get {
+ return ResourceManager.GetString("Menu.Appearance.CustomThemes.NoneSelected", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to rename custom theme {0}: {1}.
+ ///
+ public static string Menu_Appearance_CustomThemes_RenameFailed {
+ get {
+ return ResourceManager.GetString("Menu.Appearance.CustomThemes.RenameFailed", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Configure how Bloxstrap should look..
///
diff --git a/Bloxstrap/Resources/Strings.resx b/Bloxstrap/Resources/Strings.resx
index 9048916..8ac1611 100644
--- a/Bloxstrap/Resources/Strings.resx
+++ b/Bloxstrap/Resources/Strings.resx
@@ -1239,6 +1239,9 @@ Would you like to enable test mode?
Version {0}
+
+ Custom
+
Bloxstrap tried to upgrade Roblox but can't because Roblox's files are still in use.
@@ -1282,4 +1285,164 @@ Please close any applications that may be using Roblox's files, and relaunch.
Not all modifications will be present in the current launch.
+
+ Apache License 2.0
+
+
+ Blank
+
+
+ Simple
+
+
+ Theme XML root is not {0}
+
+
+ Custom dialog has already been initialised
+
+
+ Custom bootstrappers can only have a maximum of {0} elements, got {1}.
+
+
+ {0} version is not set
+
+
+ {0} version is not a number
+
+
+ {0} version {1} is no longer supported
+
+
+ {0} version {1} is not recognised
+
+
+ {0} cannot have a child of {1}
+
+
+ Unknown element {0}
+
+
+ Failed to parse the theme file: {0}
+
+
+ {0} has invalid {1}: {2}
+
+
+ Element {0} is missing the {1} attribute
+
+
+ {0} {1} is not a valid {2}
+
+
+ {0} {1} must be larger than {2}
+
+
+ {0} {1} must be smaller than {2}
+
+
+ {0} Unknown {1} {2}
+
+
+ {0} can only have one {1} defined
+
+
+ {0}.{1} can only have one child
+
+
+ {0} can only have one child
+
+
+ {0}.{1} is missing it's child
+
+
+ {0}.{1} could not be parsed into a {2}
+
+
+ {0}.{1} {2} is null
+
+
+ {0}.{1} uses blacklisted scheme {2}
+
+
+ {0} failed to create {1}: {2}
+
+
+ Editing "{0}"
+
+
+ Theme successfully saved!
+
+
+ An error occurred while saving your theme.
+
+
+ Save changes to {0}?
+
+
+ Save
+
+
+ Preview
+
+
+ Open Theme Directory
+
+
+ Create New
+
+
+ Import
+
+
+ Add Custom Theme
+
+
+ Template
+
+
+ Name cannot be empty
+
+
+ Name contains illegal characters
+
+
+ Name cannot be used
+
+
+ Unknown error
+
+
+ Name is already in use
+
+
+ File must be a ZIP
+
+
+ Theme file could not be found in the ZIP file
+
+
+ Invalid or corrupted ZIP file
+
+
+ No custom theme selected
+
+
+ Failed to setup custom bootstrapper: {0}.
+Defaulting to Fluent.
+
+
+ No custom theme selected.
+
+
+ Rename
+
+
+ Edit
+
+
+ Failed to delete custom theme {0}: {1}
+
+
+ Failed to rename custom theme {0}: {1}
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/Elements/About/MainWindow.xaml b/Bloxstrap/UI/Elements/About/MainWindow.xaml
index cf9daf9..4b134a3 100644
--- a/Bloxstrap/UI/Elements/About/MainWindow.xaml
+++ b/Bloxstrap/UI/Elements/About/MainWindow.xaml
@@ -10,8 +10,8 @@
mc:Ignorable="d"
Title="{x:Static resources:Strings.About_Title}"
Background="{ui:ThemeResource ApplicationBackgroundBrush}"
- MinWidth="740"
- Width="740"
+ MinWidth="800"
+ Width="800"
Height="440"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
diff --git a/Bloxstrap/UI/Elements/About/Pages/LicensesPage.xaml b/Bloxstrap/UI/Elements/About/Pages/LicensesPage.xaml
index dbcad7b..f3b44cd 100644
--- a/Bloxstrap/UI/Elements/About/Pages/LicensesPage.xaml
+++ b/Bloxstrap/UI/Elements/About/Pages/LicensesPage.xaml
@@ -43,30 +43,44 @@
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Base/WpfUiWindow.cs b/Bloxstrap/UI/Elements/Base/WpfUiWindow.cs
index db06e7f..9802ab1 100644
--- a/Bloxstrap/UI/Elements/Base/WpfUiWindow.cs
+++ b/Bloxstrap/UI/Elements/Base/WpfUiWindow.cs
@@ -18,9 +18,15 @@ namespace Bloxstrap.UI.Elements.Base
public void ApplyTheme()
{
+ const int customThemeIndex = 2; // index for CustomTheme merged dictionary
+
_themeService.SetTheme(App.Settings.Prop.Theme.GetFinal() == Enums.Theme.Dark ? ThemeType.Dark : ThemeType.Light);
_themeService.SetSystemAccent();
+ // there doesn't seem to be a way to query the name for merged dictionaries
+ var dict = new ResourceDictionary { Source = new Uri($"pack://application:,,,/UI/Style/{Enum.GetName(App.Settings.Prop.Theme.GetFinal())}.xaml") };
+ Application.Current.Resources.MergedDictionaries[customThemeIndex] = dict;
+
#if QA_BUILD
this.BorderBrush = System.Windows.Media.Brushes.Red;
this.BorderThickness = new Thickness(4);
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Converters.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Converters.cs
new file mode 100644
index 0000000..3490e4a
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Converters.cs
@@ -0,0 +1,90 @@
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Media;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ public partial class CustomDialog
+ {
+ // https://stackoverflow.com/a/2961702
+ private static T? ConvertValue(string input) where T : struct
+ {
+ try
+ {
+ var converter = TypeDescriptor.GetConverter(typeof(T));
+ if (converter != null)
+ {
+ return (T?)converter.ConvertFromInvariantString(input);
+ }
+ return default;
+ }
+ catch (NotSupportedException)
+ {
+ return default;
+ }
+ }
+
+ private static object? GetTypeFromXElement(TypeConverter converter, XElement xmlElement, string attributeName)
+ {
+ string? attributeValue = xmlElement.Attribute(attributeName)?.Value?.ToString();
+ if (attributeValue == null)
+ return null;
+
+ try
+ {
+ return converter.ConvertFromInvariantString(attributeValue);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.ElementAttributeConversionError", xmlElement.Name, attributeName, ex.Message);
+ }
+ }
+
+ private static ThicknessConverter ThicknessConverter { get; } = new ThicknessConverter();
+ private static object? GetThicknessFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(ThicknessConverter, xmlElement, attributeName);
+
+ private static RectConverter RectConverter { get; } = new RectConverter();
+ private static object? GetRectFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(RectConverter, xmlElement, attributeName);
+
+ private static ColorConverter ColorConverter { get; } = new ColorConverter();
+ private static object? GetColorFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(ColorConverter, xmlElement, attributeName);
+
+ private static PointConverter PointConverter { get; } = new PointConverter();
+ private static object? GetPointFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(PointConverter, xmlElement, attributeName);
+
+ private static CornerRadiusConverter CornerRadiusConverter { get; } = new CornerRadiusConverter();
+ private static object? GetCornerRadiusFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(CornerRadiusConverter, xmlElement, attributeName);
+
+ private static GridLengthConverter GridLengthConverter { get; } = new GridLengthConverter();
+ private static object? GetGridLengthFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(GridLengthConverter, xmlElement, attributeName);
+
+
+ private static BrushConverter BrushConverter { get; } = new BrushConverter();
+
+ ///
+ /// Return type of string = Name of DynamicResource
+ /// Return type of brush = ... The Brush!!!
+ ///
+ private static object? GetBrushFromXElement(XElement element, string attributeName)
+ {
+ string? value = element.Attribute(attributeName)?.Value?.ToString();
+ if (value == null)
+ return null;
+
+ // dynamic resource name
+ if (value.StartsWith('{') && value.EndsWith('}'))
+ return value[1..^1];
+
+ try
+ {
+ return BrushConverter.ConvertFromInvariantString(value);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.ElementAttributeConversionError", element.Name, attributeName, ex.Message);
+ }
+ }
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs
new file mode 100644
index 0000000..e5f0c29
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs
@@ -0,0 +1,151 @@
+using System.Windows;
+using System.Xml.Linq;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ public partial class CustomDialog
+ {
+ const int Version = 1;
+
+ private class DummyFrameworkElement : FrameworkElement { }
+
+ private const int MaxElements = 100;
+
+ private bool _initialised = false;
+
+ // prevent users from creating elements with the same name multiple times
+ private List UsedNames { get; } = new List();
+
+ private string ThemeDir { get; set; } = "";
+
+ delegate object HandleXmlElementDelegate(CustomDialog dialog, XElement xmlElement);
+
+ private static Dictionary _elementHandlerMap = new Dictionary()
+ {
+ ["BloxstrapCustomBootstrapper"] = HandleXmlElement_BloxstrapCustomBootstrapper_Fake,
+ ["TitleBar"] = HandleXmlElement_TitleBar,
+ ["Button"] = HandleXmlElement_Button,
+ ["ProgressBar"] = HandleXmlElement_ProgressBar,
+ ["ProgressRing"] = HandleXmlElement_ProgressRing,
+ ["TextBlock"] = HandleXmlElement_TextBlock,
+ ["MarkdownTextBlock"] = HandleXmlElement_MarkdownTextBlock,
+ ["Image"] = HandleXmlElement_Image,
+ ["Grid"] = HandleXmlElement_Grid,
+ ["StackPanel"] = HandleXmlElement_StackPanel,
+ ["Border"] = HandleXmlElement_Border,
+
+ ["SolidColorBrush"] = HandleXmlElement_SolidColorBrush,
+ ["ImageBrush"] = HandleXmlElement_ImageBrush,
+ ["LinearGradientBrush"] = HandleXmlElement_LinearGradientBrush,
+
+ ["GradientStop"] = HandleXmlElement_GradientStop,
+
+ ["ScaleTransform"] = HandleXmlElement_ScaleTransform,
+ ["SkewTransform"] = HandleXmlElement_SkewTransform,
+ ["RotateTransform"] = HandleXmlElement_RotateTransform,
+ ["TranslateTransform"] = HandleXmlElement_TranslateTransform,
+
+ ["BlurEffect"] = HandleXmlElement_BlurEffect,
+ ["DropShadowEffect"] = HandleXmlElement_DropShadowEffect,
+
+ ["Ellipse"] = HandleXmlElement_Ellipse,
+ ["Line"] = HandleXmlElement_Line,
+ ["Rectangle"] = HandleXmlElement_Rectangle,
+
+ ["RowDefinition"] = HandleXmlElement_RowDefinition,
+ ["ColumnDefinition"] = HandleXmlElement_ColumnDefinition
+ };
+
+ private static T HandleXml(CustomDialog dialog, XElement xmlElement) where T : class
+ {
+ if (!_elementHandlerMap.ContainsKey(xmlElement.Name.ToString()))
+ throw new CustomThemeException("CustomTheme.Errors.UnknownElement", xmlElement.Name);
+
+ var element = _elementHandlerMap[xmlElement.Name.ToString()](dialog, xmlElement);
+ if (element is not T)
+ throw new CustomThemeException("CustomTheme.Errors.ElementInvalidChild", xmlElement.Parent!.Name, xmlElement.Name);
+
+ return (T)element;
+ }
+
+ private static void AddXml(CustomDialog dialog, XElement xmlElement)
+ {
+ if (xmlElement.Name.ToString().StartsWith($"{xmlElement.Parent!.Name}."))
+ return; // not an xml element
+
+ var uiElement = HandleXml(dialog, xmlElement);
+ if (uiElement is not DummyFrameworkElement)
+ dialog.ElementGrid.Children.Add(uiElement);
+ }
+
+ private static void AssertThemeVersion(string? versionStr)
+ {
+ if (string.IsNullOrEmpty(versionStr))
+ throw new CustomThemeException("CustomTheme.Errors.VersionNotSet", "BloxstrapCustomBootstrapper");
+
+ if (!uint.TryParse(versionStr, out uint version))
+ throw new CustomThemeException("CustomTheme.Errors.VersionNotNumber", "BloxstrapCustomBootstrapper");
+
+ switch (version)
+ {
+ case Version:
+ break;
+ case 0: // Themes made between Oct 19, 2024 to Mar 11, 2025 (on the feature/custom-bootstrappers branch)
+ throw new CustomThemeException("CustomTheme.Errors.VersionNotSupported", "BloxstrapCustomBootstrapper", version);
+ default:
+ throw new CustomThemeException("CustomTheme.Errors.VersionNotRecognised", "BloxstrapCustomBootstrapper", version);
+ }
+ }
+
+ private void HandleXmlBase(XElement xml)
+ {
+ if (_initialised)
+ throw new CustomThemeException("CustomTheme.Errors.DialogAlreadyInitialised");
+
+ if (xml.Name != "BloxstrapCustomBootstrapper")
+ throw new CustomThemeException("CustomTheme.Errors.InvalidRoot", "BloxstrapCustomBootstrapper");
+
+ AssertThemeVersion(xml.Attribute("Version")?.Value);
+
+ if (xml.Descendants().Count() > MaxElements)
+ throw new CustomThemeException("CustomTheme.Errors.TooManyElements", MaxElements, xml.Descendants().Count());
+
+ _initialised = true;
+
+ // handle root
+ HandleXmlElement_BloxstrapCustomBootstrapper(this, xml);
+
+ // handle everything else
+ foreach (var child in xml.Elements())
+ AddXml(this, child);
+ }
+
+ #region Public APIs
+ public void ApplyCustomTheme(string name, string contents)
+ {
+ ThemeDir = System.IO.Path.Combine(Paths.CustomThemes, name);
+
+ XElement xml;
+
+ try
+ {
+ using (MemoryStream ms = new MemoryStream(Encoding.UTF8.GetBytes(contents)))
+ xml = XElement.Load(ms);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.XMLParseFailed", ex.Message);
+ }
+
+ HandleXmlBase(xml);
+ }
+
+ public void ApplyCustomTheme(string name)
+ {
+ string path = System.IO.Path.Combine(Paths.CustomThemes, name, "Theme.xml");
+
+ ApplyCustomTheme(name, File.ReadAllText(path));
+ }
+ #endregion
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Elements.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Elements.cs
new file mode 100644
index 0000000..68a61d0
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Elements.cs
@@ -0,0 +1,772 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Data;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Media.Effects;
+using System.Windows.Shapes;
+using System.Xml.Linq;
+
+using Wpf.Ui.Markup;
+
+using Bloxstrap.UI.Elements.Controls;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ public partial class CustomDialog
+ {
+ #region Transformation
+ private static Transform HandleXmlElement_ScaleTransform(CustomDialog dialog, XElement xmlElement)
+ {
+ var st = new ScaleTransform();
+
+ st.ScaleX = ParseXmlAttribute(xmlElement, "ScaleX", 1);
+ st.ScaleY = ParseXmlAttribute(xmlElement, "ScaleY", 1);
+ st.CenterX = ParseXmlAttribute(xmlElement, "CenterX", 0);
+ st.CenterY = ParseXmlAttribute(xmlElement, "CenterY", 0);
+
+ return st;
+ }
+
+ private static Transform HandleXmlElement_SkewTransform(CustomDialog dialog, XElement xmlElement)
+ {
+ var st = new SkewTransform();
+
+ st.AngleX = ParseXmlAttribute(xmlElement, "AngleX", 0);
+ st.AngleY = ParseXmlAttribute(xmlElement, "AngleY", 0);
+ st.CenterX = ParseXmlAttribute(xmlElement, "CenterX", 0);
+ st.CenterY = ParseXmlAttribute(xmlElement, "CenterY", 0);
+
+ return st;
+ }
+
+ private static Transform HandleXmlElement_RotateTransform(CustomDialog dialog, XElement xmlElement)
+ {
+ var rt = new RotateTransform();
+
+ rt.Angle = ParseXmlAttribute(xmlElement, "Angle", 0);
+ rt.CenterX = ParseXmlAttribute(xmlElement, "CenterX", 0);
+ rt.CenterY = ParseXmlAttribute(xmlElement, "CenterY", 0);
+
+ return rt;
+ }
+
+ private static Transform HandleXmlElement_TranslateTransform(CustomDialog dialog, XElement xmlElement)
+ {
+ var tt = new TranslateTransform();
+
+ tt.X = ParseXmlAttribute(xmlElement, "X", 0);
+ tt.Y = ParseXmlAttribute(xmlElement, "Y", 0);
+
+ return tt;
+ }
+ #endregion
+
+ #region Effects
+ private static BlurEffect HandleXmlElement_BlurEffect(CustomDialog dialog, XElement xmlElement)
+ {
+ var effect = new BlurEffect();
+
+ effect.KernelType = ParseXmlAttribute(xmlElement, "KernelType", KernelType.Gaussian);
+ effect.Radius = ParseXmlAttribute(xmlElement, "Radius", 5);
+ effect.RenderingBias = ParseXmlAttribute(xmlElement, "RenderingBias", RenderingBias.Performance);
+
+ return effect;
+ }
+
+ private static DropShadowEffect HandleXmlElement_DropShadowEffect(CustomDialog dialog, XElement xmlElement)
+ {
+ var effect = new DropShadowEffect();
+
+ effect.BlurRadius = ParseXmlAttribute(xmlElement, "BlurRadius", 5);
+ effect.Direction = ParseXmlAttribute(xmlElement, "Direction", 315);
+ effect.Opacity = ParseXmlAttribute(xmlElement, "Opacity", 1);
+ effect.ShadowDepth = ParseXmlAttribute(xmlElement, "ShadowDepth", 5);
+ effect.RenderingBias = ParseXmlAttribute(xmlElement, "RenderingBias", RenderingBias.Performance);
+
+ var color = GetColorFromXElement(xmlElement, "Color");
+ if (color is Color)
+ effect.Color = (Color)color;
+
+ return effect;
+ }
+ #endregion
+
+ #region Brushes
+ private static void HandleXml_Brush(Brush brush, XElement xmlElement)
+ {
+ brush.Opacity = ParseXmlAttribute(xmlElement, "Opacity", 1.0);
+ }
+
+ private static Brush HandleXmlElement_SolidColorBrush(CustomDialog dialog, XElement xmlElement)
+ {
+ var brush = new SolidColorBrush();
+ HandleXml_Brush(brush, xmlElement);
+
+ object? color = GetColorFromXElement(xmlElement, "Color");
+ if (color is Color)
+ brush.Color = (Color)color;
+
+ return brush;
+ }
+
+ private static Brush HandleXmlElement_ImageBrush(CustomDialog dialog, XElement xmlElement)
+ {
+ var imageBrush = new ImageBrush();
+ HandleXml_Brush(imageBrush, xmlElement);
+
+ imageBrush.AlignmentX = ParseXmlAttribute(xmlElement, "AlignmentX", AlignmentX.Center);
+ imageBrush.AlignmentY = ParseXmlAttribute(xmlElement, "AlignmentY", AlignmentY.Center);
+
+ imageBrush.Stretch = ParseXmlAttribute(xmlElement, "Stretch", Stretch.Fill);
+ imageBrush.TileMode = ParseXmlAttribute(xmlElement, "TileMode", TileMode.None);
+
+ imageBrush.ViewboxUnits = ParseXmlAttribute(xmlElement, "ViewboxUnits", BrushMappingMode.RelativeToBoundingBox);
+ imageBrush.ViewportUnits = ParseXmlAttribute(xmlElement, "ViewportUnits", BrushMappingMode.RelativeToBoundingBox);
+
+ var viewbox = GetRectFromXElement(xmlElement, "Viewbox");
+ if (viewbox is Rect)
+ imageBrush.Viewbox = (Rect)viewbox;
+
+ var viewport = GetRectFromXElement(xmlElement, "Viewport");
+ if (viewport is Rect)
+ imageBrush.Viewport = (Rect)viewport;
+
+ var sourceData = GetImageSourceData(dialog, "ImageSource", xmlElement);
+
+ if (sourceData.IsIcon)
+ {
+ // bind the icon property
+ Binding binding = new Binding("Icon") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(imageBrush, ImageBrush.ImageSourceProperty, binding);
+ }
+ else
+ {
+ BitmapImage bitmapImage;
+ try
+ {
+ bitmapImage = new BitmapImage(sourceData.Uri!);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.ElementTypeCreationFailed", "Image", "BitmapImage", ex.Message);
+ }
+
+ imageBrush.ImageSource = bitmapImage;
+ }
+
+ return imageBrush;
+ }
+
+ private static GradientStop HandleXmlElement_GradientStop(CustomDialog dialog, XElement xmlElement)
+ {
+ var gs = new GradientStop();
+
+ object? color = GetColorFromXElement(xmlElement, "Color");
+ if (color is Color)
+ gs.Color = (Color)color;
+
+ gs.Offset = ParseXmlAttribute(xmlElement, "Offset", 0.0);
+
+ return gs;
+ }
+
+ private static Brush HandleXmlElement_LinearGradientBrush(CustomDialog dialog, XElement xmlElement)
+ {
+ var brush = new LinearGradientBrush();
+ HandleXml_Brush(brush, xmlElement);
+
+ object? startPoint = GetPointFromXElement(xmlElement, "StartPoint");
+ if (startPoint is Point)
+ brush.StartPoint = (Point)startPoint;
+
+ object? endPoint = GetPointFromXElement(xmlElement, "EndPoint");
+ if (endPoint is Point)
+ brush.EndPoint = (Point)endPoint;
+
+ brush.ColorInterpolationMode = ParseXmlAttribute(xmlElement, "ColorInterpolationMode", ColorInterpolationMode.SRgbLinearInterpolation);
+ brush.MappingMode = ParseXmlAttribute(xmlElement, "MappingMode", BrushMappingMode.RelativeToBoundingBox);
+ brush.SpreadMethod = ParseXmlAttribute(xmlElement, "SpreadMethod", GradientSpreadMethod.Pad);
+
+ foreach (var child in xmlElement.Elements())
+ brush.GradientStops.Add(HandleXml(dialog, child));
+
+ return brush;
+ }
+
+ private static void ApplyBrush_UIElement(CustomDialog dialog, FrameworkElement uiElement, string name, DependencyProperty dependencyProperty, XElement xmlElement)
+ {
+ // check if attribute exists
+ object? brushAttr = GetBrushFromXElement(xmlElement, name);
+ if (brushAttr is Brush)
+ {
+ uiElement.SetValue(dependencyProperty, brushAttr);
+ return;
+ }
+ else if (brushAttr is string)
+ {
+ uiElement.SetResourceReference(dependencyProperty, brushAttr);
+ return;
+ }
+
+ // check if element exists
+ var brushElement = xmlElement.Element($"{xmlElement.Name}.{name}");
+ if (brushElement == null)
+ return;
+
+ var first = brushElement.FirstNode as XElement;
+ if (first == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissingChild", xmlElement.Name, name);
+
+ var brush = HandleXml(dialog, first);
+ uiElement.SetValue(dependencyProperty, brush);
+ }
+ #endregion
+
+ #region Shapes
+ private static void HandleXmlElement_Shape(CustomDialog dialog, Shape shape, XElement xmlElement)
+ {
+ HandleXmlElement_FrameworkElement(dialog, shape, xmlElement);
+
+ ApplyBrush_UIElement(dialog, shape, "Fill", Shape.FillProperty, xmlElement);
+ ApplyBrush_UIElement(dialog, shape, "Stroke", Shape.StrokeProperty, xmlElement);
+
+ shape.Stretch = ParseXmlAttribute(xmlElement, "Stretch", Stretch.Fill);
+
+ shape.StrokeDashCap = ParseXmlAttribute(xmlElement, "StrokeDashCap", PenLineCap.Flat);
+ shape.StrokeDashOffset = ParseXmlAttribute(xmlElement, "StrokeDashOffset", 0);
+ shape.StrokeEndLineCap = ParseXmlAttribute(xmlElement, "StrokeEndLineCap", PenLineCap.Flat);
+ shape.StrokeLineJoin = ParseXmlAttribute(xmlElement, "StrokeLineJoin", PenLineJoin.Miter);
+ shape.StrokeMiterLimit = ParseXmlAttribute(xmlElement, "StrokeMiterLimit", 10);
+ shape.StrokeStartLineCap = ParseXmlAttribute(xmlElement, "StrokeStartLineCap", PenLineCap.Flat);
+ shape.StrokeThickness = ParseXmlAttribute(xmlElement, "StrokeThickness", 1);
+ }
+
+ private static Ellipse HandleXmlElement_Ellipse(CustomDialog dialog, XElement xmlElement)
+ {
+ var ellipse = new Ellipse();
+ HandleXmlElement_Shape(dialog, ellipse, xmlElement);
+
+ return ellipse;
+ }
+
+ private static Line HandleXmlElement_Line(CustomDialog dialog, XElement xmlElement)
+ {
+ var line = new Line();
+ HandleXmlElement_Shape(dialog, line, xmlElement);
+
+ line.X1 = ParseXmlAttribute(xmlElement, "X1", 0);
+ line.X2 = ParseXmlAttribute(xmlElement, "X2", 0);
+ line.Y1 = ParseXmlAttribute(xmlElement, "Y1", 0);
+ line.Y2 = ParseXmlAttribute(xmlElement, "Y2", 0);
+
+ return line;
+ }
+
+ private static Rectangle HandleXmlElement_Rectangle(CustomDialog dialog, XElement xmlElement)
+ {
+ var rectangle = new Rectangle();
+ HandleXmlElement_Shape(dialog, rectangle, xmlElement);
+
+ rectangle.RadiusX = ParseXmlAttribute(xmlElement, "RadiusX", 0);
+ rectangle.RadiusY = ParseXmlAttribute(xmlElement, "RadiusY", 0);
+
+ return rectangle;
+ }
+
+ #endregion
+
+ #region Elements
+ private static void HandleXmlElement_FrameworkElement(CustomDialog dialog, FrameworkElement uiElement, XElement xmlElement)
+ {
+ // prevent two elements from having the same name
+ string? name = xmlElement.Attribute("Name")?.Value?.ToString();
+ if (name != null)
+ {
+ if (dialog.UsedNames.Contains(name))
+ throw new Exception($"{xmlElement.Name} has duplicate name {name}");
+
+ dialog.UsedNames.Add(name);
+ }
+
+ uiElement.Name = name;
+
+ uiElement.Visibility = ParseXmlAttribute(xmlElement, "Visibility", Visibility.Visible);
+ uiElement.IsEnabled = ParseXmlAttribute(xmlElement, "IsEnabled", true);
+
+ object? margin = GetThicknessFromXElement(xmlElement, "Margin");
+ if (margin != null)
+ uiElement.Margin = (Thickness)margin;
+
+ uiElement.Height = ParseXmlAttribute(xmlElement, "Height", double.NaN);
+ uiElement.Width = ParseXmlAttribute(xmlElement, "Width", double.NaN);
+
+ // default values of these were originally Stretch but that was no good
+ uiElement.HorizontalAlignment = ParseXmlAttribute(xmlElement, "HorizontalAlignment", HorizontalAlignment.Left);
+ uiElement.VerticalAlignment = ParseXmlAttribute(xmlElement, "VerticalAlignment", VerticalAlignment.Top);
+
+ uiElement.Opacity = ParseXmlAttribute(xmlElement, "Opacity", 1);
+ ApplyBrush_UIElement(dialog, uiElement, "OpacityMask", FrameworkElement.OpacityMaskProperty, xmlElement);
+
+ object? renderTransformOrigin = GetPointFromXElement(xmlElement, "RenderTransformOrigin");
+ if (renderTransformOrigin is Point)
+ uiElement.RenderTransformOrigin = (Point)renderTransformOrigin;
+
+ int zIndex = ParseXmlAttributeClamped(xmlElement, "Panel.ZIndex", defaultValue: 0, min: 0, max: 1000);
+ Panel.SetZIndex(uiElement, zIndex);
+
+ int gridRow = ParseXmlAttribute(xmlElement, "Grid.Row", 0);
+ Grid.SetRow(uiElement, gridRow);
+ int gridRowSpan = ParseXmlAttribute(xmlElement, "Grid.RowSpan", 1);
+ Grid.SetRowSpan(uiElement, gridRowSpan);
+
+ int gridColumn = ParseXmlAttribute(xmlElement, "Grid.Column", 0);
+ Grid.SetColumn(uiElement, gridColumn);
+ int gridColumnSpan = ParseXmlAttribute(xmlElement, "Grid.ColumnSpan", 1);
+ Grid.SetColumnSpan(uiElement, gridColumnSpan);
+
+ ApplyTransformations_UIElement(dialog, uiElement, xmlElement);
+ ApplyEffects_UIElement(dialog, uiElement, xmlElement);
+ }
+
+ private static void HandleXmlElement_Control(CustomDialog dialog, Control uiElement, XElement xmlElement)
+ {
+ HandleXmlElement_FrameworkElement(dialog, uiElement, xmlElement);
+
+ object? padding = GetThicknessFromXElement(xmlElement, "Padding");
+ if (padding != null)
+ uiElement.Padding = (Thickness)padding;
+
+ object? borderThickness = GetThicknessFromXElement(xmlElement, "BorderThickness");
+ if (borderThickness != null)
+ uiElement.BorderThickness = (Thickness)borderThickness;
+
+ ApplyBrush_UIElement(dialog, uiElement, "Foreground", Control.ForegroundProperty, xmlElement);
+
+ ApplyBrush_UIElement(dialog, uiElement, "Background", Control.BackgroundProperty, xmlElement);
+
+ ApplyBrush_UIElement(dialog, uiElement, "BorderBrush", Control.BorderBrushProperty, xmlElement);
+
+ var fontSize = ParseXmlAttributeNullable(xmlElement, "FontSize");
+ if (fontSize is double)
+ uiElement.FontSize = (double)fontSize;
+ uiElement.FontWeight = GetFontWeightFromXElement(xmlElement);
+ uiElement.FontStyle = GetFontStyleFromXElement(xmlElement);
+
+ // NOTE: font family can both be the name of the font or a uri
+ string? fontFamily = GetFullPath(dialog, xmlElement.Attribute("FontFamily")?.Value);
+ if (fontFamily != null)
+ uiElement.FontFamily = new System.Windows.Media.FontFamily(fontFamily);
+ }
+
+ private static UIElement HandleXmlElement_BloxstrapCustomBootstrapper(CustomDialog dialog, XElement xmlElement)
+ {
+ xmlElement.SetAttributeValue("Visibility", "Collapsed"); // don't show the bootstrapper yet!!!
+ xmlElement.SetAttributeValue("IsEnabled", "True");
+ HandleXmlElement_Control(dialog, dialog, xmlElement);
+
+ dialog.Opacity = 1;
+
+ // transfer effect to element grid
+ dialog.ElementGrid.RenderTransform = dialog.RenderTransform;
+ dialog.RenderTransform = null;
+ dialog.ElementGrid.LayoutTransform = dialog.LayoutTransform;
+ dialog.LayoutTransform = null;
+
+ dialog.ElementGrid.Effect = dialog.Effect;
+ dialog.Effect = null;
+
+ var theme = ParseXmlAttribute(xmlElement, "Theme", Theme.Default);
+ if (theme == Theme.Default)
+ theme = App.Settings.Prop.Theme;
+
+ var wpfUiTheme = theme.GetFinal() == Theme.Dark ? Wpf.Ui.Appearance.ThemeType.Dark : Wpf.Ui.Appearance.ThemeType.Light;
+
+ dialog.Resources.MergedDictionaries.Clear();
+ dialog.Resources.MergedDictionaries.Add(new ThemesDictionary() { Theme = wpfUiTheme });
+ dialog.DefaultBorderThemeOverwrite = wpfUiTheme;
+
+ dialog.WindowCornerPreference = ParseXmlAttribute(xmlElement, "WindowCornerPreference", Wpf.Ui.Appearance.WindowCornerPreference.Round);
+
+ // disable default window border if border is modified
+ if (xmlElement.Attribute("BorderBrush") != null || xmlElement.Attribute("BorderThickness") != null)
+ dialog.DefaultBorderEnabled = false;
+
+ // set the margin & padding on the element grid
+ dialog.ElementGrid.Margin = dialog.Margin;
+ // TODO: put elementgrid inside a border?
+
+ dialog.Margin = new Thickness(0, 0, 0, 0);
+ dialog.Padding = new Thickness(0, 0, 0, 0);
+
+ string? title = xmlElement.Attribute("Title")?.Value?.ToString() ?? "Bloxstrap";
+ dialog.Title = title;
+
+ bool ignoreTitleBarInset = ParseXmlAttribute(xmlElement, "IgnoreTitleBarInset", false);
+ if (ignoreTitleBarInset)
+ {
+ Grid.SetRow(dialog.ElementGrid, 0);
+ Grid.SetRowSpan(dialog.ElementGrid, 2);
+ }
+
+ return new DummyFrameworkElement();
+ }
+
+ private static UIElement HandleXmlElement_BloxstrapCustomBootstrapper_Fake(CustomDialog dialog, XElement xmlElement)
+ {
+ // this only exists to error out the theme if someone tries to use two BloxstrapCustomBootstrappers
+ throw new Exception($"{xmlElement.Parent!.Name} cannot have a child of {xmlElement.Name}");
+ }
+
+ private static DummyFrameworkElement HandleXmlElement_TitleBar(CustomDialog dialog, XElement xmlElement)
+ {
+ xmlElement.SetAttributeValue("Name", "TitleBar"); // prevent two titlebars from existing
+ xmlElement.SetAttributeValue("IsEnabled", "True");
+ HandleXmlElement_Control(dialog, dialog.RootTitleBar, xmlElement);
+
+ // get rid of all effects
+ dialog.RootTitleBar.RenderTransform = null;
+ dialog.RootTitleBar.LayoutTransform = null;
+
+ dialog.RootTitleBar.Effect = null;
+
+ Panel.SetZIndex(dialog.RootTitleBar, 1001); // always show above others
+
+ // properties we dont want modifiable
+ dialog.RootTitleBar.Height = double.NaN;
+ dialog.RootTitleBar.Width = double.NaN;
+ dialog.RootTitleBar.HorizontalAlignment = HorizontalAlignment.Stretch;
+ dialog.RootTitleBar.Margin = new Thickness(0, 0, 0, 0);
+
+ dialog.RootTitleBar.ShowMinimize = ParseXmlAttribute(xmlElement, "ShowMinimize", true);
+ dialog.RootTitleBar.ShowClose = ParseXmlAttribute(xmlElement, "ShowClose", true);
+
+ string? title = xmlElement.Attribute("Title")?.Value?.ToString() ?? "Bloxstrap";
+ dialog.RootTitleBar.Title = title;
+
+ return new DummyFrameworkElement(); // dont add anything
+ }
+
+ private static UIElement HandleXmlElement_Button(CustomDialog dialog, XElement xmlElement)
+ {
+ var button = new Button();
+ HandleXmlElement_Control(dialog, button, xmlElement);
+
+ button.Content = GetContentFromXElement(dialog, xmlElement);
+
+ if (xmlElement.Attribute("Name")?.Value == "CancelButton")
+ {
+ Binding cancelEnabledBinding = new Binding("CancelEnabled") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(button, Button.IsEnabledProperty, cancelEnabledBinding);
+
+ Binding cancelCommandBinding = new Binding("CancelInstallCommand");
+ BindingOperations.SetBinding(button, Button.CommandProperty, cancelCommandBinding);
+ }
+
+ return button;
+ }
+
+ private static void HandleXmlElement_RangeBase(CustomDialog dialog, RangeBase rangeBase, XElement xmlElement)
+ {
+ HandleXmlElement_Control(dialog, rangeBase, xmlElement);
+
+ rangeBase.Value = ParseXmlAttribute(xmlElement, "Value", 0);
+ rangeBase.Maximum = ParseXmlAttribute(xmlElement, "Maximum", 100);
+ }
+
+ private static UIElement HandleXmlElement_ProgressBar(CustomDialog dialog, XElement xmlElement)
+ {
+ var progressBar = new Wpf.Ui.Controls.ProgressBar();
+ HandleXmlElement_RangeBase(dialog, progressBar, xmlElement);
+
+ progressBar.IsIndeterminate = ParseXmlAttribute(xmlElement, "IsIndeterminate", false);
+
+ object? cornerRadius = GetCornerRadiusFromXElement(xmlElement, "CornerRadius");
+ if (cornerRadius != null)
+ progressBar.CornerRadius = (CornerRadius)cornerRadius;
+
+ object? indicatorCornerRadius = GetCornerRadiusFromXElement(xmlElement, "IndicatorCornerRadius");
+ if (indicatorCornerRadius != null)
+ progressBar.IndicatorCornerRadius = (CornerRadius)indicatorCornerRadius;
+
+ if (xmlElement.Attribute("Name")?.Value == "PrimaryProgressBar")
+ {
+ Binding isIndeterminateBinding = new Binding("ProgressIndeterminate") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, ProgressBar.IsIndeterminateProperty, isIndeterminateBinding);
+
+ Binding maximumBinding = new Binding("ProgressMaximum") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, ProgressBar.MaximumProperty, maximumBinding);
+
+ Binding valueBinding = new Binding("ProgressValue") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, ProgressBar.ValueProperty, valueBinding);
+ }
+
+ return progressBar;
+ }
+
+ private static UIElement HandleXmlElement_ProgressRing(CustomDialog dialog, XElement xmlElement)
+ {
+ var progressBar = new Wpf.Ui.Controls.ProgressRing();
+ HandleXmlElement_RangeBase(dialog, progressBar, xmlElement);
+
+ progressBar.IsIndeterminate = ParseXmlAttribute(xmlElement, "IsIndeterminate", false);
+
+ if (xmlElement.Attribute("Name")?.Value == "PrimaryProgressRing")
+ {
+ Binding isIndeterminateBinding = new Binding("ProgressIndeterminate") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, Wpf.Ui.Controls.ProgressRing.IsIndeterminateProperty, isIndeterminateBinding);
+
+ Binding maximumBinding = new Binding("ProgressMaximum") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, Wpf.Ui.Controls.ProgressRing.MaximumProperty, maximumBinding);
+
+ Binding valueBinding = new Binding("ProgressValue") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, Wpf.Ui.Controls.ProgressRing.ValueProperty, valueBinding);
+ }
+
+ return progressBar;
+ }
+
+ private static void HandleXmlElement_TextBlock_Base(CustomDialog dialog, TextBlock textBlock, XElement xmlElement)
+ {
+ HandleXmlElement_FrameworkElement(dialog, textBlock, xmlElement);
+
+ ApplyBrush_UIElement(dialog, textBlock, "Foreground", TextBlock.ForegroundProperty, xmlElement);
+
+ ApplyBrush_UIElement(dialog, textBlock, "Background", TextBlock.BackgroundProperty, xmlElement);
+
+ var fontSize = ParseXmlAttributeNullable(xmlElement, "FontSize");
+ if (fontSize is double)
+ textBlock.FontSize = (double)fontSize;
+ textBlock.FontWeight = GetFontWeightFromXElement(xmlElement);
+ textBlock.FontStyle = GetFontStyleFromXElement(xmlElement);
+
+ textBlock.LineHeight = ParseXmlAttribute(xmlElement, "LineHeight", double.NaN);
+ textBlock.LineStackingStrategy = ParseXmlAttribute(xmlElement, "LineStackingStrategy", LineStackingStrategy.MaxHeight);
+
+ textBlock.TextAlignment = ParseXmlAttribute(xmlElement, "TextAlignment", TextAlignment.Center);
+ textBlock.TextTrimming = ParseXmlAttribute(xmlElement, "TextTrimming", TextTrimming.None);
+ textBlock.TextWrapping = ParseXmlAttribute(xmlElement, "TextWrapping", TextWrapping.NoWrap);
+ textBlock.TextDecorations = GetTextDecorationsFromXElement(xmlElement);
+
+ textBlock.IsHyphenationEnabled = ParseXmlAttribute(xmlElement, "IsHyphenationEnabled", false);
+ textBlock.BaselineOffset = ParseXmlAttribute(xmlElement, "BaselineOffset", double.NaN);
+
+ // NOTE: font family can both be the name of the font or a uri
+ string? fontFamily = GetFullPath(dialog, xmlElement.Attribute("FontFamily")?.Value);
+ if (fontFamily != null)
+ textBlock.FontFamily = new System.Windows.Media.FontFamily(fontFamily);
+
+ object? padding = GetThicknessFromXElement(xmlElement, "Padding");
+ if (padding != null)
+ textBlock.Padding = (Thickness)padding;
+ }
+
+ private static UIElement HandleXmlElement_TextBlock(CustomDialog dialog, XElement xmlElement)
+ {
+ var textBlock = new TextBlock();
+ HandleXmlElement_TextBlock_Base(dialog, textBlock, xmlElement);
+
+ textBlock.Text = GetTranslatedText(xmlElement.Attribute("Text")?.Value);
+
+ if (xmlElement.Attribute("Name")?.Value == "StatusText")
+ {
+ Binding textBinding = new Binding("Message") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(textBlock, TextBlock.TextProperty, textBinding);
+ }
+
+ return textBlock;
+ }
+
+ private static UIElement HandleXmlElement_MarkdownTextBlock(CustomDialog dialog, XElement xmlElement)
+ {
+ var textBlock = new MarkdownTextBlock();
+ HandleXmlElement_TextBlock_Base(dialog, textBlock, xmlElement);
+
+ string? text = GetTranslatedText(xmlElement.Attribute("Text")?.Value);
+ if (text != null)
+ textBlock.MarkdownText = text;
+
+ return textBlock;
+ }
+
+ private static UIElement HandleXmlElement_Image(CustomDialog dialog, XElement xmlElement)
+ {
+ var image = new Image();
+ HandleXmlElement_FrameworkElement(dialog, image, xmlElement);
+
+ image.Stretch = ParseXmlAttribute(xmlElement, "Stretch", Stretch.Uniform);
+ image.StretchDirection = ParseXmlAttribute(xmlElement, "StretchDirection", StretchDirection.Both);
+
+ RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.HighQuality); // should this be modifiable by the user?
+
+ var sourceData = GetImageSourceData(dialog, "Source", xmlElement);
+
+ if (sourceData.IsIcon)
+ {
+ // bind the icon property
+ Binding binding = new Binding("Icon") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(image, Image.SourceProperty, binding);
+ }
+ else
+ {
+ bool isAnimated = ParseXmlAttribute(xmlElement, "IsAnimated", false);
+ if (!isAnimated)
+ {
+ BitmapImage bitmapImage;
+ try
+ {
+ bitmapImage = new BitmapImage(sourceData.Uri!);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.ElementTypeCreationFailed", "Image", "BitmapImage", ex.Message);
+ }
+
+ image.Source = bitmapImage;
+ }
+ else
+ {
+ XamlAnimatedGif.AnimationBehavior.SetSourceUri(image, sourceData.Uri!);
+ }
+ }
+
+ return image;
+ }
+
+ private static RowDefinition HandleXmlElement_RowDefinition(CustomDialog dialog, XElement xmlElement)
+ {
+ var rowDefinition = new RowDefinition();
+
+ var height = GetGridLengthFromXElement(xmlElement, "Height");
+ if (height != null)
+ rowDefinition.Height = (GridLength)height;
+
+ rowDefinition.MinHeight = ParseXmlAttribute(xmlElement, "MinHeight", 0);
+ rowDefinition.MaxHeight = ParseXmlAttribute(xmlElement, "MaxHeight", double.PositiveInfinity);
+
+ return rowDefinition;
+ }
+
+ private static ColumnDefinition HandleXmlElement_ColumnDefinition(CustomDialog dialog, XElement xmlElement)
+ {
+ var columnDefinition = new ColumnDefinition();
+
+ var width = GetGridLengthFromXElement(xmlElement, "Width");
+ if (width != null)
+ columnDefinition.Width = (GridLength)width;
+
+ columnDefinition.MinWidth = ParseXmlAttribute(xmlElement, "MinWidth", 0);
+ columnDefinition.MaxWidth = ParseXmlAttribute(xmlElement, "MaxWidth", double.PositiveInfinity);
+
+ return columnDefinition;
+ }
+
+ private static void HandleXmlElement_Grid_RowDefinitions(Grid grid, CustomDialog dialog, XElement xmlElement)
+ {
+ foreach (var element in xmlElement.Elements())
+ {
+ var rowDefinition = HandleXml(dialog, element);
+ grid.RowDefinitions.Add(rowDefinition);
+ }
+ }
+
+ private static void HandleXmlElement_Grid_ColumnDefinitions(Grid grid, CustomDialog dialog, XElement xmlElement)
+ {
+ foreach (var element in xmlElement.Elements())
+ {
+ var columnDefinition = HandleXml(dialog, element);
+ grid.ColumnDefinitions.Add(columnDefinition);
+ }
+ }
+
+ private static Grid HandleXmlElement_Grid(CustomDialog dialog, XElement xmlElement)
+ {
+ var grid = new Grid();
+ HandleXmlElement_FrameworkElement(dialog, grid, xmlElement);
+
+ bool rowsSet = false;
+ bool columnsSet = false;
+
+ foreach (var element in xmlElement.Elements())
+ {
+ if (element.Name == "Grid.RowDefinitions")
+ {
+ if (rowsSet)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleDefinitions", "Grid", "RowDefinitions");
+ rowsSet = true;
+
+ HandleXmlElement_Grid_RowDefinitions(grid, dialog, element);
+ }
+ else if (element.Name == "Grid.ColumnDefinitions")
+ {
+ if (columnsSet)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleDefinitions", "Grid", "ColumnDefinitions");
+ columnsSet = true;
+
+ HandleXmlElement_Grid_ColumnDefinitions(grid, dialog, element);
+ }
+ else if (element.Name.ToString().StartsWith("Grid."))
+ {
+ continue; // ignore others
+ }
+ else
+ {
+ var uiElement = HandleXml(dialog, element);
+ grid.Children.Add(uiElement);
+ }
+ }
+
+ return grid;
+ }
+
+ private static StackPanel HandleXmlElement_StackPanel(CustomDialog dialog, XElement xmlElement)
+ {
+ var stackPanel = new StackPanel();
+ HandleXmlElement_FrameworkElement(dialog, stackPanel, xmlElement);
+
+ stackPanel.Orientation = ParseXmlAttribute(xmlElement, "Orientation", Orientation.Vertical);
+
+ foreach (var element in xmlElement.Elements())
+ {
+ var uiElement = HandleXml(dialog, element);
+ stackPanel.Children.Add(uiElement);
+ }
+
+ return stackPanel;
+ }
+
+ private static Border HandleXmlElement_Border(CustomDialog dialog, XElement xmlElement)
+ {
+ var border = new Border();
+ HandleXmlElement_FrameworkElement(dialog, border, xmlElement);
+
+ ApplyBrush_UIElement(dialog, border, "Background", Border.BackgroundProperty, xmlElement);
+ ApplyBrush_UIElement(dialog, border, "BorderBrush", Border.BorderBrushProperty, xmlElement);
+
+ object? borderThickness = GetThicknessFromXElement(xmlElement, "BorderThickness");
+ if (borderThickness != null)
+ border.BorderThickness = (Thickness)borderThickness;
+
+ object? padding = GetThicknessFromXElement(xmlElement, "Padding");
+ if (padding != null)
+ border.Padding = (Thickness)padding;
+
+ object? cornerRadius = GetCornerRadiusFromXElement(xmlElement, "CornerRadius");
+ if (cornerRadius != null)
+ border.CornerRadius = (CornerRadius)cornerRadius;
+
+ var children = xmlElement.Elements().Where(x => !x.Name.ToString().StartsWith("Border."));
+ if (children.Any())
+ {
+ if (children.Count() > 1)
+ throw new CustomThemeException("CustomTheme.Errors.ElementMultipleChildren", "Border");
+
+ border.Child = HandleXml(dialog, children.First());
+ }
+
+ return border;
+ }
+ #endregion
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Utilities.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Utilities.cs
new file mode 100644
index 0000000..0deafb3
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Utilities.cs
@@ -0,0 +1,300 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Effects;
+using System.Xml.Linq;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ public partial class CustomDialog
+ {
+ struct GetImageSourceDataResult
+ {
+ public bool IsIcon = false;
+ public Uri? Uri = null;
+
+ public GetImageSourceDataResult()
+ {
+ }
+ }
+
+ private static string GetXmlAttribute(XElement element, string attributeName, string? defaultValue = null)
+ {
+ var attribute = element.Attribute(attributeName);
+
+ if (attribute == null)
+ {
+ if (defaultValue != null)
+ return defaultValue;
+
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissing", element.Name, attributeName);
+ }
+
+ return attribute.Value.ToString();
+ }
+
+ private static T ParseXmlAttribute(XElement element, string attributeName, T? defaultValue = null) where T : struct
+ {
+ var attribute = element.Attribute(attributeName);
+
+ if (attribute == null)
+ {
+ if (defaultValue != null)
+ return (T)defaultValue;
+
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissing", element.Name, attributeName);
+ }
+
+ T? parsed = ConvertValue(attribute.Value);
+ if (parsed == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeInvalidType", element.Name, attributeName, typeof(T).Name);
+
+ return (T)parsed;
+ }
+
+ ///
+ /// ParseXmlAttribute but the default value is always null
+ ///
+ private static T? ParseXmlAttributeNullable(XElement element, string attributeName) where T : struct
+ {
+ var attribute = element.Attribute(attributeName);
+
+ if (attribute == null)
+ return null;
+
+ T? parsed = ConvertValue(attribute.Value);
+ if (parsed == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeInvalidType", element.Name, attributeName, typeof(T).Name);
+
+ return (T)parsed;
+ }
+
+ private static void ValidateXmlElement(string elementName, string attributeName, int value, int? min = null, int? max = null)
+ {
+ if (min != null && value < min)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeLargerThanMin", elementName, attributeName, min);
+ if (max != null && value > max)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeSmallerThanMax", elementName, attributeName, max);
+ }
+
+ private static void ValidateXmlElement(string elementName, string attributeName, double value, double? min = null, double? max = null)
+ {
+ if (min != null && value < min)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeLargerThanMin", elementName, attributeName, min);
+ if (max != null && value > max)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeSmallerThanMax", elementName, attributeName, max);
+ }
+
+ // You can't do numeric only generics in .NET 6. The feature is exclusive to .NET 7+.
+ private static int ParseXmlAttributeClamped(XElement element, string attributeName, int? defaultValue = null, int? min = null, int? max = null)
+ {
+ int value = ParseXmlAttribute(element, attributeName, defaultValue);
+ ValidateXmlElement(element.Name.ToString(), attributeName, value, min, max);
+ return value;
+ }
+
+ private static FontWeight GetFontWeightFromXElement(XElement element)
+ {
+ string? value = element.Attribute("FontWeight")?.Value?.ToString();
+ if (string.IsNullOrEmpty(value))
+ value = "Normal";
+
+ // bruh
+ // https://learn.microsoft.com/en-us/dotnet/api/system.windows.fontweights?view=windowsdesktop-6.0
+ switch (value)
+ {
+ case "Thin":
+ return FontWeights.Thin;
+
+ case "ExtraLight":
+ case "UltraLight":
+ return FontWeights.ExtraLight;
+
+ case "Medium":
+ return FontWeights.Medium;
+
+ case "Normal":
+ case "Regular":
+ return FontWeights.Normal;
+
+ case "DemiBold":
+ case "SemiBold":
+ return FontWeights.DemiBold;
+
+ case "Bold":
+ return FontWeights.Bold;
+
+ case "ExtraBold":
+ case "UltraBold":
+ return FontWeights.ExtraBold;
+
+ case "Black":
+ case "Heavy":
+ return FontWeights.Black;
+
+ case "ExtraBlack":
+ case "UltraBlack":
+ return FontWeights.UltraBlack;
+
+ default:
+ throw new CustomThemeException("CustomTheme.Errors.UnknownEnumValue", element.Name, "FontWeight", value);
+ }
+ }
+
+ private static FontStyle GetFontStyleFromXElement(XElement element)
+ {
+ string? value = element.Attribute("FontStyle")?.Value?.ToString();
+ if (string.IsNullOrEmpty(value))
+ value = "Normal";
+
+ switch (value)
+ {
+ case "Normal":
+ return FontStyles.Normal;
+
+ case "Italic":
+ return FontStyles.Italic;
+
+ case "Oblique":
+ return FontStyles.Oblique;
+
+ default:
+ throw new CustomThemeException("CustomTheme.Errors.UnknownEnumValue", element.Name, "FontStyle", value);
+ }
+ }
+
+ private static TextDecorationCollection? GetTextDecorationsFromXElement(XElement element)
+ {
+ string? value = element.Attribute("TextDecorations")?.Value?.ToString();
+ if (string.IsNullOrEmpty(value))
+ return null;
+
+ switch (value)
+ {
+ case "Baseline":
+ return TextDecorations.Baseline;
+
+ case "OverLine":
+ return TextDecorations.OverLine;
+
+ case "Strikethrough":
+ return TextDecorations.Strikethrough;
+
+ case "Underline":
+ return TextDecorations.Underline;
+
+ default:
+ throw new CustomThemeException("CustomTheme.Errors.UnknownEnumValue", element.Name, "TextDecorations", value);
+ }
+ }
+
+ private static string? GetTranslatedText(string? text)
+ {
+ if (text == null || !text.StartsWith('{') || !text.EndsWith('}'))
+ return text; // can't be translated (not in the correct format)
+
+ string resourceName = text[1..^1];
+
+ if (resourceName == "Version")
+ return App.Version;
+
+ return Strings.ResourceManager.GetStringSafe(resourceName);
+ }
+
+ private static string? GetFullPath(CustomDialog dialog, string? sourcePath)
+ {
+ if (sourcePath == null)
+ return null;
+
+ // TODO: this is bad :(
+ return sourcePath.Replace("theme://", $"{dialog.ThemeDir}\\");
+ }
+
+ private static GetImageSourceDataResult GetImageSourceData(CustomDialog dialog, string name, XElement xmlElement)
+ {
+ string path = GetXmlAttribute(xmlElement, name);
+
+ if (path == "{Icon}")
+ return new GetImageSourceDataResult { IsIcon = true };
+
+ path = GetFullPath(dialog, path)!;
+
+ if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out Uri? result))
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeParseError", xmlElement.Name, name, "Uri");
+
+ if (result == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeParseErrorNull", xmlElement.Name, name, "Uri");
+
+ if (result.Scheme != "file")
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeBlacklistedUriScheme", xmlElement.Name, name, result.Scheme);
+
+ return new GetImageSourceDataResult { Uri = result };
+ }
+
+ private static object? GetContentFromXElement(CustomDialog dialog, XElement xmlElement)
+ {
+ var contentAttr = xmlElement.Attribute("Content");
+ var contentElement = xmlElement.Element($"{xmlElement.Name}.Content");
+ if (contentAttr != null && contentElement != null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleDefinitions", xmlElement.Name, "Content");
+
+ if (contentAttr != null)
+ return GetTranslatedText(contentAttr.Value);
+
+ if (contentElement == null)
+ return null;
+
+ var children = contentElement.Elements();
+ if (children.Count() > 1)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleChildren", xmlElement.Name, "Content");
+
+ var first = contentElement.FirstNode as XElement;
+ if (first == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissingChild", xmlElement.Name, "Content");
+
+ var uiElement = HandleXml(dialog, first);
+ return uiElement;
+ }
+
+ private static void ApplyEffects_UIElement(CustomDialog dialog, UIElement uiElement, XElement xmlElement)
+ {
+ var effectElement = xmlElement.Element($"{xmlElement.Name}.Effect");
+ if (effectElement == null)
+ return;
+
+ var children = effectElement.Elements();
+ if (children.Count() > 1)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleChildren", xmlElement.Name, "Effect");
+
+ var child = children.FirstOrDefault();
+ if (child == null)
+ return;
+
+ Effect effect = HandleXml(dialog, child);
+ uiElement.Effect = effect;
+ }
+
+ private static void ApplyTransformation_UIElement(CustomDialog dialog, string name, DependencyProperty property, UIElement uiElement, XElement xmlElement)
+ {
+ var transformElement = xmlElement.Element($"{xmlElement.Name}.{name}");
+
+ if (transformElement == null)
+ return;
+
+ var tg = new TransformGroup();
+
+ foreach (var child in transformElement.Elements())
+ {
+ Transform element = HandleXml(dialog, child);
+ tg.Children.Add(element);
+ }
+
+ uiElement.SetValue(property, tg);
+ }
+
+ private static void ApplyTransformations_UIElement(CustomDialog dialog, UIElement uiElement, XElement xmlElement)
+ {
+ ApplyTransformation_UIElement(dialog, "RenderTransform", FrameworkElement.RenderTransformProperty, uiElement, xmlElement);
+ ApplyTransformation_UIElement(dialog, "LayoutTransform", FrameworkElement.LayoutTransformProperty, uiElement, xmlElement);
+ }
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml
new file mode 100644
index 0000000..44e7065
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml.cs
new file mode 100644
index 0000000..35684f4
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml.cs
@@ -0,0 +1,122 @@
+using Bloxstrap.UI.Elements.Bootstrapper.Base;
+using Bloxstrap.UI.ViewModels.Bootstrapper;
+using System.ComponentModel;
+using System.Windows.Forms;
+using System.Windows.Shell;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ ///
+ /// Interaction logic for CustomDialog.xaml
+ ///
+ public partial class CustomDialog : IBootstrapperDialog
+ {
+ private readonly BootstrapperDialogViewModel _viewModel;
+
+ public Bloxstrap.Bootstrapper? Bootstrapper { get; set; }
+
+ private bool _isClosing;
+
+ #region UI Elements
+ public string Message
+ {
+ get => _viewModel.Message;
+ set
+ {
+ _viewModel.Message = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.Message));
+ }
+ }
+
+ public ProgressBarStyle ProgressStyle
+ {
+ get => _viewModel.ProgressIndeterminate ? ProgressBarStyle.Marquee : ProgressBarStyle.Continuous;
+ set
+ {
+ _viewModel.ProgressIndeterminate = (value == ProgressBarStyle.Marquee);
+ _viewModel.OnPropertyChanged(nameof(_viewModel.ProgressIndeterminate));
+ }
+ }
+
+ public int ProgressMaximum
+ {
+ get => _viewModel.ProgressMaximum;
+ set
+ {
+ _viewModel.ProgressMaximum = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.ProgressMaximum));
+ }
+ }
+
+ public int ProgressValue
+ {
+ get => _viewModel.ProgressValue;
+ set
+ {
+ _viewModel.ProgressValue = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.ProgressValue));
+ }
+ }
+
+ public TaskbarItemProgressState TaskbarProgressState
+ {
+ get => _viewModel.TaskbarProgressState;
+ set
+ {
+ _viewModel.TaskbarProgressState = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.TaskbarProgressState));
+ }
+ }
+
+ public double TaskbarProgressValue
+ {
+ get => _viewModel.TaskbarProgressValue;
+ set
+ {
+ _viewModel.TaskbarProgressValue = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.TaskbarProgressValue));
+ }
+ }
+
+ public bool CancelEnabled
+ {
+ get => _viewModel.CancelEnabled;
+ set
+ {
+ _viewModel.CancelEnabled = value;
+
+ _viewModel.OnPropertyChanged(nameof(_viewModel.CancelButtonVisibility));
+ _viewModel.OnPropertyChanged(nameof(_viewModel.CancelEnabled));
+ }
+ }
+ #endregion
+
+ public CustomDialog()
+ {
+ InitializeComponent();
+
+ _viewModel = new BootstrapperDialogViewModel(this);
+ DataContext = _viewModel;
+ Title = App.Settings.Prop.BootstrapperTitle;
+ Icon = App.Settings.Prop.BootstrapperIcon.GetIcon().GetImageSource();
+ }
+
+ private void UiWindow_Closing(object sender, CancelEventArgs e)
+ {
+ if (!_isClosing)
+ Bootstrapper?.Cancel();
+ }
+
+ #region IBootstrapperDialog Methods
+ public void ShowBootstrapper() => this.ShowDialog();
+
+ public void CloseBootstrapper()
+ {
+ _isClosing = true;
+ Dispatcher.BeginInvoke(this.Close);
+ }
+
+ public void ShowSuccess(string message, Action? callback) => BaseFunctions.ShowSuccess(message, callback);
+ #endregion
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml b/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml
new file mode 100644
index 0000000..b49fcb0
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml.cs b/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml.cs
new file mode 100644
index 0000000..94ce4a8
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml.cs
@@ -0,0 +1,230 @@
+using Bloxstrap.UI.Elements.Base;
+using Bloxstrap.UI.ViewModels.Dialogs;
+using Microsoft.Win32;
+using System.IO.Compression;
+using System.Windows;
+
+namespace Bloxstrap.UI.Elements.Dialogs
+{
+ ///
+ /// Interaction logic for AddCustomThemeDialog.xaml
+ ///
+ public partial class AddCustomThemeDialog : WpfUiWindow
+ {
+ private const int CreateNewTabId = 0;
+ private const int ImportTabId = 1;
+
+ private readonly AddCustomThemeViewModel _viewModel;
+
+ public bool Created { get; private set; } = false;
+ public string ThemeName { get; private set; } = "";
+ public bool OpenEditor { get; private set; } = false;
+
+ public AddCustomThemeDialog()
+ {
+ _viewModel = new AddCustomThemeViewModel();
+ _viewModel.Name = GenerateRandomName();
+
+ DataContext = _viewModel;
+
+ InitializeComponent();
+ }
+
+ private static string GetThemePath(string name)
+ {
+ return Path.Combine(Paths.CustomThemes, name, "Theme.xml");
+ }
+
+ private static string GenerateRandomName()
+ {
+ int count = Directory.GetDirectories(Paths.CustomThemes).Count();
+
+ string name = $"Custom Theme {count + 1}";
+
+ // TODO: this sucks
+ if (File.Exists(GetThemePath(name)))
+ name += " " + Random.Shared.Next(1, 100000).ToString(); // easy
+
+ return name;
+ }
+
+ private static string GetUniqueName(string name)
+ {
+ const int maxTries = 100;
+
+ if (!File.Exists(GetThemePath(name)))
+ return name;
+
+ for (int i = 1; i <= maxTries; i++)
+ {
+ string newName = $"{name}_{i}";
+ if (!File.Exists(GetThemePath(newName)))
+ return newName;
+ }
+
+ // last resort
+ return $"{name}_{Random.Shared.Next(maxTries+1, 1_000_000)}";
+ }
+
+ private static void CreateCustomTheme(string name, CustomThemeTemplate template)
+ {
+ string dir = Path.Combine(Paths.CustomThemes, name);
+
+ if (Directory.Exists(dir))
+ Directory.Delete(dir, true);
+ Directory.CreateDirectory(dir);
+
+ string themeFilePath = Path.Combine(dir, "Theme.xml");
+
+ string templateContent = Encoding.UTF8.GetString(Resource.Get(template.GetFileName()).Result);
+
+ File.WriteAllText(themeFilePath, templateContent);
+ }
+
+ private bool ValidateCreateNew()
+ {
+ const string LOG_IDENT = "AddCustomThemeDialog::ValidateCreateNew";
+
+ if (string.IsNullOrEmpty(_viewModel.Name))
+ {
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_NameEmpty;
+ return false;
+ }
+
+ var validationResult = PathValidator.IsFileNameValid(_viewModel.Name);
+
+ if (validationResult != PathValidator.ValidationResult.Ok)
+ {
+ switch (validationResult)
+ {
+ case PathValidator.ValidationResult.IllegalCharacter:
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_NameIllegalCharacters;
+ break;
+ case PathValidator.ValidationResult.ReservedFileName:
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_NameReserved;
+ break;
+ default:
+ App.Logger.WriteLine(LOG_IDENT, $"Got unhandled PathValidator::ValidationResult {validationResult}");
+ Debug.Assert(false);
+
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_Unknown;
+ break;
+ }
+
+ return false;
+ }
+
+ // better to check for the file instead of the directory so broken themes can be overwritten
+ string path = Path.Combine(Paths.CustomThemes, _viewModel.Name, "Theme.xml");
+ if (File.Exists(path))
+ {
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_NameTaken;
+ return false;
+ }
+
+ return true;
+ }
+
+ private bool ValidateImport()
+ {
+ const string LOG_IDENT = "AddCustomThemeDialog::ValidateImport";
+
+ if (!_viewModel.FilePath.EndsWith(".zip"))
+ {
+ _viewModel.FileError = Strings.CustomTheme_Add_Errors_FileNotZip;
+ return false;
+ }
+
+ try
+ {
+ using var zipFile = ZipFile.OpenRead(_viewModel.FilePath);
+ var entries = zipFile.Entries;
+
+ bool foundThemeFile = false;
+
+ foreach (var entry in entries)
+ {
+ if (entry.FullName == "Theme.xml")
+ {
+ foundThemeFile = true;
+ break;
+ }
+ }
+
+ if (!foundThemeFile)
+ {
+ _viewModel.FileError = Strings.CustomTheme_Add_Errors_ZipMissingThemeFile;
+ return false;
+ }
+
+ return true;
+ }
+ catch (InvalidDataException ex)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Got invalid data");
+ App.Logger.WriteException(LOG_IDENT, ex);
+
+ _viewModel.FileError = Strings.CustomTheme_Add_Errors_ZipInvalidData;
+ return false;
+ }
+ }
+
+ private void CreateNew()
+ {
+ if (!ValidateCreateNew())
+ return;
+
+ CreateCustomTheme(_viewModel.Name, _viewModel.Template);
+
+ Created = true;
+ ThemeName = _viewModel.Name;
+ OpenEditor = true;
+
+ Close();
+ }
+
+ private void Import()
+ {
+ if (!ValidateImport())
+ return;
+
+ string fileName = Path.GetFileNameWithoutExtension(_viewModel.FilePath);
+ string name = GetUniqueName(fileName);
+
+ string directory = Path.Combine(Paths.CustomThemes, name);
+ if (Directory.Exists(directory))
+ Directory.Delete(directory, true);
+ Directory.CreateDirectory(directory);
+
+ var fastZip = new ICSharpCode.SharpZipLib.Zip.FastZip();
+ fastZip.ExtractZip(_viewModel.FilePath, directory, null);
+
+ Created = true;
+ ThemeName = name;
+ OpenEditor = false;
+
+ Close();
+ }
+
+ private void OnOkButtonClicked(object sender, RoutedEventArgs e)
+ {
+ if (_viewModel.SelectedTab == CreateNewTabId)
+ CreateNew();
+ else
+ Import();
+ }
+
+ private void OnImportButtonClicked(object sender, RoutedEventArgs e)
+ {
+ var dialog = new OpenFileDialog
+ {
+ Filter = $"{Strings.FileTypes_ZipArchive}|*.zip"
+ };
+
+ if (dialog.ShowDialog() != true)
+ return;
+
+ _viewModel.FilePath = dialog.FileName;
+ }
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml
new file mode 100644
index 0000000..31b9449
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs
new file mode 100644
index 0000000..9cfc678
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs
@@ -0,0 +1,580 @@
+using System.Windows.Input;
+using System.Xml;
+
+using ICSharpCode.AvalonEdit.CodeCompletion;
+using ICSharpCode.AvalonEdit.Document;
+using ICSharpCode.AvalonEdit.Editing;
+using ICSharpCode.AvalonEdit.Highlighting.Xshd;
+using ICSharpCode.AvalonEdit.Highlighting;
+
+using Bloxstrap.UI.Elements.Base;
+using Bloxstrap.UI.ViewModels.Editor;
+using System.Windows;
+
+namespace Bloxstrap.UI.Elements.Editor
+{
+ ///
+ /// Interaction logic for BootstrapperEditorWindow.xaml
+ ///
+ public partial class BootstrapperEditorWindow : WpfUiWindow
+ {
+ private static class CustomBootstrapperSchema
+ {
+ private class Schema
+ {
+ public Dictionary Elements { get; set; } = new Dictionary();
+ public Dictionary Types { get; set; } = new Dictionary();
+ }
+
+ private class Element
+ {
+ public string? SuperClass { get; set; } = null;
+ public bool IsCreatable { get; set; } = false;
+
+ // [AttributeName] = [TypeName]
+ public Dictionary Attributes { get; set; } = new Dictionary();
+ }
+
+ public class Type
+ {
+ public bool CanHaveElement { get; set; } = false;
+ public List? Values { get; set; } = null;
+ }
+
+ private static Schema? _schema;
+
+ ///
+ /// Elements and their attributes
+ ///
+ public static SortedDictionary> ElementInfo { get; set; } = new();
+
+ ///
+ /// Attributes of elements that can have property elements
+ ///
+ public static Dictionary> PropertyElements { get; set; } = new();
+
+ ///
+ /// All type info
+ ///
+ public static SortedDictionary Types { get; set; } = new();
+
+ public static void ParseSchema()
+ {
+ if (_schema != null)
+ return;
+
+ _schema = JsonSerializer.Deserialize(Resource.GetString("CustomBootstrapperSchema.json").Result);
+ if (_schema == null)
+ throw new Exception("Deserialised CustomBootstrapperSchema is null");
+
+ foreach (var type in _schema.Types)
+ Types.Add(type.Key, type.Value);
+
+ PopulateElementInfo();
+ }
+
+ private static (SortedDictionary, List) GetElementAttributes(string name, Element element)
+ {
+ if (ElementInfo.ContainsKey(name))
+ return (ElementInfo[name], PropertyElements[name]);
+
+ List properties = new List();
+ SortedDictionary attributes = new();
+
+ foreach (var attribute in element.Attributes)
+ {
+ attributes.Add(attribute.Key, attribute.Value);
+
+ if (!Types.ContainsKey(attribute.Value))
+ throw new Exception($"Schema for type {attribute.Value} is missing. Blame Matt!");
+
+ Type type = Types[attribute.Value];
+ if (type.CanHaveElement)
+ properties.Add(attribute.Key);
+ }
+
+ if (element.SuperClass != null)
+ {
+ (SortedDictionary superAttributes, List superProperties) = GetElementAttributes(element.SuperClass, _schema!.Elements[element.SuperClass]);
+ foreach (var attribute in superAttributes)
+ attributes.Add(attribute.Key, attribute.Value);
+
+ foreach (var property in superProperties)
+ properties.Add(property);
+ }
+
+ properties.Sort();
+
+ ElementInfo[name] = attributes;
+ PropertyElements[name] = properties;
+
+ return (attributes, properties);
+ }
+
+ private static void PopulateElementInfo()
+ {
+ List toRemove = new List();
+
+ foreach (var element in _schema!.Elements)
+ {
+ GetElementAttributes(element.Key, element.Value);
+
+ if (!element.Value.IsCreatable)
+ toRemove.Add(element.Key);
+ }
+
+ // remove non-creatable from list now that everything is done
+ foreach (var name in toRemove)
+ {
+ ElementInfo.Remove(name);
+ }
+ }
+ }
+
+ private BootstrapperEditorWindowViewModel _viewModel;
+ private CompletionWindow? _completionWindow = null;
+
+ public BootstrapperEditorWindow(string name)
+ {
+ CustomBootstrapperSchema.ParseSchema();
+
+ string directory = Path.Combine(Paths.CustomThemes, name);
+
+ string themeContents = File.ReadAllText(Path.Combine(directory, "Theme.xml"));
+ themeContents = ToCRLF(themeContents); // make sure the theme is in CRLF. a function expects CRLF.
+
+ _viewModel = new BootstrapperEditorWindowViewModel();
+ _viewModel.ThemeSavedCallback = ThemeSavedCallback;
+ _viewModel.Directory = directory;
+ _viewModel.Name = name;
+ _viewModel.Title = string.Format(Strings.CustomTheme_Editor_Title, name);
+ _viewModel.Code = themeContents;
+
+ DataContext = _viewModel;
+ InitializeComponent();
+
+ UIXML.Text = _viewModel.Code;
+ UIXML.TextChanged += OnCodeChanged;
+ UIXML.TextArea.TextEntered += OnTextAreaTextEntered;
+
+ LoadHighlightingTheme();
+ }
+
+ private void LoadHighlightingTheme()
+ {
+ string name = $"Editor-Theme-{App.Settings.Prop.Theme.GetFinal()}.xshd";
+ using Stream xmlStream = Resource.GetStream(name);
+ using XmlReader reader = XmlReader.Create(xmlStream);
+ UIXML.SyntaxHighlighting = HighlightingLoader.Load(reader, HighlightingManager.Instance);
+
+ UIXML.TextArea.TextView.SetResourceReference(ICSharpCode.AvalonEdit.Rendering.TextView.LinkTextForegroundBrushProperty, "NewTextEditorLink");
+ }
+
+ private void ThemeSavedCallback(bool success, string message)
+ {
+ if (success)
+ Snackbar.Show(Strings.CustomTheme_Editor_Save_Success, message, Wpf.Ui.Common.SymbolRegular.CheckmarkCircle32, Wpf.Ui.Common.ControlAppearance.Success);
+ else
+ Snackbar.Show(Strings.CustomTheme_Editor_Save_Error, message, Wpf.Ui.Common.SymbolRegular.ErrorCircle24, Wpf.Ui.Common.ControlAppearance.Danger);
+ }
+
+ private static string ToCRLF(string text)
+ {
+ return text.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n");
+ }
+
+ private void OnCodeChanged(object? sender, EventArgs e)
+ {
+ _viewModel.Code = UIXML.Text;
+ _viewModel.CodeChanged = true;
+ }
+
+ private void OnClosing(object sender, System.ComponentModel.CancelEventArgs e)
+ {
+ if (!_viewModel.CodeChanged)
+ return;
+
+ var result = Frontend.ShowMessageBox(string.Format(Strings.CustomTheme_Editor_ConfirmSave, _viewModel.Name), MessageBoxImage.Information, MessageBoxButton.YesNoCancel);
+ if (result == MessageBoxResult.Cancel)
+ {
+ e.Cancel = true;
+ }
+ else if (result == MessageBoxResult.Yes)
+ {
+ _viewModel.SaveCommand.Execute(null);
+ }
+ }
+
+ private void OnTextAreaTextEntered(object sender, TextCompositionEventArgs e)
+ {
+ switch (e.Text)
+ {
+ case "<":
+ OpenElementAutoComplete();
+ break;
+ case " ":
+ OpenAttributeAutoComplete();
+ break;
+ case ".":
+ OpenPropertyElementAutoComplete();
+ break;
+ case "/":
+ AddEndTag();
+ break;
+ case ">":
+ CloseCompletionWindow();
+ break;
+ case "!":
+ CloseCompletionWindow();
+ break;
+ }
+ }
+
+ private (string, int) GetLineAndPosAtCaretPosition()
+ {
+ // this assumes the file was saved as CSLF (\r\n newlines)
+ int offset = UIXML.CaretOffset - 1;
+ int lineStartIdx = UIXML.Text.LastIndexOf('\n', offset);
+ int lineEndIdx = UIXML.Text.IndexOf('\n', offset);
+
+ string line;
+ int pos;
+ if (lineStartIdx == -1 && lineEndIdx == -1)
+ {
+ line = UIXML.Text;
+ pos = offset;
+ }
+ else if (lineStartIdx == -1)
+ {
+ line = UIXML.Text[..(lineEndIdx - 1)];
+ pos = offset;
+ }
+ else if (lineEndIdx == -1)
+ {
+ line = UIXML.Text[(lineStartIdx + 1)..];
+ pos = offset - lineStartIdx - 2;
+ }
+ else
+ {
+ line = UIXML.Text[(lineStartIdx + 1)..(lineEndIdx - 1)];
+ pos = offset - lineStartIdx - 2;
+ }
+
+ return (line, pos);
+ }
+
+ ///
+ /// Source: https://xsemmel.codeplex.com
+ ///
+ ///
+ ///
+ ///
+ public static string? GetElementAtCursor(string xml, int offset, bool onlyAllowInside = false)
+ {
+ if (offset == xml.Length)
+ {
+ offset--;
+ }
+ int startIdx = xml.LastIndexOf('<', offset);
+ if (startIdx < 0) return null;
+
+ if (startIdx < xml.Length && xml[startIdx + 1] == '/')
+ {
+ startIdx = startIdx + 1;
+ }
+
+ int endIdx1 = xml.IndexOf(' ', startIdx);
+ if (endIdx1 == -1 /*|| endIdx1 > offset*/) endIdx1 = int.MaxValue;
+
+ int endIdx2 = xml.IndexOf('>', startIdx);
+ if (endIdx2 == -1 /*|| endIdx2 > offset*/)
+ {
+ endIdx2 = int.MaxValue;
+ }
+ else
+ {
+ if (onlyAllowInside && endIdx2 < offset)
+ return null; // we dont want attribute auto complete to show outside of elements
+
+ if (endIdx2 < xml.Length && xml[endIdx2 - 1] == '/')
+ {
+ endIdx2 = endIdx2 - 1;
+ }
+ }
+
+ int endIdx = Math.Min(endIdx1, endIdx2);
+ if (endIdx2 > 0 && endIdx2 < int.MaxValue && endIdx > startIdx)
+ {
+ string element = xml.Substring(startIdx + 1, endIdx - startIdx - 1);
+ return element == "!--" ? null : element; // dont treat comments as elements
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// A space between the cursor and the element will completely cancel this function
+ ///
+ private string? GetElementAtCursorNoSpaces(string xml, int offset)
+ {
+ (string line, int pos) = GetLineAndPosAtCaretPosition();
+
+ string curr = "";
+ while (pos != -1)
+ {
+ char c = line[pos];
+ if (c == ' ' || c == '\t')
+ return null;
+ if (c == '<')
+ return curr;
+ curr = c + curr;
+ pos--;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Returns null if not eligible to auto complete there.
+ /// Returns the name of the element to show the attributes for
+ ///
+ ///
+ private string? ShowAttributesForElementName()
+ {
+ (string line, int pos) = GetLineAndPosAtCaretPosition();
+
+ // check if theres an even number of speech marks on the line
+ int numSpeech = line.Count(x => x == '"');
+ if (numSpeech % 2 == 0)
+ {
+ // we have an equal number, let's check if pos is in between the speech marks
+ int count = -1;
+ int idx = pos;
+ int size = line.Length - 1;
+ while (idx != -1)
+ {
+ count++;
+
+ if (size > idx + 1)
+ idx = line.IndexOf('"', idx + 1);
+ else
+ idx = -1;
+ }
+
+ if (count % 2 != 0)
+ {
+ // odd number of speech marks means we're inside a string right now
+ // we dont want to display attribute auto complete while we're inside a string
+ return null;
+ }
+ }
+
+ return GetElementAtCursor(UIXML.Text, UIXML.CaretOffset, true);
+ }
+
+ private void AddEndTag()
+ {
+ CloseCompletionWindow();
+
+ if (UIXML.Text.Length > 2 && UIXML.Text[UIXML.CaretOffset - 2] == '<')
+ {
+ var elementName = GetElementAtCursor(UIXML.Text, UIXML.CaretOffset - 3);
+ if (elementName == null)
+ return;
+
+ UIXML.TextArea.Document.Insert(UIXML.CaretOffset, $"{elementName}>");
+ }
+ else
+ {
+ if (UIXML.Text.Length > UIXML.CaretOffset && UIXML.Text[UIXML.CaretOffset] == '>')
+ return;
+
+ var elementName = ShowAttributesForElementName(); // re-using functions :)
+ if (elementName != null)
+ UIXML.TextArea.Document.Insert(UIXML.CaretOffset, ">");
+ }
+ }
+
+ private void OpenElementAutoComplete()
+ {
+ var data = new List();
+
+ foreach (var element in CustomBootstrapperSchema.ElementInfo.Keys)
+ data.Add(new ElementCompletionData(element));
+
+ ShowCompletionWindow(data);
+ }
+
+ private void OpenAttributeAutoComplete()
+ {
+ string? element = ShowAttributesForElementName();
+ if (element == null)
+ {
+ CloseCompletionWindow();
+ return;
+ }
+
+ if (!CustomBootstrapperSchema.ElementInfo.ContainsKey(element))
+ {
+ CloseCompletionWindow();
+ return;
+ }
+
+ var attributes = CustomBootstrapperSchema.ElementInfo[element];
+
+ var data = new List();
+
+ foreach (var attribute in attributes)
+ data.Add(new AttributeCompletionData(attribute.Key, () => OpenTypeValueAutoComplete(attribute.Value)));
+
+ ShowCompletionWindow(data);
+ }
+
+ private void OpenTypeValueAutoComplete(string typeName)
+ {
+ var typeValues = CustomBootstrapperSchema.Types[typeName].Values;
+ if (typeValues == null)
+ return;
+
+ var data = new List();
+
+ foreach (var value in typeValues)
+ data.Add(new TypeValueCompletionData(value));
+
+ ShowCompletionWindow(data);
+ }
+
+ private void OpenPropertyElementAutoComplete()
+ {
+ string? element = GetElementAtCursorNoSpaces(UIXML.Text, UIXML.CaretOffset);
+ if (element == null)
+ {
+ CloseCompletionWindow();
+ return;
+ }
+
+ if (!CustomBootstrapperSchema.PropertyElements.ContainsKey(element))
+ {
+ CloseCompletionWindow();
+ return;
+ }
+
+ var properties = CustomBootstrapperSchema.PropertyElements[element];
+
+ var data = new List();
+
+ foreach (var property in properties)
+ data.Add(new TypeValueCompletionData(property));
+
+ ShowCompletionWindow(data);
+ }
+
+ private void CloseCompletionWindow()
+ {
+ if (_completionWindow != null)
+ {
+ _completionWindow.Close();
+ _completionWindow = null;
+ }
+ }
+
+ private void ShowCompletionWindow(List completionData)
+ {
+ CloseCompletionWindow();
+
+ if (!completionData.Any())
+ return;
+
+ _completionWindow = new CompletionWindow(UIXML.TextArea);
+
+ IList data = _completionWindow.CompletionList.CompletionData;
+ foreach (var c in completionData)
+ data.Add(c);
+
+ _completionWindow.Show();
+ _completionWindow.Closed += (_, _) => _completionWindow = null;
+ }
+ }
+
+ public class ElementCompletionData : ICompletionData
+ {
+ public ElementCompletionData(string text)
+ {
+ this.Text = text;
+ }
+
+ public System.Windows.Media.ImageSource? Image => null;
+
+ public string Text { get; private set; }
+
+ // Use this property if you want to show a fancy UIElement in the list.
+ public object Content => Text;
+
+ public object? Description => null;
+
+ public double Priority { get; }
+
+ public void Complete(TextArea textArea, ISegment completionSegment,
+ EventArgs insertionRequestEventArgs)
+ {
+ textArea.Document.Replace(completionSegment, this.Text);
+ }
+ }
+
+ public class AttributeCompletionData : ICompletionData
+ {
+ private Action _openValueAutoCompleteAction;
+
+ public AttributeCompletionData(string text, Action openValueAutoCompleteAction)
+ {
+ _openValueAutoCompleteAction = openValueAutoCompleteAction;
+ this.Text = text;
+ }
+
+ public System.Windows.Media.ImageSource? Image => null;
+
+ public string Text { get; private set; }
+
+ // Use this property if you want to show a fancy UIElement in the list.
+ public object Content => Text;
+
+ public object? Description => null;
+
+ public double Priority { get; }
+
+ public void Complete(TextArea textArea, ISegment completionSegment,
+ EventArgs insertionRequestEventArgs)
+ {
+ textArea.Document.Replace(completionSegment, this.Text + "=\"\"");
+ textArea.Caret.Offset = textArea.Caret.Offset - 1;
+ _openValueAutoCompleteAction();
+ }
+ }
+
+ public class TypeValueCompletionData : ICompletionData
+ {
+ public TypeValueCompletionData(string text)
+ {
+ this.Text = text;
+ }
+
+ public System.Windows.Media.ImageSource? Image => null;
+
+ public string Text { get; private set; }
+
+ // Use this property if you want to show a fancy UIElement in the list.
+ public object Content => Text;
+
+ public object? Description => null;
+
+ public double Priority { get; }
+
+ public void Complete(TextArea textArea, ISegment completionSegment,
+ EventArgs insertionRequestEventArgs)
+ {
+ textArea.Document.Replace(completionSegment, this.Text);
+ }
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml b/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml
index 2ad5ead..63a3bca 100644
--- a/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml
+++ b/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml
@@ -8,7 +8,7 @@
xmlns:controls="clr-namespace:Bloxstrap.UI.Elements.Controls"
xmlns:resources="clr-namespace:Bloxstrap.Resources"
mc:Ignorable="d"
- d:DesignHeight="640" d:DesignWidth="800"
+ d:DesignHeight="900" d:DesignWidth="800"
Title="AppearancePage"
Scrollable="True">
@@ -43,17 +43,83 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -12,5 +14,16 @@ namespace Bloxstrap.UI.Elements.Settings.Pages
DataContext = new AppearanceViewModel(this);
InitializeComponent();
}
+
+ public void CustomThemeSelection(object sender, SelectionChangedEventArgs e)
+ {
+ AppearanceViewModel viewModel = (AppearanceViewModel)DataContext;
+
+ viewModel.SelectedCustomTheme = (string)((ListBox)sender).SelectedItem;
+ viewModel.SelectedCustomThemeName = viewModel.SelectedCustomTheme;
+
+ viewModel.OnPropertyChanged(nameof(viewModel.SelectedCustomTheme));
+ viewModel.OnPropertyChanged(nameof(viewModel.SelectedCustomThemeName));
+ }
}
}
diff --git a/Bloxstrap/UI/Frontend.cs b/Bloxstrap/UI/Frontend.cs
index d195906..318a45f 100644
--- a/Bloxstrap/UI/Frontend.cs
+++ b/Bloxstrap/UI/Frontend.cs
@@ -58,6 +58,32 @@ namespace Bloxstrap.UI
});
}
+ private static IBootstrapperDialog GetCustomBootstrapper()
+ {
+ const string LOG_IDENT = "Frontend::GetCustomBootstrapper";
+
+ Directory.CreateDirectory(Paths.CustomThemes);
+
+ try
+ {
+ if (App.Settings.Prop.SelectedCustomTheme == null)
+ throw new CustomThemeException("CustomTheme.Errors.NoThemeSelected");
+
+ CustomDialog dialog = new CustomDialog();
+ dialog.ApplyCustomTheme(App.Settings.Prop.SelectedCustomTheme);
+ return dialog;
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteException(LOG_IDENT, ex);
+
+ if (!App.LaunchSettings.QuietFlag.Active)
+ ShowMessageBox(string.Format(Strings.CustomTheme_Errors_SetupFailed, ex.Message), MessageBoxImage.Error);
+
+ return GetBootstrapperDialog(BootstrapperStyle.FluentDialog);
+ }
+ }
+
public static IBootstrapperDialog GetBootstrapperDialog(BootstrapperStyle style)
{
return style switch
@@ -70,6 +96,7 @@ namespace Bloxstrap.UI
BootstrapperStyle.ByfronDialog => new ByfronDialog(),
BootstrapperStyle.FluentDialog => new FluentDialog(false),
BootstrapperStyle.FluentAeroDialog => new FluentDialog(true),
+ BootstrapperStyle.CustomDialog => GetCustomBootstrapper(),
_ => new FluentDialog(false)
};
}
diff --git a/Bloxstrap/UI/Style/Dark.xaml b/Bloxstrap/UI/Style/Dark.xaml
new file mode 100644
index 0000000..2c7b0cb
--- /dev/null
+++ b/Bloxstrap/UI/Style/Dark.xaml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/Style/Default.xaml b/Bloxstrap/UI/Style/Default.xaml
new file mode 100644
index 0000000..9083a8b
--- /dev/null
+++ b/Bloxstrap/UI/Style/Default.xaml
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/Style/Editor-Theme-Dark.xshd b/Bloxstrap/UI/Style/Editor-Theme-Dark.xshd
new file mode 100644
index 0000000..5f5da3e
--- /dev/null
+++ b/Bloxstrap/UI/Style/Editor-Theme-Dark.xshd
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <!--
+ -->
+
+
+ <!\[CDATA\[
+ ]]>
+
+
+ <!DOCTYPE
+ >
+
+
+ <\?
+ \?>
+
+
+ <
+ >
+
+
+
+ "
+ "|(?=<)
+
+
+ '
+ '|(?=<)
+
+ [\d\w_\-\.]+(?=(\s*=))
+ =
+
+
+
+
+
+
+
+ &
+ [\w\d\#]+
+ ;
+
+
+
+ &
+ [\w\d\#]*
+ #missing ;
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/Style/Editor-Theme-Light.xshd b/Bloxstrap/UI/Style/Editor-Theme-Light.xshd
new file mode 100644
index 0000000..8f0bdef
--- /dev/null
+++ b/Bloxstrap/UI/Style/Editor-Theme-Light.xshd
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <!--
+ -->
+
+
+ <!\[CDATA\[
+ ]]>
+
+
+ <!DOCTYPE
+ >
+
+
+ <\?
+ \?>
+
+
+ <
+ >
+
+
+
+ "
+ "|(?=<)
+
+
+ '
+ '|(?=<)
+
+ [\d\w_\-\.]+(?=(\s*=))
+ =
+
+
+
+
+
+
+
+ &
+ [\w\d\#]+
+ ;
+
+
+
+ &
+ [\w\d\#]*
+ #missing ;
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/Style/Light.xaml b/Bloxstrap/UI/Style/Light.xaml
new file mode 100644
index 0000000..fcc308d
--- /dev/null
+++ b/Bloxstrap/UI/Style/Light.xaml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/ViewModels/Dialogs/AddCustomThemeViewModel.cs b/Bloxstrap/UI/ViewModels/Dialogs/AddCustomThemeViewModel.cs
new file mode 100644
index 0000000..ec4282d
--- /dev/null
+++ b/Bloxstrap/UI/ViewModels/Dialogs/AddCustomThemeViewModel.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace Bloxstrap.UI.ViewModels.Dialogs
+{
+ internal class AddCustomThemeViewModel : NotifyPropertyChangedViewModel
+ {
+ public static CustomThemeTemplate[] Templates => Enum.GetValues();
+
+ public CustomThemeTemplate Template { get; set; } = CustomThemeTemplate.Simple;
+
+ public string Name { get; set; } = "";
+
+ private string _filePath = "";
+ public string FilePath
+ {
+ get => _filePath;
+ set
+ {
+ if (_filePath != value)
+ {
+ _filePath = value;
+ OnPropertyChanged(nameof(FilePath));
+ OnPropertyChanged(nameof(FilePathVisibility));
+ }
+ }
+ }
+ public Visibility FilePathVisibility => string.IsNullOrEmpty(FilePath) ? Visibility.Collapsed : Visibility.Visible;
+
+ public int SelectedTab { get; set; } = 0;
+
+ private string _nameError = "";
+ public string NameError
+ {
+ get => _nameError;
+ set
+ {
+ if (_nameError != value)
+ {
+ _nameError = value;
+ OnPropertyChanged(nameof(NameError));
+ OnPropertyChanged(nameof(NameErrorVisibility));
+ }
+ }
+ }
+ public Visibility NameErrorVisibility => string.IsNullOrEmpty(NameError) ? Visibility.Collapsed : Visibility.Visible;
+
+ private string _fileError = "";
+ public string FileError
+ {
+ get => _fileError;
+ set
+ {
+ if (_fileError != value)
+ {
+ _fileError = value;
+ OnPropertyChanged(nameof(FileError));
+ OnPropertyChanged(nameof(FileErrorVisibility));
+ }
+ }
+ }
+ public Visibility FileErrorVisibility => string.IsNullOrEmpty(FileError) ? Visibility.Collapsed : Visibility.Visible;
+ }
+}
diff --git a/Bloxstrap/UI/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs b/Bloxstrap/UI/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs
new file mode 100644
index 0000000..2e0bcb4
--- /dev/null
+++ b/Bloxstrap/UI/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs
@@ -0,0 +1,84 @@
+using Bloxstrap.UI.Elements.Bootstrapper;
+using CommunityToolkit.Mvvm.Input;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Input;
+
+namespace Bloxstrap.UI.ViewModels.Editor
+{
+ public class BootstrapperEditorWindowViewModel : NotifyPropertyChangedViewModel
+ {
+ private CustomDialog? _dialog = null;
+
+ public ICommand PreviewCommand => new RelayCommand(Preview);
+ public ICommand SaveCommand => new RelayCommand(Save);
+ public ICommand OpenThemeFolderCommand => new RelayCommand(OpenThemeFolder);
+
+ public Action ThemeSavedCallback { get; set; } = null!;
+
+ public string Directory { get; set; } = "";
+
+ public string Name { get; set; } = "";
+ public string Title { get; set; } = "Editing \"Custom Theme\"";
+ public string Code { get; set; } = "";
+
+ public bool CodeChanged { get; set; } = false;
+
+ private void Preview()
+ {
+ const string LOG_IDENT = "BootstrapperEditorWindowViewModel::Preview";
+
+ try
+ {
+ CustomDialog dialog = new CustomDialog();
+
+ dialog.ApplyCustomTheme(Name, Code);
+
+ _dialog?.CloseBootstrapper();
+ _dialog = dialog;
+
+ dialog.Message = Strings.Bootstrapper_StylePreview_TextCancel;
+ dialog.CancelEnabled = true;
+ dialog.ShowBootstrapper();
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Failed to preview custom theme");
+ App.Logger.WriteException(LOG_IDENT, ex);
+
+ Frontend.ShowMessageBox($"Failed to preview theme: {ex.Message}", MessageBoxImage.Error, MessageBoxButton.OK);
+ }
+ }
+
+ private void Save()
+ {
+ const string LOG_IDENT = "BootstrapperEditorWindowViewModel::Save";
+
+ string path = Path.Combine(Directory, "Theme.xml");
+
+ try
+ {
+ File.WriteAllText(path, Code);
+ CodeChanged = false;
+ ThemeSavedCallback.Invoke(true, "Your theme has been saved!");
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Failed to save custom theme");
+ App.Logger.WriteException(LOG_IDENT, ex);
+
+ //Frontend.ShowMessageBox($"Failed to save theme: {ex.Message}", MessageBoxImage.Error, MessageBoxButton.OK);
+ ThemeSavedCallback.Invoke(false, ex.Message);
+ }
+ }
+
+ private void OpenThemeFolder()
+ {
+ Process.Start("explorer.exe", Directory);
+ }
+ }
+}
diff --git a/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs b/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs
index 21ae1d3..d09f23c 100644
--- a/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs
+++ b/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs
@@ -4,10 +4,13 @@ using System.Windows.Controls;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
+using ICSharpCode.SharpZipLib.Zip;
using Microsoft.Win32;
using Bloxstrap.UI.Elements.Settings;
+using Bloxstrap.UI.Elements.Editor;
+using Bloxstrap.UI.Elements.Dialogs;
namespace Bloxstrap.UI.ViewModels.Settings
{
@@ -18,6 +21,12 @@ namespace Bloxstrap.UI.ViewModels.Settings
public ICommand PreviewBootstrapperCommand => new RelayCommand(PreviewBootstrapper);
public ICommand BrowseCustomIconLocationCommand => new RelayCommand(BrowseCustomIconLocation);
+ public ICommand AddCustomThemeCommand => new RelayCommand(AddCustomTheme);
+ public ICommand DeleteCustomThemeCommand => new RelayCommand(DeleteCustomTheme);
+ public ICommand RenameCustomThemeCommand => new RelayCommand(RenameCustomTheme);
+ public ICommand EditCustomThemeCommand => new RelayCommand(EditCustomTheme);
+ public ICommand ExportCustomThemeCommand => new RelayCommand(ExportCustomTheme);
+
private void PreviewBootstrapper()
{
IBootstrapperDialog dialog = App.Settings.Prop.BootstrapperStyle.GetNew();
@@ -51,6 +60,8 @@ namespace Bloxstrap.UI.ViewModels.Settings
foreach (var entry in BootstrapperIconEx.Selections)
Icons.Add(new BootstrapperIconEntry { IconType = entry });
+
+ PopulateCustomThemes();
}
public IEnumerable Themes { get; } = Enum.GetValues(typeof(Theme)).Cast();
@@ -78,9 +89,15 @@ namespace Bloxstrap.UI.ViewModels.Settings
public BootstrapperStyle Dialog
{
get => App.Settings.Prop.BootstrapperStyle;
- set => App.Settings.Prop.BootstrapperStyle = value;
+ set
+ {
+ App.Settings.Prop.BootstrapperStyle = value;
+ OnPropertyChanged(nameof(CustomThemesExpanded)); // TODO: only fire when needed
+ }
}
+ public bool CustomThemesExpanded => App.Settings.Prop.BootstrapperStyle == BootstrapperStyle.CustomDialog;
+
public ObservableCollection Icons { get; set; } = new();
public BootstrapperIcon Icon
@@ -116,5 +133,183 @@ namespace Bloxstrap.UI.ViewModels.Settings
OnPropertyChanged(nameof(Icons));
}
}
+
+ private void DeleteCustomThemeStructure(string name)
+ {
+ string dir = Path.Combine(Paths.CustomThemes, name);
+ Directory.Delete(dir, true);
+ }
+
+ private void RenameCustomThemeStructure(string oldName, string newName)
+ {
+ string oldDir = Path.Combine(Paths.CustomThemes, oldName);
+ string newDir = Path.Combine(Paths.CustomThemes, newName);
+ Directory.Move(oldDir, newDir);
+ }
+
+ private void AddCustomTheme()
+ {
+ var dialog = new AddCustomThemeDialog();
+ dialog.ShowDialog();
+
+ if (dialog.Created)
+ {
+ CustomThemes.Add(dialog.ThemeName);
+ SelectedCustomThemeIndex = CustomThemes.Count - 1;
+
+ OnPropertyChanged(nameof(SelectedCustomThemeIndex));
+ OnPropertyChanged(nameof(IsCustomThemeSelected));
+
+ if (dialog.OpenEditor)
+ EditCustomTheme();
+ }
+ }
+
+ private void DeleteCustomTheme()
+ {
+ if (SelectedCustomTheme is null)
+ return;
+
+ try
+ {
+ DeleteCustomThemeStructure(SelectedCustomTheme);
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteException("AppearanceViewModel::DeleteCustomTheme", ex);
+ Frontend.ShowMessageBox(string.Format(Strings.Menu_Appearance_CustomThemes_DeleteFailed, SelectedCustomTheme, ex.Message), MessageBoxImage.Error);
+ return;
+ }
+
+ CustomThemes.Remove(SelectedCustomTheme);
+
+ if (CustomThemes.Any())
+ {
+ SelectedCustomThemeIndex = CustomThemes.Count - 1;
+ OnPropertyChanged(nameof(SelectedCustomThemeIndex));
+ }
+
+ OnPropertyChanged(nameof(IsCustomThemeSelected));
+ }
+
+ private void RenameCustomTheme()
+ {
+ if (SelectedCustomTheme is null)
+ return;
+
+ if (SelectedCustomTheme == SelectedCustomThemeName)
+ return;
+
+ try
+ {
+ RenameCustomThemeStructure(SelectedCustomTheme, SelectedCustomThemeName);
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteException("AppearanceViewModel::RenameCustomTheme", ex);
+ Frontend.ShowMessageBox(string.Format(Strings.Menu_Appearance_CustomThemes_RenameFailed, SelectedCustomTheme, ex.Message), MessageBoxImage.Error);
+ return;
+ }
+
+ int idx = CustomThemes.IndexOf(SelectedCustomTheme);
+ CustomThemes[idx] = SelectedCustomThemeName;
+
+ SelectedCustomThemeIndex = idx;
+ OnPropertyChanged(nameof(SelectedCustomThemeIndex));
+ }
+
+ private void EditCustomTheme()
+ {
+ if (SelectedCustomTheme is null)
+ return;
+
+ new BootstrapperEditorWindow(SelectedCustomTheme).ShowDialog();
+ }
+
+ private void ExportCustomTheme()
+ {
+ if (SelectedCustomTheme is null)
+ return;
+
+ var dialog = new SaveFileDialog
+ {
+ FileName = $"{SelectedCustomTheme}.zip",
+ Filter = $"{Strings.FileTypes_ZipArchive}|*.zip"
+ };
+
+ if (dialog.ShowDialog() != true)
+ return;
+
+ string themeDir = Path.Combine(Paths.CustomThemes, SelectedCustomTheme);
+
+ using var memStream = new MemoryStream();
+ using var zipStream = new ZipOutputStream(memStream);
+
+ foreach (var filePath in Directory.EnumerateFiles(themeDir, "*.*", SearchOption.AllDirectories))
+ {
+ string relativePath = filePath[(themeDir.Length + 1)..];
+
+ var entry = new ZipEntry(relativePath);
+ entry.DateTime = DateTime.Now;
+
+ zipStream.PutNextEntry(entry);
+
+ using var fileStream = File.OpenRead(filePath);
+ fileStream.CopyTo(zipStream);
+ }
+
+ zipStream.CloseEntry();
+ zipStream.Finish();
+ memStream.Position = 0;
+
+ using var outputStream = File.OpenWrite(dialog.FileName);
+ memStream.CopyTo(outputStream);
+
+ Process.Start("explorer.exe", $"/select,\"{dialog.FileName}\"");
+ }
+
+ private void PopulateCustomThemes()
+ {
+ string? selected = App.Settings.Prop.SelectedCustomTheme;
+
+ Directory.CreateDirectory(Paths.CustomThemes);
+
+ foreach (string directory in Directory.GetDirectories(Paths.CustomThemes))
+ {
+ if (!File.Exists(Path.Combine(directory, "Theme.xml")))
+ continue; // missing the main theme file, ignore
+
+ string name = Path.GetFileName(directory);
+ CustomThemes.Add(name);
+ }
+
+ if (selected != null)
+ {
+ int idx = CustomThemes.IndexOf(selected);
+
+ if (idx != -1)
+ {
+ SelectedCustomThemeIndex = idx;
+ OnPropertyChanged(nameof(SelectedCustomThemeIndex));
+ }
+ else
+ {
+ SelectedCustomTheme = null;
+ }
+ }
+ }
+
+ public string? SelectedCustomTheme
+ {
+ get => App.Settings.Prop.SelectedCustomTheme;
+ set => App.Settings.Prop.SelectedCustomTheme = value;
+ }
+
+ public string SelectedCustomThemeName { get; set; } = "";
+
+ public int SelectedCustomThemeIndex { get; set; }
+
+ public ObservableCollection CustomThemes { get; set; } = new();
+ public bool IsCustomThemeSelected => SelectedCustomTheme is not null;
}
}
diff --git a/Bloxstrap/Utility/PathValidator.cs b/Bloxstrap/Utility/PathValidator.cs
new file mode 100644
index 0000000..024ed09
--- /dev/null
+++ b/Bloxstrap/Utility/PathValidator.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Bloxstrap.Utility
+{
+ internal static class PathValidator
+ {
+ public enum ValidationResult
+ {
+ Ok,
+ IllegalCharacter,
+ ReservedFileName,
+ ReservedDirectoryName
+ }
+
+ private static readonly string[] _reservedNames = new string[]
+ {
+ "CON",
+ "PRN",
+ "AUX",
+ "NUL",
+ "COM1",
+ "COM2",
+ "COM3",
+ "COM4",
+ "COM5",
+ "COM6",
+ "COM7",
+ "COM8",
+ "COM9",
+ "LPT1",
+ "LPT2",
+ "LPT3",
+ "LPT4",
+ "LPT5",
+ "LPT6",
+ "LPT7",
+ "LPT8",
+ "LPT9"
+ };
+
+ private static readonly char[] _directorySeperatorDelimiters = new char[]
+ {
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ };
+
+ private static readonly char[] _invalidPathChars = GetInvalidPathChars();
+
+ public static char[] GetInvalidPathChars()
+ {
+ char[] invalids = new char[] { '/', '\\', ':', '*', '?', '"', '<', '>', '|' };
+ char[] otherInvalids = Path.GetInvalidPathChars();
+
+ char[] result = new char[invalids.Length + otherInvalids.Length];
+ invalids.CopyTo(result, 0);
+ otherInvalids.CopyTo(result, invalids.Length);
+
+ return result;
+ }
+
+ public static ValidationResult IsFileNameValid(string fileName)
+ {
+ if (fileName.IndexOfAny(_invalidPathChars) != -1)
+ return ValidationResult.IllegalCharacter;
+
+ string fileNameNoExt = Path.GetFileNameWithoutExtension(fileName).ToUpperInvariant();
+ if (_reservedNames.Contains(fileNameNoExt))
+ return ValidationResult.ReservedFileName;
+
+ return ValidationResult.Ok;
+ }
+
+ public static ValidationResult IsPathValid(string path)
+ {
+ string? pathRoot = Path.GetPathRoot(path);
+ string pathNoRoot = pathRoot != null ? path[pathRoot.Length..] : path;
+
+ string[] pathParts = pathNoRoot.Split(_directorySeperatorDelimiters);
+
+ foreach (var part in pathParts)
+ {
+ if (part.IndexOfAny(_invalidPathChars) != -1)
+ return ValidationResult.IllegalCharacter;
+
+ if (_reservedNames.Contains(part))
+ return ValidationResult.ReservedDirectoryName;
+ }
+
+ string fileName = Path.GetFileName(path);
+ if (fileName.IndexOfAny(_invalidPathChars) != -1)
+ return ValidationResult.IllegalCharacter;
+
+ string fileNameNoExt = Path.GetFileNameWithoutExtension(path).ToUpperInvariant();
+ if (_reservedNames.Contains(fileNameNoExt))
+ return ValidationResult.ReservedFileName;
+
+ return ValidationResult.Ok;
+ }
+ }
+}
diff --git a/wpfui b/wpfui
index 9080158..dca423b 160000
--- a/wpfui
+++ b/wpfui
@@ -1 +1 @@
-Subproject commit 9080158ba8d496501146d1167aae910898eff9af
+Subproject commit dca423b724ec24bd3377da3a27f4055ae317b50a