diff --git a/Bloxstrap/Bloxstrap.csproj b/Bloxstrap/Bloxstrap.csproj
index 16b56e6..3e54892 100644
--- a/Bloxstrap/Bloxstrap.csproj
+++ b/Bloxstrap/Bloxstrap.csproj
@@ -28,6 +28,7 @@
+
@@ -49,6 +50,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/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/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 36f8136..47a20df 100644
--- a/Bloxstrap/Paths.cs
+++ b/Bloxstrap/Paths.cs
@@ -22,6 +22,7 @@
public static string Integrations { get; private set; } = "";
public static string Modifications { get; private set; } = "";
public static string Roblox { 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");
Modifications = Path.Combine(Base, "Modifications");
Roblox = Path.Combine(Base, "Roblox");
+ CustomThemes = Path.Combine(Base, "CustomThemes");
Application = Path.Combine(Base, $"{App.ProjectName}.exe");
}
diff --git a/Bloxstrap/Resources/CustomBootstrapperTemplate.xml b/Bloxstrap/Resources/CustomBootstrapperTemplate.xml
new file mode 100644
index 0000000..3f4cd22
--- /dev/null
+++ b/Bloxstrap/Resources/CustomBootstrapperTemplate.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/Resources/Strings.Designer.cs b/Bloxstrap/Resources/Strings.Designer.cs
index ad28195..6e0be3f 100644
--- a/Bloxstrap/Resources/Strings.Designer.cs
+++ b/Bloxstrap/Resources/Strings.Designer.cs
@@ -1084,6 +1084,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).
///
diff --git a/Bloxstrap/Resources/Strings.resx b/Bloxstrap/Resources/Strings.resx
index 6b7fe31..69e8cfa 100644
--- a/Bloxstrap/Resources/Strings.resx
+++ b/Bloxstrap/Resources/Strings.resx
@@ -1239,4 +1239,7 @@ Would you like to enable test mode?
Version {0}
+
+ Custom
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs
new file mode 100644
index 0000000..b6ccbad
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs
@@ -0,0 +1,602 @@
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Xml.Linq;
+using Wpf.Ui.Markup;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ public partial class CustomDialog
+ {
+ private const int MaxElements = 50;
+
+ private static ThicknessConverter? _thicknessConverter = null;
+ private static ThicknessConverter ThicknessConverter { get => _thicknessConverter ??= new ThicknessConverter(); }
+
+ private static BrushConverter? _brushConverter = null;
+ private static BrushConverter BrushConverter { get => _brushConverter ??= new BrushConverter(); }
+
+ 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 void HandleXmlElementDelegate(CustomDialog dialog, XElement xmlElement);
+ delegate void HandleXmlTransformationElementDelegate(TransformGroup group, XElement xmlElement);
+
+ private static Dictionary _elementHandlerMap = new Dictionary()
+ {
+ //["BloxstrapCustomBootstrapper"] = HandleXmlElement_BloxstrapCustomBootstrapper,
+ ["TitleBar"] = HandleXmlElement_TitleBar,
+ ["Button"] = HandleXmlElement_Button,
+ ["ProgressBar"] = HandleXmlElement_ProgressBar,
+ ["TextBlock"] = HandleXmlElement_TextBlock,
+ ["Image"] = HandleXmlElement_Image
+ };
+
+ private static Dictionary _transformationHandlerMap = new Dictionary()
+ {
+ ["ScaleTransform"] = HandleXmlTransformationElement_ScaleTransform,
+ ["SkewTransform"] = HandleXmlTransformationElement_SkewTransform,
+ ["RotateTransform"] = HandleXmlTransformationElement_RotateTransform,
+ ["TranslateTransform"] = HandleXmlTransformationElement_TranslateTransform
+ };
+
+ #region Utilities
+ // https://stackoverflow.com/a/2961702
+ private static T? ConvertValue(string input) where T : struct
+ {
+ try
+ {
+ var converter = TypeDescriptor.GetConverter(typeof(T));
+ if (converter != null)
+ {
+ // Cast ConvertFromString(string text) : object to (T)
+ //return (T?)converter.ConvertFromString(input);
+ return (T?)converter.ConvertFromInvariantString(input);
+ }
+ return default;
+ }
+ catch (NotSupportedException)
+ {
+ return default;
+ }
+ }
+
+ 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 Exception($"Element {element.Name} is missing the {attributeName} attribute");
+ }
+
+ 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 Exception($"Element {element.Name} is missing the {attributeName} attribute");
+ }
+
+ T? parsed = ConvertValue(attribute.Value);
+ if (parsed == null)
+ throw new Exception($"{element.Name} height is not a valid {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 Exception($"{elementName} {attributeName} must be larger than {min}");
+ if (max != null && value > max)
+ throw new Exception($"{elementName} {attributeName} must be smaller than {max}");
+ }
+
+ private static void ValidateXmlElement(string elementName, string attributeName, double value, double? min = null, double? max = null)
+ {
+ if (min != null && value < min)
+ throw new Exception($"{elementName} {attributeName} must be larger than {min}");
+ if (max != null && value > max)
+ throw new Exception($"{elementName} {attributeName} must be smaller than {max}");
+ }
+
+ // You can't do numeric only generics in .NET 6. The feature is exclusive to .NET 7+.
+ private static double ParseXmlAttributeClamped(XElement element, string attributeName, double? defaultValue = null, double? min = null, double? max = null)
+ {
+ double value = ParseXmlAttribute(element, attributeName, defaultValue);
+ ValidateXmlElement(element.Name.ToString(), attributeName, value, min, max);
+ return value;
+ }
+
+ 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 object? GetThicknessFromXElement(XElement xmlElement, string attributeName)
+ {
+ string? attributeValue = xmlElement.Attribute(attributeName)?.Value?.ToString();
+ if (attributeValue == null)
+ return null;
+
+ object? thickness;
+ try
+ {
+ thickness = ThicknessConverter.ConvertFromInvariantString(attributeValue);
+ }
+ catch (Exception ex)
+ {
+ throw new Exception($"{xmlElement.Name} has invalid {attributeName}: {ex.Message}", ex);
+ }
+
+ if (thickness == null)
+ throw new Exception($"{xmlElement.Name} has invalid {attributeName}");
+
+ return thickness;
+ }
+
+ 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 Exception($"{element.Name} Unknown 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 Exception($"{element.Name} Unknown FontStyle {value}");
+ }
+ }
+
+ ///
+ /// 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 Exception($"{element.Name} has invalid {attributeName}: {ex.Message}", ex);
+ }
+ }
+ #endregion
+
+ #region Transformation Elements
+ private static void HandleXmlTransformationElement_ScaleTransform(TransformGroup group, 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);
+
+ group.Children.Add(st);
+ }
+
+ private static void HandleXmlTransformationElement_SkewTransform(TransformGroup group, 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);
+
+ group.Children.Add(st);
+ }
+
+ private static void HandleXmlTransformationElement_RotateTransform(TransformGroup group, XElement xmlElement)
+ {
+ var rt = new RotateTransform();
+
+ rt.Angle = ParseXmlAttribute(xmlElement, "Angle", 0);
+ rt.CenterX = ParseXmlAttribute(xmlElement, "CenterX", 0);
+ rt.CenterY = ParseXmlAttribute(xmlElement, "CenterY", 0);
+
+ group.Children.Add(rt);
+ }
+
+ private static void HandleXmlTransformationElement_TranslateTransform(TransformGroup group, XElement xmlElement)
+ {
+ var tt = new TranslateTransform();
+
+ tt.X = ParseXmlAttribute(xmlElement, "X", 0);
+ tt.Y = ParseXmlAttribute(xmlElement, "Y", 0);
+
+ group.Children.Add(tt);
+ }
+
+ private static void HandleXmlTransformation(TransformGroup group, XElement xmlElement)
+ {
+ if (!_transformationHandlerMap.ContainsKey(xmlElement.Name.ToString()))
+ throw new Exception($"Unknown transformation {xmlElement.Name}");
+
+ _transformationHandlerMap[xmlElement.Name.ToString()](group, xmlElement);
+ }
+
+ private static void ApplyTransformations_UIElement(UIElement uiElement, XElement xmlElement)
+ {
+ if (!xmlElement.HasElements)
+ return;
+
+ var tg = new TransformGroup();
+
+ foreach (var child in xmlElement.Elements())
+ HandleXmlTransformation(tg, child);
+
+ if (tg.Children.Any())
+ uiElement.RenderTransform = tg;
+ }
+ #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);
+
+ object? margin = GetThicknessFromXElement(xmlElement, "Margin");
+ if (margin != null)
+ uiElement.Margin = (Thickness)margin;
+
+ uiElement.Height = ParseXmlAttributeClamped(xmlElement, "Height", defaultValue: 100.0, min: 0, max: 1000);
+ uiElement.Width = ParseXmlAttributeClamped(xmlElement, "Width", defaultValue: 100.0, min: 0, max: 1000);
+
+ // 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);
+
+ int zIndex = ParseXmlAttributeClamped(xmlElement, "ZIndex", defaultValue: 0, min: 0, max: 1000);
+ Panel.SetZIndex(uiElement, zIndex);
+ }
+
+ 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;
+
+ // TODO: this isn't working for BloxstrapCustomBootstrapper. likely because of wpf.ui's themeservice.
+ object? foregroundBrush = GetBrushFromXElement(xmlElement, "Background");
+ if (foregroundBrush is Brush)
+ uiElement.Background = (Brush)foregroundBrush;
+ else if (foregroundBrush is string)
+ uiElement.SetResourceReference(Control.BackgroundProperty, foregroundBrush);
+
+ object? borderBrush = GetBrushFromXElement(xmlElement, "BorderBrush");
+ if (borderBrush is Brush)
+ uiElement.BorderBrush = (Brush)borderBrush;
+ else if (borderBrush is string)
+ uiElement.SetResourceReference(Control.BorderBrushProperty, borderBrush);
+ }
+
+ private static void HandleXmlElement_BloxstrapCustomBootstrapper(CustomDialog dialog, XElement xmlElement)
+ {
+ xmlElement.SetAttributeValue("Visibility", "Collapsed"); // don't show the bootstrapper yet!!!
+ HandleXmlElement_Control(dialog, dialog, xmlElement);
+
+ var theme = ParseXmlAttribute(xmlElement, "Theme", Theme.Default);
+ dialog.Resources.MergedDictionaries.Clear();
+ dialog.Resources.MergedDictionaries.Add(new ThemesDictionary() { Theme = theme.GetFinal() == Theme.Dark ? Wpf.Ui.Appearance.ThemeType.Dark : Wpf.Ui.Appearance.ThemeType.Light });
+
+ // 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);
+
+ dialog.MaxHeight = dialog.Height;
+ dialog.MaxWidth = dialog.Width;
+ }
+
+ private static void HandleXmlElement_TitleBar(CustomDialog dialog, XElement xmlElement)
+ {
+ xmlElement.SetAttributeValue("Name", "TitleBar"); // prevent two titlebars from existing
+ HandleXmlElement_Control(dialog, dialog.RootTitleBar, xmlElement);
+
+ 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.Title = title;
+ dialog.RootTitleBar.Title = title;
+ }
+
+ private static void HandleXmlElement_Button(CustomDialog dialog, XElement xmlElement)
+ {
+ var button = new Button();
+ HandleXmlElement_Control(dialog, button, xmlElement);
+
+ button.Content = xmlElement.Attribute("Text")?.Value?.ToString();
+
+ 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);
+ }
+
+ ApplyTransformations_UIElement(button, xmlElement);
+
+ dialog.ElementGrid.Children.Add(button);
+ }
+
+ private static void HandleXmlElement_ProgressBar(CustomDialog dialog, XElement xmlElement)
+ {
+ var progressBar = new ProgressBar();
+ HandleXmlElement_Control(dialog, progressBar, xmlElement);
+
+ progressBar.IsIndeterminate = ParseXmlAttribute(xmlElement, "IsIndeterminate", false);
+
+ progressBar.Value = ParseXmlAttribute(xmlElement, "Value", 0);
+ progressBar.Maximum = ParseXmlAttribute(xmlElement, "Maximum", 100);
+
+ 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);
+ }
+
+ ApplyTransformations_UIElement(progressBar, xmlElement);
+
+ dialog.ElementGrid.Children.Add(progressBar);
+ }
+
+ private static void HandleXmlElement_TextBlock(CustomDialog dialog, XElement xmlElement)
+ {
+ var textBlock = new TextBlock();
+ HandleXmlElement_FrameworkElement(dialog, textBlock, xmlElement);
+
+ textBlock.Text = xmlElement.Attribute("Text")?.Value;
+
+ object? foregroundBrush = GetBrushFromXElement(xmlElement, "Foreground");
+ if (foregroundBrush is Brush)
+ textBlock.Foreground = (Brush)foregroundBrush;
+ else if (foregroundBrush is string)
+ textBlock.SetResourceReference(TextBlock.ForegroundProperty, foregroundBrush);
+
+ textBlock.FontSize = ParseXmlAttribute(xmlElement, "FontSize", 12);
+ textBlock.FontWeight = GetFontWeightFromXElement(xmlElement);
+ textBlock.FontStyle = GetFontStyleFromXElement(xmlElement);
+
+ textBlock.TextAlignment = ParseXmlAttribute(xmlElement, "TextAlignment", TextAlignment.Center);
+
+ if (xmlElement.Attribute("Name")?.Value == "StatusText")
+ {
+ Binding textBinding = new Binding("Message") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(textBlock, TextBlock.TextProperty, textBinding);
+ }
+
+ ApplyTransformations_UIElement(textBlock, xmlElement);
+
+ dialog.ElementGrid.Children.Add(textBlock);
+ }
+
+ private static void HandleXmlElement_Image(CustomDialog dialog, XElement xmlElement)
+ {
+ var image = new Image();
+ HandleXmlElement_FrameworkElement(dialog, image, xmlElement);
+
+ string sourcePath = GetXmlAttribute(xmlElement, "Source");
+ sourcePath = sourcePath.Replace("theme://", $"{dialog.ThemeDir}\\");
+
+ RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.HighQuality); // should this be modifiable by the user?
+
+ if (sourcePath == "{Icon}")
+ {
+ // bind the icon property
+ Binding binding = new Binding("Icon") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(image, Image.SourceProperty, binding);
+ }
+ else
+ {
+ if (!Uri.TryCreate(sourcePath, UriKind.RelativeOrAbsolute, out Uri? result))
+ throw new Exception("Image failed to parse Source as Uri");
+
+ if (result == null)
+ throw new Exception("Image Source uri is null");
+
+ BitmapImage bitmapImage;
+ try
+ {
+ bitmapImage = new BitmapImage(result);
+ }
+ catch (Exception ex)
+ {
+ throw new Exception($"Image Failed to create BitmapImage: {ex.Message}", ex);
+ }
+
+ image.Source = bitmapImage;
+ }
+
+ ApplyTransformations_UIElement(image, xmlElement);
+
+ dialog.ElementGrid.Children.Add(image);
+ }
+
+ private void HandleXml(CustomDialog dialog, XElement xmlElement)
+ {
+ if (!_elementHandlerMap.ContainsKey(xmlElement.Name.ToString()))
+ throw new Exception($"Unknown element {xmlElement.Name}");
+
+ _elementHandlerMap[xmlElement.Name.ToString()](dialog, xmlElement);
+ }
+
+ private void HandleXmlBase(XElement xml)
+ {
+ if (_initialised)
+ throw new Exception("Custom dialog has already been initialised");
+
+ if (xml.Name != "BloxstrapCustomBootstrapper")
+ throw new Exception("XML root is not a BloxstrapCustomBootstrapper");
+
+ if (xml.Attribute("Version")?.Value != "0")
+ throw new Exception("Unknown BloxstrapCustomBootstrapper version");
+
+ if (xml.Elements().Count() > MaxElements)
+ throw new Exception($"Custom bootstrappers can have a maximum of {MaxElements} elements");
+
+ _initialised = true;
+
+ // handle root
+ HandleXmlElement_BloxstrapCustomBootstrapper(this, xml);
+
+ // handle everything else
+ foreach (var child in xml.Elements())
+ HandleXml(this, child);
+ }
+ #endregion
+
+ #region Public APIs
+ public void ApplyCustomTheme(string name, string contents)
+ {
+ ThemeDir = 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 Exception($"XML parse failed: {ex.Message}", ex);
+ }
+
+ HandleXmlBase(xml);
+ }
+
+ public void ApplyCustomTheme(string name)
+ {
+ string path = Path.Combine(Paths.CustomThemes, name, "Theme.xml");
+
+ ApplyCustomTheme(name, File.ReadAllText(path));
+ }
+ #endregion
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml
new file mode 100644
index 0000000..a37854c
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/Editor/BootstrapperEditorWindow.xaml b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml
new file mode 100644
index 0000000..4c4cdf4
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs
new file mode 100644
index 0000000..df75b5e
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs
@@ -0,0 +1,31 @@
+using Bloxstrap.UI.Elements.Base;
+using Bloxstrap.UI.ViewModels.Editor;
+
+namespace Bloxstrap.UI.Elements.Editor
+{
+ ///
+ /// Interaction logic for BootstrapperEditorWindow.xaml
+ ///
+ public partial class BootstrapperEditorWindow : WpfUiWindow
+ {
+ public BootstrapperEditorWindow(string name)
+ {
+ var viewModel = new BootstrapperEditorWindowViewModel();
+ viewModel.Name = name;
+ viewModel.Title = $"Editing \"{name}\"";
+ viewModel.Code = File.ReadAllText(Path.Combine(Paths.CustomThemes, name, "Theme.xml"));
+
+ DataContext = viewModel;
+ InitializeComponent();
+
+ UIXML.Text = viewModel.Code;
+ }
+
+ private void OnCodeChanged(object sender, EventArgs e)
+ {
+ BootstrapperEditorWindowViewModel viewModel = (BootstrapperEditorWindowViewModel)DataContext;
+ viewModel.Code = UIXML.Text;
+ viewModel.OnPropertyChanged(nameof(viewModel.Code));
+ }
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml b/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml
index 2ad5ead..8846e6e 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">
@@ -114,5 +114,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml.cs b/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml.cs
index e3f264e..885832b 100644
--- a/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml.cs
+++ b/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml.cs
@@ -1,5 +1,7 @@
using Bloxstrap.UI.ViewModels.Settings;
+using System.Windows.Controls;
+
namespace Bloxstrap.UI.Elements.Settings.Pages
{
///
@@ -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 dc40990..82174bd 100644
--- a/Bloxstrap/UI/Frontend.cs
+++ b/Bloxstrap/UI/Frontend.cs
@@ -54,6 +54,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 Exception("No custom theme selected");
+
+ 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)
+ Frontend.ShowMessageBox($"Failed to setup custom bootstrapper: {ex.Message}.\nDefaulting to Fluent.", MessageBoxImage.Error);
+
+ return GetBootstrapperDialog(BootstrapperStyle.FluentDialog);
+ }
+ }
+
public static IBootstrapperDialog GetBootstrapperDialog(BootstrapperStyle style)
{
return style switch
@@ -66,6 +92,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/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs b/Bloxstrap/UI/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs
new file mode 100644
index 0000000..7148dd4
--- /dev/null
+++ b/Bloxstrap/UI/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs
@@ -0,0 +1,70 @@
+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 string Name { get; set; } = "";
+ public string Title { get; set; } = "Editing \"Custom Theme\"";
+ public string Code { get; set; } = "";
+
+ private void Preview()
+ {
+ const string LOG_IDENT = "BootstrapperEditorWindowViewModel::Preview";
+
+ try
+ {
+ CustomDialog dialog = new CustomDialog();
+
+ dialog.ApplyCustomTheme(Name, Code);
+
+ if (_dialog != null)
+ _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(Paths.CustomThemes, Name, "Theme.xml");
+
+ try
+ {
+ File.WriteAllText(path, Code);
+ }
+ 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);
+ }
+ }
+ }
+}
diff --git a/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs b/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs
index 21ae1d3..5f22c87 100644
--- a/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs
+++ b/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs
@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using Bloxstrap.UI.Elements.Settings;
+using Bloxstrap.UI.Elements.Editor;
namespace Bloxstrap.UI.ViewModels.Settings
{
@@ -18,6 +19,11 @@ 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);
+
private void PreviewBootstrapper()
{
IBootstrapperDialog dialog = App.Settings.Prop.BootstrapperStyle.GetNew();
@@ -51,6 +57,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();
@@ -116,5 +124,170 @@ namespace Bloxstrap.UI.ViewModels.Settings
OnPropertyChanged(nameof(Icons));
}
}
+
+ private string CreateCustomThemeName()
+ {
+ int count = Directory.GetDirectories(Paths.CustomThemes).Count();
+
+ string name = $"Custom Theme {count + 1}";
+
+ // TODO: this sucks
+ if (Directory.Exists(Path.Combine(Paths.CustomThemes, name))) // DUCK
+ name += " " + Random.Shared.Next(1, 100000).ToString(); // easy
+
+ return name;
+ }
+
+ private void CreateCustomThemeStructure(string name)
+ {
+ string dir = Path.Combine(Paths.CustomThemes, name);
+ Directory.CreateDirectory(dir);
+
+ string themeFilePath = Path.Combine(dir, "Theme.xml");
+
+ string templateContent = Encoding.UTF8.GetString(Resource.Get("CustomBootstrapperTemplate.xml").Result);
+
+ File.WriteAllText(themeFilePath, templateContent);
+ }
+
+ 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()
+ {
+ string name = CreateCustomThemeName();
+
+ try
+ {
+ CreateCustomThemeStructure(name);
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteException("AppearanceViewModel::AddCustomTheme", ex);
+ Frontend.ShowMessageBox($"Failed to create custom theme: {ex.Message}", MessageBoxImage.Error);
+ return;
+ }
+
+ CustomThemes.Add(name);
+ SelectedCustomThemeIndex = CustomThemes.Count - 1;
+
+ OnPropertyChanged(nameof(SelectedCustomThemeIndex));
+ OnPropertyChanged(nameof(IsCustomThemeSelected));
+ }
+
+ private void DeleteCustomTheme()
+ {
+ if (SelectedCustomTheme is null)
+ return;
+
+ try
+ {
+ DeleteCustomThemeStructure(SelectedCustomTheme);
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteException("AppearanceViewModel::DeleteCustomTheme", ex);
+ Frontend.ShowMessageBox($"Failed to delete custom theme {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($"Failed to rename custom theme {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 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;
}
}