Ionite
1 year ago
25 changed files with 866 additions and 138 deletions
@ -0,0 +1,32 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Diagnostics.CodeAnalysis; |
||||
using Avalonia.Controls; |
||||
using Avalonia.Controls.Templates; |
||||
using Avalonia.Metadata; |
||||
using StabilityMatrix.Core.Models; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Controls; |
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||
public class LaunchOptionCardTemplateSelector : IDataTemplate |
||||
{ |
||||
// public bool SupportsRecycling => false; |
||||
|
||||
// ReSharper disable once CollectionNeverUpdated.Global |
||||
[Content] |
||||
public Dictionary<LaunchOptionType, IDataTemplate> Templates { get; } = new(); |
||||
|
||||
// Check if we can accept the provided data |
||||
public bool Match(object? data) |
||||
{ |
||||
return data is LaunchOptionCard; |
||||
} |
||||
|
||||
// Build the DataTemplate here |
||||
public Control Build(object? data) |
||||
{ |
||||
if (data is not LaunchOptionCard card) throw new ArgumentException(null, nameof(data)); |
||||
return Templates[card.Type].Build(card)!; |
||||
} |
||||
} |
@ -0,0 +1,46 @@
|
||||
using System; |
||||
using System.Globalization; |
||||
using Avalonia.Data.Converters; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Converters; |
||||
|
||||
public class LaunchOptionConverter : IValueConverter |
||||
{ |
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||
{ |
||||
if (targetType == typeof(string)) |
||||
{ |
||||
return value?.ToString() ?? ""; |
||||
} |
||||
|
||||
if (targetType == typeof(bool?)) |
||||
{ |
||||
return bool.TryParse(value?.ToString(), out var boolValue) && boolValue; |
||||
} |
||||
|
||||
if (targetType == typeof(double?)) |
||||
{ |
||||
if (value == null) |
||||
{ |
||||
return null; |
||||
} |
||||
return double.TryParse(value?.ToString(), out var doubleValue) ? doubleValue : 0; |
||||
} |
||||
|
||||
if (targetType == typeof(int?)) |
||||
{ |
||||
if (value == null) |
||||
{ |
||||
return null; |
||||
} |
||||
return int.TryParse(value?.ToString(), out var intValue) ? intValue : 0; |
||||
} |
||||
|
||||
throw new ArgumentException("Unsupported type"); |
||||
} |
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||
{ |
||||
return value; |
||||
} |
||||
} |
@ -0,0 +1,34 @@
|
||||
using System; |
||||
using System.Globalization; |
||||
using Avalonia.Data.Converters; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Converters; |
||||
|
||||
public class LaunchOptionIntDoubleConverter : IValueConverter |
||||
{ |
||||
// Convert from int to double |
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||
{ |
||||
if (targetType == typeof(double?)) |
||||
{ |
||||
if (value == null) |
||||
{ |
||||
return null; |
||||
} |
||||
return System.Convert.ToDouble(value); |
||||
} |
||||
|
||||
throw new ArgumentException($"Unsupported type {targetType}"); |
||||
} |
||||
|
||||
// Convert from double to object int (floor) |
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||
{ |
||||
if (targetType == typeof(int?) || targetType == typeof(object)) |
||||
{ |
||||
return System.Convert.ToInt32(value); |
||||
} |
||||
|
||||
throw new ArgumentException($"Unsupported type {targetType}"); |
||||
} |
||||
} |
@ -0,0 +1,19 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using Avalonia.Data.Converters; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Converters; |
||||
|
||||
public class ValueConverterGroup : List<IValueConverter>, IValueConverter |
||||
{ |
||||
public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) |
||||
{ |
||||
return this.Aggregate(value, (current, converter) => converter.Convert(current, targetType, parameter, culture)); |
||||
} |
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) |
||||
{ |
||||
throw new NotImplementedException(); |
||||
} |
||||
} |
@ -0,0 +1,20 @@
|
||||
using StabilityMatrix.Core.Helper; |
||||
using StabilityMatrix.Core.Models.FileInterfaces; |
||||
using StabilityMatrix.Core.Models.Packages; |
||||
|
||||
namespace StabilityMatrix.Avalonia.DesignData; |
||||
|
||||
public class MockSharedFolders : ISharedFolders |
||||
{ |
||||
public void SetupLinksForPackage(BasePackage basePackage, DirectoryPath installDirectory) |
||||
{ |
||||
} |
||||
|
||||
public void UpdateLinksForPackage(BasePackage basePackage, DirectoryPath installDirectory) |
||||
{ |
||||
} |
||||
|
||||
public void RemoveLinksForAllPackages() |
||||
{ |
||||
} |
||||
} |
@ -0,0 +1,85 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Collections.Immutable; |
||||
using System.Linq; |
||||
using CommunityToolkit.Mvvm.ComponentModel; |
||||
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||
using StabilityMatrix.Core.Attributes; |
||||
using StabilityMatrix.Core.Helper.Cache; |
||||
using StabilityMatrix.Core.Models; |
||||
|
||||
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||
|
||||
[View(typeof(LaunchOptionsDialog))] |
||||
public partial class LaunchOptionsViewModel : ContentDialogViewModelBase |
||||
{ |
||||
private readonly LRUCache<string, ImmutableList<LaunchOptionCard>> cache = new(100); |
||||
|
||||
[ObservableProperty] private string title = "Launch Options"; |
||||
[ObservableProperty] private bool isSearchBoxEnabled = true; |
||||
|
||||
[ObservableProperty] |
||||
[NotifyPropertyChangedFor(nameof(FilteredCards))] |
||||
private string searchText = string.Empty; |
||||
|
||||
[ObservableProperty] |
||||
private IReadOnlyList<LaunchOptionCard>? filteredCards; |
||||
|
||||
public IReadOnlyList<LaunchOptionCard>? Cards { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Return cards that match the search text |
||||
/// </summary> |
||||
private IReadOnlyList<LaunchOptionCard>? GetFilteredCards() |
||||
{ |
||||
var text = SearchText; |
||||
if (string.IsNullOrWhiteSpace(text) || text.Length < 2) |
||||
{ |
||||
return Cards; |
||||
} |
||||
// Try cache |
||||
if (cache.Get(text, out var cachedCards)) |
||||
{ |
||||
return cachedCards!; |
||||
} |
||||
|
||||
var searchCard = new LaunchOptionCard |
||||
{ |
||||
Title = text.ToLowerInvariant(), |
||||
Type = LaunchOptionType.Bool, |
||||
Options = Array.Empty<LaunchOption>() |
||||
}; |
||||
|
||||
var extracted = FuzzySharp.Process |
||||
.ExtractTop(searchCard, Cards, c => c.Title.ToLowerInvariant()); |
||||
var results = extracted |
||||
.Where(r => r.Score > 40) |
||||
.Select(r => r.Value) |
||||
.ToImmutableList(); |
||||
cache.Add(text, results); |
||||
return results; |
||||
} |
||||
|
||||
public void UpdateFilterCards() => FilteredCards = GetFilteredCards(); |
||||
|
||||
public override void OnLoaded() |
||||
{ |
||||
base.OnLoaded(); |
||||
UpdateFilterCards(); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Export the current cards options to a list of strings |
||||
/// </summary> |
||||
public List<LaunchOption> AsLaunchArgs() |
||||
{ |
||||
var launchArgs = new List<LaunchOption>(); |
||||
if (Cards is null) return launchArgs; |
||||
|
||||
foreach (var card in Cards) |
||||
{ |
||||
launchArgs.AddRange(card.Options); |
||||
} |
||||
return launchArgs; |
||||
} |
||||
} |
@ -0,0 +1,172 @@
|
||||
<controls:UserControlBase xmlns="https://github.com/avaloniaui" |
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||
xmlns:dialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs" |
||||
xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core" |
||||
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" |
||||
xmlns:ui="using:FluentAvalonia.UI.Controls" |
||||
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||
xmlns:system="clr-namespace:System;assembly=System.Runtime" |
||||
d:DataContext="{x:Static mocks:DesignData.LaunchOptionsViewModel}" |
||||
x:DataType="dialogs:LaunchOptionsViewModel" |
||||
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="650" |
||||
x:Class="StabilityMatrix.Avalonia.Views.Dialogs.LaunchOptionsDialog"> |
||||
<controls:UserControlBase.Resources> |
||||
|
||||
<converters:LaunchOptionConverter x:Key="LaunchOptionConverter" /> |
||||
<converters:LaunchOptionIntDoubleConverter x:Key="LaunchOptionIntDoubleConverter" /> |
||||
<converters:ValueConverterGroup x:Key="LaunchOptionIntToStringConverter"> |
||||
<converters:LaunchOptionConverter /> |
||||
<converters:LaunchOptionIntDoubleConverter /> |
||||
</converters:ValueConverterGroup> |
||||
|
||||
</controls:UserControlBase.Resources> |
||||
|
||||
<Grid MinWidth="400" RowDefinitions="0.2*,0.8*" Margin="8"> |
||||
<StackPanel |
||||
HorizontalAlignment="Stretch" |
||||
Spacing="4" |
||||
Margin="0,0,0,16" |
||||
Orientation="Vertical"> |
||||
<!-- Title --> |
||||
<TextBlock |
||||
FontSize="24" |
||||
FontWeight="Bold" |
||||
Margin="16" |
||||
Text="{Binding Title}" |
||||
TextWrapping="Wrap" /> |
||||
<!-- Search box --> |
||||
<TextBox |
||||
HorizontalAlignment="Stretch" |
||||
Margin="8,0" |
||||
Watermark="Search..." |
||||
Text="{Binding SearchText, Mode=TwoWay}" |
||||
VerticalAlignment="Top" |
||||
IsVisible="{Binding IsSearchBoxEnabled}" |
||||
x:Name="SearchBox"> |
||||
<TextBox.InnerRightContent> |
||||
<ui:SymbolIcon Symbol="Find" /> |
||||
</TextBox.InnerRightContent> |
||||
</TextBox> |
||||
</StackPanel> |
||||
|
||||
<!-- Option Cards --> |
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"> |
||||
<ItemsControl |
||||
HorizontalAlignment="Stretch" |
||||
Padding="8" |
||||
ItemsSource="{Binding FilteredCards}"> |
||||
|
||||
<ItemsControl.ItemsPanel> |
||||
<ItemsPanelTemplate> |
||||
<VirtualizingStackPanel /> |
||||
</ItemsPanelTemplate> |
||||
</ItemsControl.ItemsPanel> |
||||
|
||||
<ItemsControl.DataTemplates> |
||||
<controls:LaunchOptionCardTemplateSelector> |
||||
<!-- Int type card (textboxes) --> |
||||
<DataTemplate x:DataType="models:LaunchOptionCard" x:Key="{x:Static models:LaunchOptionType.Int}"> |
||||
<controls:Card Margin="0,8"> |
||||
<StackPanel |
||||
HorizontalAlignment="Stretch" |
||||
Margin="8,0,8,0" |
||||
Orientation="Vertical"> |
||||
<TextBlock |
||||
FontSize="16" |
||||
FontWeight="Bold" |
||||
Margin="0,8" |
||||
Text="{Binding Title}" |
||||
TextWrapping="Wrap" /> |
||||
<ItemsControl ItemsSource="{Binding Options}"> |
||||
<ItemsControl.ItemTemplate> |
||||
<DataTemplate> |
||||
<StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> |
||||
<Label Content="{Binding Name}" /> |
||||
<ui:NumberBox |
||||
HorizontalAlignment="Stretch" |
||||
Margin="8" |
||||
PlaceholderText="{Binding DefaultValue, Mode=OneWay, Converter={StaticResource LaunchOptionConverter}}" |
||||
SpinButtonPlacementMode="Compact" |
||||
ValidationMode="Disabled" |
||||
Value="{Binding OptionValue, Converter={StaticResource LaunchOptionIntDoubleConverter}, Mode=TwoWay}" |
||||
VerticalAlignment="Stretch" /> |
||||
</StackPanel> |
||||
</DataTemplate> |
||||
</ItemsControl.ItemTemplate> |
||||
</ItemsControl> |
||||
</StackPanel> |
||||
</controls:Card> |
||||
</DataTemplate> |
||||
|
||||
<!-- String type card (textboxes) --> |
||||
<DataTemplate DataType="{x:Type models:LaunchOptionCard}" x:Key="{x:Static models:LaunchOptionType.String}"> |
||||
<controls:Card Margin="0,8"> |
||||
<StackPanel |
||||
HorizontalAlignment="Stretch" |
||||
Margin="8,0,8,0" |
||||
Orientation="Vertical"> |
||||
<TextBlock |
||||
FontSize="16" |
||||
FontWeight="Bold" |
||||
Margin="0,8" |
||||
Text="{Binding Title}" |
||||
TextWrapping="Wrap" /> |
||||
<ItemsControl ItemsSource="{Binding Options}"> |
||||
<ItemsControl.ItemTemplate> |
||||
<DataTemplate> |
||||
<StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> |
||||
<Label Content="{Binding Name}" /> |
||||
<!--PlaceholderEnabled="{Binding HasDefaultValue}"--> |
||||
<TextBox |
||||
HorizontalAlignment="Stretch" |
||||
Margin="8" |
||||
Watermark="{Binding DefaultValue}" |
||||
Text="{Binding OptionValue, Converter={StaticResource LaunchOptionConverter}}" |
||||
VerticalAlignment="Stretch" /> |
||||
</StackPanel> |
||||
</DataTemplate> |
||||
</ItemsControl.ItemTemplate> |
||||
</ItemsControl> |
||||
</StackPanel> |
||||
</controls:Card> |
||||
</DataTemplate> |
||||
|
||||
<!-- Bool type card (checkboxes) --> |
||||
<DataTemplate DataType="{x:Type models:LaunchOptionCard}" x:Key="{x:Static models:LaunchOptionType.Bool}"> |
||||
<controls:Card Margin="0,8"> |
||||
<StackPanel |
||||
HorizontalAlignment="Left" |
||||
Margin="8,0,8,0" |
||||
Orientation="Vertical"> |
||||
<TextBlock |
||||
FontSize="16" |
||||
FontWeight="Bold" |
||||
Margin="0,8" |
||||
Text="{Binding Title}" |
||||
TextWrapping="Wrap" /> |
||||
<StackPanel Orientation="Horizontal"> |
||||
<ItemsControl ItemsSource="{Binding Options}"> |
||||
<ItemsControl.ItemTemplate> |
||||
<DataTemplate> |
||||
<CheckBox |
||||
Content="{Binding Name}" |
||||
IsChecked="{Binding OptionValue, Converter={StaticResource LaunchOptionConverter}}" /> |
||||
</DataTemplate> |
||||
</ItemsControl.ItemTemplate> |
||||
</ItemsControl> |
||||
</StackPanel> |
||||
</StackPanel> |
||||
</controls:Card> |
||||
</DataTemplate> |
||||
</controls:LaunchOptionCardTemplateSelector> |
||||
</ItemsControl.DataTemplates> |
||||
</ItemsControl> |
||||
</ScrollViewer> |
||||
|
||||
</Grid> |
||||
|
||||
</controls:UserControlBase> |
@ -0,0 +1,18 @@
|
||||
using Avalonia; |
||||
using Avalonia.Controls; |
||||
using Avalonia.Markup.Xaml; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Views.Dialogs; |
||||
|
||||
public partial class LaunchOptionsDialog : UserControl |
||||
{ |
||||
public LaunchOptionsDialog() |
||||
{ |
||||
InitializeComponent(); |
||||
} |
||||
|
||||
private void InitializeComponent() |
||||
{ |
||||
AvaloniaXamlLoader.Load(this); |
||||
} |
||||
} |
@ -1,34 +1,125 @@
|
||||
using System.Collections.ObjectModel; |
||||
using System.Collections.Immutable; |
||||
using System.Diagnostics; |
||||
|
||||
namespace StabilityMatrix.Core.Models; |
||||
|
||||
public class LaunchOptionCard |
||||
public readonly record struct LaunchOptionCard |
||||
{ |
||||
public string Title { get; set; } |
||||
public LaunchOptionType Type { get; set; } |
||||
public string? Description { get; set; } |
||||
public ObservableCollection<LaunchOption> Options { get; set; } = new(); |
||||
|
||||
public LaunchOptionCard(string title, LaunchOptionType type = LaunchOptionType.Bool) |
||||
public required string Title { get; init; } |
||||
public required LaunchOptionType Type { get; init; } |
||||
public required IReadOnlyList<LaunchOption> Options { get; init; } |
||||
public string? Description { get; init; } |
||||
|
||||
public static LaunchOptionCard FromDefinition(LaunchOptionDefinition definition) |
||||
{ |
||||
Title = title; |
||||
Type = type; |
||||
return new LaunchOptionCard |
||||
{ |
||||
Title = definition.Name, |
||||
Description = definition.Description, |
||||
Type = definition.Type, |
||||
|
||||
Options = definition.Options.Select(s => |
||||
{ |
||||
var option = new LaunchOption |
||||
{ |
||||
Name = s, |
||||
Type = definition.Type, |
||||
DefaultValue = definition.DefaultValue |
||||
}; |
||||
return option; |
||||
}).ToImmutableArray() |
||||
}; |
||||
} |
||||
|
||||
public LaunchOptionCard(LaunchOptionDefinition definition) |
||||
/// <summary> |
||||
/// Yield LaunchOptionCards given definitions and launch args to load |
||||
/// </summary> |
||||
/// <param name="definitions"></param> |
||||
/// <param name="launchArgs"></param> |
||||
/// <returns></returns> |
||||
/// <exception cref="InvalidOperationException"></exception> |
||||
public static IEnumerable<LaunchOptionCard> FromDefinitions( |
||||
IEnumerable<LaunchOptionDefinition> definitions, |
||||
IEnumerable<LaunchOption> launchArgs) |
||||
{ |
||||
Title = definition.Name; |
||||
Description = definition.Description; |
||||
Type = definition.Type; |
||||
foreach (var optionName in definition.Options) |
||||
// During card creation, store dict of options with initial values |
||||
var initialOptions = new Dictionary<string, object>(); |
||||
|
||||
// Dict of |
||||
var launchArgsDict = launchArgs.ToDictionary(launchArg => launchArg.Name); |
||||
|
||||
// Create cards |
||||
foreach (var definition in definitions) |
||||
{ |
||||
var option = new LaunchOption |
||||
// Check that non-bool types have exactly one option |
||||
if (definition.Type != LaunchOptionType.Bool && definition.Options.Count != 1) |
||||
{ |
||||
throw new InvalidOperationException( |
||||
$"Definition: '{definition.Name}' has {definition.Options.Count} options," + |
||||
$" it must have exactly 1 option for non-bool types"); |
||||
} |
||||
// Store initial values |
||||
if (definition.InitialValue != null) |
||||
{ |
||||
// For bool types, initial value can be string (single/multiple options) or bool (single option) |
||||
if (definition.Type == LaunchOptionType.Bool) |
||||
{ |
||||
// For single option, check bool |
||||
if (definition.Options.Count == 1 && definition.InitialValue is bool boolValue) |
||||
{ |
||||
initialOptions[definition.Options.First()] = boolValue; |
||||
} |
||||
else |
||||
{ |
||||
// For single/multiple options (string only) |
||||
var option = definition.Options.FirstOrDefault(opt => opt.Equals(definition.InitialValue)); |
||||
if (option == null) |
||||
{ |
||||
throw new InvalidOperationException( |
||||
$"Definition '{definition.Name}' has InitialValue of '{definition.InitialValue}', but it was not found in options:" + |
||||
$" '{string.Join(",", definition.Options)}'"); |
||||
} |
||||
initialOptions[option] = true; |
||||
} |
||||
} |
||||
else |
||||
{ |
||||
// Otherwise store initial value for first option |
||||
initialOptions[definition.Options.First()] = definition.InitialValue; |
||||
} |
||||
} |
||||
// Create the new card |
||||
var card = new LaunchOptionCard |
||||
{ |
||||
Name = optionName, |
||||
Title = definition.Name, |
||||
Description = definition.Description, |
||||
Type = definition.Type, |
||||
DefaultValue = definition.DefaultValue |
||||
Options = definition.Options.Select(s => |
||||
{ |
||||
// Parse defaults and user loaded values here |
||||
var userOption = launchArgsDict.GetValueOrDefault(s); |
||||
var userValue = userOption?.OptionValue; |
||||
// If no user value, check set initial value |
||||
if (userValue is null) |
||||
{ |
||||
var initialValue = initialOptions.GetValueOrDefault(s); |
||||
userValue ??= initialValue; |
||||
Debug.WriteLineIf(initialValue != null, |
||||
$"Using initial value {initialValue} for option {s}"); |
||||
} |
||||
|
||||
var option = new LaunchOption |
||||
{ |
||||
Name = s, |
||||
Type = definition.Type, |
||||
DefaultValue = definition.DefaultValue, |
||||
OptionValue = userValue |
||||
}; |
||||
return option; |
||||
}).ToImmutableArray() |
||||
}; |
||||
Options.Add(option); |
||||
|
||||
yield return card; |
||||
} |
||||
} |
||||
} |
||||
|
Loading…
Reference in new issue