add the import dialog

This commit is contained in:
bluepilledgreat 2025-03-11 11:17:18 +00:00
parent ff8466f0f5
commit 239a0e3e1d
12 changed files with 632 additions and 40 deletions

View File

@ -31,7 +31,8 @@
<ItemGroup>
<EmbeddedResource Include="Resources\CustomBootstrapperSchema.json" />
<EmbeddedResource Include="Resources\CustomBootstrapperTemplate.xml" />
<EmbeddedResource Include="Resources\CustomBootstrapperTemplate_Blank.xml" />
<EmbeddedResource Include="Resources\CustomBootstrapperTemplate_Simple.xml" />
<EmbeddedResource Include="Resources\Icon2008.ico" />
<EmbeddedResource Include="Resources\Icon2011.ico" />
<EmbeddedResource Include="Resources\Icon2017.ico" />

View File

@ -0,0 +1,8 @@
namespace Bloxstrap.Enums
{
public enum CustomThemeTemplate
{
Blank,
Simple
}
}

View File

@ -0,0 +1,10 @@
namespace Bloxstrap.Extensions
{
static class CustomThemeTemplateEx
{
public static string GetFileName(this CustomThemeTemplate template)
{
return $"CustomBootstrapperTemplate_{template}.xml";
}
}
}

View File

@ -0,0 +1,9 @@
<BloxstrapCustomBootstrapper Version="0" Height="320" Width="520" IgnoreTitleBarInset="True" Theme="Default" Margin="30">
<!-- Find more custom bootstrapper examples at https://github.com/bloxstraplabs/custom-bootstrapper-examples -->
<TitleBar Title="" ShowMinimize="False" ShowClose="False" />
<Image Source="{Icon}" Height="100" Width="100" HorizontalAlignment="Center" Margin="0,15,0,0" />
<TextBlock HorizontalAlignment="Center" Name="StatusText" FontSize="20" Margin="0,170,0,0" />
<ProgressBar Width="450" Height="12" Name="PrimaryProgressBar" HorizontalAlignment="Center" Margin="0,200,0,0" />
<Button Content="Cancel" Name="CancelButton" HorizontalAlignment="Center" Margin="0,225,0,0" Height="30" Width="100" />
</BloxstrapCustomBootstrapper>

View File

