From 8ee6beb3de41247042f70ac919413f0a11d8a7d1 Mon Sep 17 00:00:00 2001 From: bluepilledgreat <97983689+bluepilledgreat@users.noreply.github.com> Date: Sat, 19 Oct 2024 21:05:37 +0100 Subject: [PATCH] add custom bootstrappers --- Bloxstrap/Bloxstrap.csproj | 2 + Bloxstrap/Enums/BootstrapperStyle.cs | 3 +- Bloxstrap/Extensions/BootstrapperStyleEx.cs | 3 +- Bloxstrap/Models/Persistable/Settings.cs | 1 + Bloxstrap/Paths.cs | 2 + .../Resources/CustomBootstrapperTemplate.xml | 4 + Bloxstrap/Resources/Strings.Designer.cs | 9 + Bloxstrap/Resources/Strings.resx | 3 + .../Bootstrapper/CustomDialog.Creator.cs | 602 ++++++++++++++++++ .../Elements/Bootstrapper/CustomDialog.xaml | 51 ++ .../Bootstrapper/CustomDialog.xaml.cs | 122 ++++ .../Editor/BootstrapperEditorWindow.xaml | 69 ++ .../Editor/BootstrapperEditorWindow.xaml.cs | 31 + .../Settings/Pages/AppearancePage.xaml | 56 +- .../Settings/Pages/AppearancePage.xaml.cs | 13 + Bloxstrap/UI/Frontend.cs | 27 + .../BootstrapperEditorWindowViewModel.cs | 70 ++ .../Settings/AppearanceViewModel.cs | 173 +++++ 18 files changed, 1238 insertions(+), 3 deletions(-) create mode 100644 Bloxstrap/Resources/CustomBootstrapperTemplate.xml create mode 100644 Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs create mode 100644 Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml create mode 100644 Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml.cs create mode 100644 Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml create mode 100644 Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs create mode 100644 Bloxstrap/UI/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs 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; } }