@ -1170,6 +1170,24 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Blank.
/// </summary>
public static string Enums_CustomThemeTemplate_Blank {
get {
return ResourceManager.GetString("Enums.CustomThemeTemplate.Blank", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Simple.
/// </summary>
public static string Enums_CustomThemeTemplate_Simple {
get {
return ResourceManager.GetString("Enums.CustomThemeTemplate.Simple", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Catmoji.
/// </summary>

View File

@ -1273,4 +1273,10 @@ Please close any applications that may be using Roblox's files, and relaunch.</v
<data name="Menu.About.Licenses.Apache" xml:space="preserve">
<value>Apache License 2.0</value>
</data>
<data name="Enums.CustomThemeTemplate.Blank" xml:space="preserve">
<value>Blank</value>
</data>
<data name="Enums.CustomThemeTemplate.Simple" xml:space="preserve">
<value>Simple</value>
</data>
</root>

View File

@ -0,0 +1,165 @@
<base:WpfUiWindow
x:Class="Bloxstrap.UI.Elements.Dialogs.AddCustomThemeDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:base="clr-namespace:Bloxstrap.UI.Elements.Base"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:Bloxstrap.UI.Elements.Dialogs"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:resources="clr-namespace:Bloxstrap.Resources"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:viewmodels="clr-namespace:Bloxstrap.UI.ViewModels.Dialogs"
Title="Add Custom Theme"
Width="480"
MinHeight="0"
d:DataContext="{d:DesignInstance viewmodels:AddCustomThemeViewModel,
IsDesignTimeCreatable=True}"
Background="{ui:ThemeResource ApplicationBackgroundBrush}"
ExtendsContentIntoTitleBar="True"
ResizeMode="NoResize"
SizeToContent="Height"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Grid>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ui:TitleBar
Title="Add Custom Theme"
Grid.Row="0"
Grid.ColumnSpan="2"
Padding="8"
CanMaximize="False"
KeyboardNavigation.TabNavigation="None"
ShowMaximize="False"
ShowMinimize="False" />
<TabControl
x:Name="Tabs"
Grid.Row="1"
Margin="16"
SelectedIndex="{Binding Path=SelectedTab, Mode=TwoWay}">
<TabItem Header="Create New">
<Grid Grid.Row="1" Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid
Grid.Row="0"
Grid.ColumnSpan="2"
Margin="0,0,0,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Row="0"
Grid.Column="0"
MinWidth="100"
VerticalAlignment="Center"
Text="{x:Static resources:Strings.Common_Name}" />
<TextBox
Grid.Row="0"
Grid.Column="1"
Text="{Binding Path=Name, Mode=TwoWay}" />
<TextBlock
Grid.Row="1"
Grid.Column="1"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Text="{Binding Path=NameError, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap"
Visibility="{Binding Path=NameErrorVisibility, Mode=OneWay}" />
</Grid>
<TextBlock
Grid.Row="1"
Grid.Column="0"
MinWidth="100"
VerticalAlignment="Center"
Text="Template" />
<ComboBox
Grid.Row="1"
Grid.Column="1"
ItemsSource="{Binding Path=Templates, Mode=OneTime}"
Text="{Binding Path=Template, Mode=TwoWay}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Path=., Converter={StaticResource EnumNameConverter}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
</TabItem>
<TabItem Header="Import">
<Grid Margin="11">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
FontSize="14"
Text="{Binding Path=FilePath}"
TextAlignment="Center"
TextWrapping="Wrap"
Visibility="{Binding Path=FilePathVisibility}" />
<ui:Button
Grid.Row="1"
Margin="4"
HorizontalAlignment="Stretch"
Click="OnImportButtonClicked"
Content="{x:Static resources:Strings.Common_ImportFromFile}"
Icon="DocumentArrowUp16" />
<TextBlock
Grid.Row="2"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Text="{Binding Path=FileError}"
TextAlignment="Center"
TextWrapping="Wrap"
Visibility="{Binding Path=FileErrorVisibility}" />
</Grid>
</TabItem>
</TabControl>
<Border
Grid.Row="2"
Margin="0,10,0,0"
Padding="15"
Background="{ui:ThemeResource SolidBackgroundFillColorSecondaryBrush}">
<StackPanel
HorizontalAlignment="Right"
FlowDirection="LeftToRight"
Orientation="Horizontal">
<Button
MinWidth="100"
Click="OnOkButtonClicked"
Content="{x:Static resources:Strings.Common_OK}" />
<Button
MinWidth="100"
Margin="12,0,0,0"
Content="{x:Static resources:Strings.Common_Cancel}"
IsCancel="True" />
</StackPanel>
</Border>
</Grid>
</Grid>
</base:WpfUiWindow>

View File

@ -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
{
/// <summary>
/// Interaction logic for AddCustomThemeDialog.xaml
/// </summary>
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 = "Name cannot be empty";
return false;
}
var validationResult = PathValidator.IsFileNameValid(_viewModel.Name);
if (validationResult != PathValidator.ValidationResult.Ok)
{
switch (validationResult)
{
case PathValidator.ValidationResult.IllegalCharacter:
_viewModel.NameError = "Name contains illegal characters";
break;
case PathValidator.ValidationResult.ReservedFileName:
_viewModel.NameError = "Name cannot be used";
break;
default:
App.Logger.WriteLine(LOG_IDENT, $"Got unhandled PathValidator::ValidationResult {validationResult}");
Debug.Assert(false);
_viewModel.NameError = "Unknown error";
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 = "Name is already in use";
return false;
}
return true;
}
private bool ValidateImport()
{
const string LOG_IDENT = "AddCustomThemeDialog::ValidateImport";
if (!_viewModel.FilePath.EndsWith(".zip"))
{
_viewModel.FileError = "File must be a ZIP";
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 = "Theme file could not be found in the ZIP file";
return false;
}
return true;
}
catch (InvalidDataException ex)
{
App.Logger.WriteLine(LOG_IDENT, "Got invalid data");
App.Logger.WriteException(LOG_IDENT, ex);
_viewModel.FileError = "Invalid or corrupted ZIP file";
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;
}
}
}

View File

@ -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<CustomThemeTemplate>();
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;
}
}

View File

@ -9,6 +9,8 @@ using Microsoft.Win32;
using Bloxstrap.UI.Elements.Settings;
using Bloxstrap.UI.Elements.Editor;
using Bloxstrap.UI.Elements.Dialogs;
using System.Xml.Linq;
namespace Bloxstrap.UI.ViewModels.Settings
{
@ -131,31 +133,6 @@ namespace Bloxstrap.UI.ViewModels.Settings
}
}
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);
@ -171,24 +148,20 @@ namespace Bloxstrap.UI.ViewModels.Settings
private void AddCustomTheme()
{
string name = CreateCustomThemeName();
var dialog = new AddCustomThemeDialog();
dialog.ShowDialog();
try
if (dialog.Created)
{
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(dialog.ThemeName);
SelectedCustomThemeIndex = CustomThemes.Count - 1;
CustomThemes.Add(name);
SelectedCustomThemeIndex = CustomThemes.Count - 1;
OnPropertyChanged(nameof(SelectedCustomThemeIndex));
OnPropertyChanged(nameof(IsCustomThemeSelected));
OnPropertyChanged(nameof(SelectedCustomThemeIndex));
OnPropertyChanged(nameof(IsCustomThemeSelected));
if (dialog.OpenEditor)
EditCustomTheme();
}
}
private void DeleteCustomTheme()

View File

@ -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;
}
}
}