Browse Source

Merge branch 'dev' into merge-main-to-dev-85e4960

pull/324/head
Ionite 12 months ago committed by GitHub
parent
commit
b03235fbad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .github/workflows/build.yml
  2. 55
      .github/workflows/release.yml
  3. 2
      .github/workflows/test-ui.yml
  4. 2
      .github/workflows/version-bump.yml
  5. 35
      CHANGELOG.md
  6. 2
      StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj
  7. 53
      StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs
  8. 7
      StabilityMatrix.Avalonia/App.axaml
  9. 94
      StabilityMatrix.Avalonia/App.axaml.cs
  10. 6
      StabilityMatrix.Avalonia/Assets.cs
  11. BIN
      StabilityMatrix.Avalonia/Assets/brands-civitai.png
  12. BIN
      StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp
  13. 13
      StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs
  14. 13
      StabilityMatrix.Avalonia/Controls/HyperlinkIconButton.cs
  15. 124
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml
  16. 199
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs
  17. 59
      StabilityMatrix.Avalonia/Controls/StarsRating.axaml
  18. 168
      StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs
  19. 30
      StabilityMatrix.Avalonia/Converters/BooleanChoiceMultiConverter.cs
  20. 32
      StabilityMatrix.Avalonia/Converters/EnumStringConverter.cs
  21. 24
      StabilityMatrix.Avalonia/Converters/EnumToBooleanConverter.cs
  22. 50
      StabilityMatrix.Avalonia/Converters/KiloFormatter.cs
  23. 25
      StabilityMatrix.Avalonia/Converters/KiloFormatterStringConverter.cs
  24. 89
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  25. 84
      StabilityMatrix.Avalonia/DialogHelper.cs
  26. 102
      StabilityMatrix.Avalonia/Helpers/UriHandler.cs
  27. 162
      StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
  28. 54
      StabilityMatrix.Avalonia/Languages/Resources.resx
  29. 46
      StabilityMatrix.Avalonia/Models/AppArgs.cs
  30. 10
      StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs
  31. 70
      StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs
  32. 15
      StabilityMatrix.Avalonia/Models/ViewModelState.cs
  33. 203
      StabilityMatrix.Avalonia/Program.cs
  34. 254
      StabilityMatrix.Avalonia/Services/AccountsService.cs
  35. 29
      StabilityMatrix.Avalonia/Services/IAccountsService.cs
  36. 18
      StabilityMatrix.Avalonia/Services/INavigationService.cs
  37. 83
      StabilityMatrix.Avalonia/Services/NavigationService.cs
  38. 57
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  39. 44
      StabilityMatrix.Avalonia/Styles/ControlThemes/HyperlinkIconButtonStyles.axaml
  40. 25
      StabilityMatrix.Avalonia/Styles/ControlThemes/ListBoxStyles.axaml
  41. 2
      StabilityMatrix.Avalonia/Styles/ThemeColors.axaml
  42. 2
      StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs
  43. 77
      StabilityMatrix.Avalonia/ViewModels/Base/TaskDialogViewModelBase.cs
  44. 53
      StabilityMatrix.Avalonia/ViewModels/Base/ViewModelBase.cs
  45. 5
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs
  46. 11
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs
  47. 72
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs
  48. 5
      StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs
  49. 4
      StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs
  50. 141
      StabilityMatrix.Avalonia/ViewModels/Dialogs/LykosLoginViewModel.cs
  51. 84
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OAuthConnectViewModel.cs
  52. 4
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs
  53. 246
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  54. 128
      StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
  55. 4
      StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs
  56. 52
      StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs
  57. 286
      StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs
  58. 17
      StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs
  59. 960
      StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs
  60. 236
      StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs
  61. 910
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  62. 533
      StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml
  63. 23
      StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml.cs
  64. 35
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
  65. 69
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs
  66. 7
      StabilityMatrix.Avalonia/Views/Dialogs/InstallerDialog.axaml
  67. 130
      StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml
  68. 13
      StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml.cs
  69. 40
      StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml
  70. 13
      StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml.cs
  71. 17
      StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs
  72. 317
      StabilityMatrix.Avalonia/Views/OutputsPage.axaml
  73. 8
      StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs
  74. 16
      StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml
  75. 179
      StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml
  76. 13
      StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml.cs
  77. 83
      StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml
  78. 10
      StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs
  79. 572
      StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml
  80. 13
      StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml.cs
  81. 154
      StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml
  82. 41
      StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml.cs
  83. 541
      StabilityMatrix.Avalonia/Views/SettingsPage.axaml
  84. 87
      StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs
  85. 55
      StabilityMatrix.Core/Api/ICivitTRPCApi.cs
  86. 73
      StabilityMatrix.Core/Api/ILykosAuthApi.cs
  87. 7
      StabilityMatrix.Core/Api/ITokenProvider.cs
  88. 52
      StabilityMatrix.Core/Api/LykosAuthTokenProvider.cs
  89. 69
      StabilityMatrix.Core/Api/TokenAuthHeaderHandler.cs
  90. 11
      StabilityMatrix.Core/Attributes/SingletonAttribute.cs
  91. 12
      StabilityMatrix.Core/Exceptions/AppException.cs
  92. 24
      StabilityMatrix.Core/Extensions/SemVersionExtensions.cs
  93. 25
      StabilityMatrix.Core/Extensions/UriExtensions.cs
  94. 5
      StabilityMatrix.Core/Helper/CodeTimer.cs
  95. 14
      StabilityMatrix.Core/Helper/Compat.cs
  96. 60
      StabilityMatrix.Core/Helper/FileTransfers.cs
  97. 25
      StabilityMatrix.Core/Helper/LazyInstance.cs
  98. 26
      StabilityMatrix.Core/Helper/ObjectHash.cs
  99. 15
      StabilityMatrix.Core/Models/Api/CivitAccountStatusUpdateEventArgs.cs
  100. 3
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitApiTokens.cs
  101. Some files were not shown because too many files have changed in this diff Show More

2
.github/workflows/build.yml

@ -21,7 +21,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
dotnet-version: '8.0.x'
- name: Install dependencies
run: dotnet restore

55
.github/workflows/release.yml

@ -82,10 +82,10 @@ jobs:
sudo apt-get -y install libfuse2
dotnet tool install --framework net6.0 -g KuiperZone.PupNet
- name: Set up .NET 7
- name: Set up .NET 8
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
dotnet-version: '8.0.x'
- name: PupNet Build
env:
@ -143,10 +143,10 @@ jobs:
echo "Using version ${{ github.event.inputs.version }}"
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV
- name: Set up .NET 7
- name: Set up .NET 8
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
dotnet-version: '8.0.x'
- name: Install dependencies
run: dotnet restore
@ -197,8 +197,8 @@ jobs:
# Zip each build
- name: Zip Artifacts
run: |
zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64/*
zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64/*
cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD
cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD
- name: Create Github Release
id: create_release
@ -235,10 +235,16 @@ jobs:
python-version: '3.11'
- name: Install Python Dependencies
run: pip install stability-matrix-tools~=0.2.7
run: pip install stability-matrix-tools>=0.2.10 --upgrade
- name: Publish Auto-Update Release
run: sm-tools updates publish-matrix -v $RELEASE_VERSION -y
env:
SM_B2_API_ID: ${{ secrets.SM_B2_API_ID }}
SM_B2_API_KEY: ${{ secrets.SM_B2_API_KEY }}
SM_CF_CACHE_PURGE_TOKEN: ${{ secrets.SM_CF_CACHE_PURGE_TOKEN }}
SM_CF_ZONE_ID: ${{ secrets.SM_CF_ZONE_ID }}
SM_SIGNING_PRIVATE_KEY: ${{ secrets.SM_SIGNING_PRIVATE_KEY }}
run: sm-tools updates publish-matrix -v ${{ github.event.inputs.version }} -y
publish-auto-update-b2:
name: Publish Auto-Update Release (B2)
@ -253,23 +259,22 @@ jobs:
echo "Using version ${{ github.event.inputs.version }}"
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV
# Downloads all previous artifacts to the current working directory
- name: Download Artifacts
uses: actions/download-artifact@v3
# Zip each build
- name: Zip Artifacts
run: |
cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD
cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Python Dependencies
run: pip install stability-matrix-tools~=0.2.7
- name: Download Changelog
run: >
sm-tools updates download-changelog -v $RELEASE_VERSION -y
--changelog
# Zip each build
- name: Zip Artifacts
run: |
zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64/*
zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64/*
run: pip install stability-matrix-tools>=0.2.10 --upgrade
# Check that the zips and CHANGELOG.md are in the current working directory
- name: Check files
@ -288,10 +293,16 @@ jobs:
fi
- name: Publish Auto-Update Release
env:
SM_B2_API_ID: ${{ secrets.SM_B2_API_ID }}
SM_B2_API_KEY: ${{ secrets.SM_B2_API_KEY }}
SM_CF_CACHE_PURGE_TOKEN: ${{ secrets.SM_CF_CACHE_PURGE_TOKEN }}
SM_CF_ZONE_ID: ${{ secrets.SM_CF_ZONE_ID }}
SM_SIGNING_PRIVATE_KEY: ${{ secrets.SM_SIGNING_PRIVATE_KEY }}
run: >
sm-tools updates publish-files-v3 -v $RELEASE_VERSION -y
sm-tools updates publish-files-v3 -v ${{ github.event.inputs.version }}
--channel ${{ github.event.inputs.auto-update-release-channel }}
--changelog CHANGELOG.md
--win-x64 StabilityMatrix-win-x64.zip
--linux-x64 StabilityMatrix-linux-x64.zip
--b2-bucket-name ${{ secrets.B2_BUCKET_NAME }}
-y

2
.github/workflows/test-ui.yml

@ -18,7 +18,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
dotnet-version: '8.0.x'
- name: Install dependencies
run: dotnet restore

2
.github/workflows/version-bump.yml

@ -25,7 +25,7 @@ jobs:
- name: Setup .NET Core
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
dotnet-version: '8.0.x'
- name: Bump versions
uses: SiqiLu/dotnet-bump-version@2.0.0

35
CHANGELOG.md

@ -5,9 +5,44 @@ All notable changes to Stability Matrix will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html).
## v2.7.0-dev.2
### Added
#### General
- Added an X button to all search fields to instantly clear them (Esc key also works)
#### Outputs Page
- Added Refresh button to update gallery from file system changes
#### Checkpoints Page
- Added the ability to drag & drop checkpoints between different folders
## Changed
#### Outputs Page
- Updated button and menu layout
#### Packages Page
- Rearranged Add Package dialog slightly to accommodate longer package list
### Fixed
- Fixed InvalidOperation errors when signing into accounts shortly after signing out, while the previous account update is still running
- Fixed Outputs page reverting back to Shared Output Folder every time the page is reloaded
- Potentially fixed updates sometimes clearing settings or launching in the wrong directory
## v2.7.0-dev.1
### Added
- Accounts Settings Subpage
- Lykos Account sign-up and login - currently for Patreon OAuth connections but GitHub requests caching and settings sync are planned
- Supporters can now connect your Patreon accounts, then head to the Updates page to choose to receive auto-updates from the Dev or Preview channels
- CivitAI Account login with API key - enables downloading models from the Browser page that require CivitAI logins, more integrations like liking and commenting are also planned
- Updates Settings Subpage
- Toggle auto-update notifications and manually check for updates
- Choose between Stable, Preview, and Dev update channels
## Changed
- Model Browser page has been redesigned, featuring more information like rating and download counts
## v2.6.3
### Fixed
- Fixed InvalidOperationException during prerequisite installs on certain platforms where process name and duration reporting are not supported
## v2.7.0-dev.3
## Changed
#### Model Browser
- Improved number formatting with K/M suffixes for download and favorite counts
- Animated zoom effect on hovering over model images
## v2.6.2
### Changed

2
StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

53
StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs

@ -1,22 +1,25 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Media;
using Avalonia.Styling;
using FluentAvalonia.UI.Media.Animation;
using Projektanker.Icons.Avalonia;
namespace StabilityMatrix.Avalonia.Animations;
public class BetterSlideNavigationTransition : BaseTransitionInfo
{
public override TimeSpan Duration { get; set; } = TimeSpan.FromMilliseconds(167);
/// <summary>
/// Gets or sets the type of animation effect to play during the slide transition.
/// </summary>
public SlideNavigationTransitionEffect Effect { get; set; } = SlideNavigationTransitionEffect.FromRight;
public SlideNavigationTransitionEffect Effect { get; set; } =
SlideNavigationTransitionEffect.FromRight;
/// <summary>
/// Gets or sets the HorizontalOffset used when animating from the Left or Right
@ -27,7 +30,12 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
/// Gets or sets the VerticalOffset used when animating from the Top or Bottom
/// </summary>
public double FromVerticalOffset { get; set; } = 56;
/// <summary>
/// Gets or sets the easing function applied to the slide transition.
/// </summary>
public Easing Easing { get; set; } = new SplineEasing(0.1, 0.9, 0.2, 1.0);
public override async void RunAnimation(Animatable ctrl, CancellationToken cancellationToken)
{
double length = 0;
@ -52,24 +60,26 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
var animation = new Animation
{
Easing = new SplineEasing(0.1, 0.9, 0.2, 1.0),
Easing = Easing,
Children =
{
new KeyFrame
{
Setters =
{
new Setter(isVertical ? TranslateTransform.YProperty : TranslateTransform.XProperty, length),
new Setter(
isVertical
? TranslateTransform.YProperty
: TranslateTransform.XProperty,
length
),
new Setter(Visual.OpacityProperty, 0d)
},
Cue = new Cue(0d)
},
new KeyFrame
{
Setters=
{
new Setter(Visual.OpacityProperty, 1d)
},
Setters = { new Setter(Visual.OpacityProperty, 1d) },
Cue = new Cue(0.05d)
},
new KeyFrame
@ -77,7 +87,12 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
Setters =
{
new Setter(Visual.OpacityProperty, 1d),
new Setter(isVertical ? TranslateTransform.YProperty : TranslateTransform.XProperty, 0.0)
new Setter(
isVertical
? TranslateTransform.YProperty
: TranslateTransform.XProperty,
0.0
)
},
Cue = new Cue(1d)
}
@ -93,4 +108,22 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
visual.Opacity = 1;
}
}
public static BetterSlideNavigationTransition PageSlideFromLeft =>
new()
{
Duration = TimeSpan.FromMilliseconds(300),
Effect = SlideNavigationTransitionEffect.FromLeft,
FromHorizontalOffset = 150,
Easing = new SplineEasing(0.6, 0.4, 0.1, 0.1)
};
public static BetterSlideNavigationTransition PageSlideFromRight =>
new()
{
Duration = TimeSpan.FromMilliseconds(300),
Effect = SlideNavigationTransitionEffect.FromRight,
FromHorizontalOffset = 150,
Easing = new SplineEasing(0.6, 0.4, 0.1, 0.1)
};
}

7
StabilityMatrix.Avalonia/App.axaml

@ -22,11 +22,15 @@
<ResourceInclude Source="Controls/EditorFlyouts.axaml"/>
<ResourceInclude Source="Controls/Scroll/BetterScrollViewer.axaml"/>
<ResourceInclude Source="Controls/ImageFolderCard.axaml"/>
<ResourceInclude Source="Styles/ControlThemes/HyperlinkIconButtonStyles.axaml"/>
<ResourceInclude Source="Styles/ControlThemes/ListBoxStyles.axaml"/>
</ResourceDictionary.MergedDictionaries>
<idcr:ControlRecycling x:Key="ControlRecyclingKey" />
<x:Double x:Key="ContentDialogMaxWidth">700</x:Double>
<x:Double x:Key="BreadcrumbBarItemThemeFontSize">32</x:Double>
<SolidColorBrush x:Key="ToolTipBackground" Color="#1E1F22"/>
<SolidColorBrush x:Key="ToolTipForeground" Color="#9FBDC3"/>
<FontFamily x:Key="NotoSansJP">avares://StabilityMatrix.Avalonia/Assets/Fonts/NotoSansJP#Noto Sans JP</FontFamily>
@ -39,6 +43,7 @@
<StyleInclude Source="avares://Dock.Avalonia/Themes/DockFluentTheme.axaml" />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml"/>
<StyleInclude Source="avares://AsyncImageLoader.Avalonia/AdvancedImage.axaml" />
<StyleInclude Source="avares://FluentAvalonia.BreadcrumbBar/Styling/Styles.axaml" />
<StyleInclude Source="Styles/ProgressRing.axaml"/>
<StyleInclude Source="Styles/ButtonStyles.axaml"/>
<StyleInclude Source="Styles/SplitButtonStyles.axaml"/>
@ -62,6 +67,8 @@
<StyleInclude Source="Controls/FreeUCard.axaml"/>
<StyleInclude Source="Controls/Paginator.axaml"/>
<StyleInclude Source="Controls/SelectableImageCard/SelectableImageButton.axaml"/>
<StyleInclude Source="Controls/SettingsAccountLinkExpander.axaml"/>
<StyleInclude Source="Controls/StarsRating.axaml"/>
<Style Selector="DockControl">
<Setter Property="(DockProperties.ControlRecycling)" Value="{StaticResource ControlRecyclingKey}" />

94
StabilityMatrix.Avalonia/App.axaml.cs

@ -17,6 +17,7 @@ using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
@ -25,6 +26,7 @@ using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using MessagePipe;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@ -53,6 +55,7 @@ using StabilityMatrix.Core.Converters.Json;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Configs;
using StabilityMatrix.Core.Models.FileInterfaces;
@ -110,6 +113,16 @@ public sealed class App : Application
public override void OnFrameworkInitializationCompleted()
{
// Remove DataAnnotations validation plugin since we're using INotifyDataErrorInfo from MvvmToolkit
var dataValidationPluginsToRemove = BindingPlugins.DataValidators
.OfType<DataAnnotationsValidationPlugin>()
.ToArray();
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
base.OnFrameworkInitializationCompleted();
if (Design.IsDesignMode)
@ -126,6 +139,8 @@ public sealed class App : Application
{
DesktopLifetime.ShutdownMode = ShutdownMode.OnExplicitShutdown;
Setup();
// First time setup if needed
var settingsManager = Services.GetRequiredService<ISettingsManager>();
if (!settingsManager.IsEulaAccepted())
@ -164,6 +179,17 @@ public sealed class App : Application
}
}
/// <summary>
/// Setup tasks to be run shortly before any window is shown
/// </summary>
private void Setup()
{
using var _ = new CodeTimer();
// Setup uri handler for `stabilitymatrix://` protocol
Program.UriHandler.RegisterUriScheme();
}
private void ShowMainWindow()
{
if (DesktopLifetime is null)
@ -232,6 +258,8 @@ public sealed class App : Application
var settingsManager = Services.GetRequiredService<ISettingsManager>();
settingsManager.LibraryDirOverride = Program.Args.DataDirectoryOverride;
if (settingsManager.TryFindLibrary())
{
Cultures.SetSupportedCultureOrDefault(settingsManager.Settings.Language);
@ -313,6 +341,10 @@ public sealed class App : Application
{
var services = new ServiceCollection();
services.AddMemoryCache();
services.AddLazyInstance();
services.AddMessagePipe();
services.AddMessagePipeNamedPipeInterprocess("StabilityMatrix");
var exportedTypes = AppDomain.CurrentDomain
.GetAssemblies()
@ -352,17 +384,31 @@ public sealed class App : Application
t1.attributes is { Length: > 0 }
&& !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)
)
.Select(t1 => new { Type = t1.t, Attribute = (SingletonAttribute)t1.attributes[0] });
.Select(
t1 =>
new
{
Type = t1.t,
Attributes = t1.attributes.Cast<SingletonAttribute>().ToArray()
}
);
foreach (var typePair in singletonTypes)
{
if (typePair.Attribute.InterfaceType is null)
{
services.AddSingleton(typePair.Type);
}
else
foreach (var attribute in typePair.Attributes)
{
services.AddSingleton(typePair.Attribute.InterfaceType, typePair.Type);
if (attribute.InterfaceType is null)
{
services.AddSingleton(typePair.Type);
}
else if (attribute.ImplType is not null)
{
services.AddSingleton(attribute.InterfaceType, attribute.ImplType);
}
else
{
services.AddSingleton(attribute.InterfaceType, typePair.Type);
}
}
}
@ -493,11 +539,45 @@ public sealed class App : Application
})
.AddPolicyHandler(retryPolicy);
services
.AddRefitClient<ICivitTRPCApi>(defaultRefitSettings)
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("https://civitai.com");
c.Timeout = TimeSpan.FromSeconds(15);
})
.AddPolicyHandler(retryPolicy);
services
.AddRefitClient<ILykosAuthApi>(defaultRefitSettings)
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("https://stableauthentication.azurewebsites.net");
c.Timeout = TimeSpan.FromSeconds(15);
})
.ConfigurePrimaryHttpMessageHandler(
() => new HttpClientHandler { AllowAutoRedirect = false }
)
.AddPolicyHandler(retryPolicy)
.AddHttpMessageHandler(
serviceProvider =>
new TokenAuthHeaderHandler(
serviceProvider.GetRequiredService<LykosAuthTokenProvider>()
)
);
// Add Refit client managers
services
.AddHttpClient("A3Client")
.AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy));
services
.AddHttpClient("DontFollowRedirects")
.ConfigurePrimaryHttpMessageHandler(
() => new HttpClientHandler { AllowAutoRedirect = false }
)
.AddPolicyHandler(retryPolicy);
/*services.AddHttpClient("IComfyApi")
.AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy));*/

6
StabilityMatrix.Avalonia/Assets.cs

@ -151,8 +151,14 @@ internal static class Assets
public static Uri DiscordServerUrl { get; } = new("https://discord.com/invite/TUrgfECxHz");
public static Uri LykosUrl { get; } = new("https://lykos.ai");
public static Uri PatreonUrl { get; } = new("https://patreon.com/StabilityMatrix");
public static Uri CivitAIUrl { get; } = new("https://civitai.com");
public static Uri LykosForgotPasswordUrl { get; } = new("https://lykos.ai/forgot-password");
/// <summary>
/// Yield AvaloniaResources given a relative directory path within the 'Assets' folder.
/// </summary>

BIN
StabilityMatrix.Avalonia/Assets/brands-civitai.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

13
StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs

@ -205,6 +205,12 @@ public class BetterContentDialog : ContentDialog
viewModel.SecondaryButtonClick += OnDialogButtonClick;
viewModel.CloseButtonClick += OnDialogButtonClick;
}
else if (Content is ContentDialogViewModelBase viewModelDirect)
{
viewModelDirect.PrimaryButtonClick += OnDialogButtonClick;
viewModelDirect.SecondaryButtonClick += OnDialogButtonClick;
viewModelDirect.CloseButtonClick += OnDialogButtonClick;
}
else if (
(Content as Control)?.DataContext
is ContentDialogProgressViewModelBase progressViewModel
@ -263,8 +269,11 @@ public class BetterContentDialog : ContentDialog
protected void OnDialogButtonClick(object? sender, ContentDialogResult e)
{
Result = e;
HideCore();
Dispatcher.UIThread.Post(() =>
{
Result = e;
HideCore();
});
}
protected override void OnDataContextChanged(EventArgs e)

13
StabilityMatrix.Avalonia/Controls/HyperlinkIconButton.cs

@ -0,0 +1,13 @@
using System;
using FluentAvalonia.UI.Controls;
namespace StabilityMatrix.Avalonia.Controls;
/// <summary>
/// Like <see cref="HyperlinkButton"/>, but with a link icon left of the text content.
/// </summary>
public class HyperlinkIconButton : HyperlinkButton
{
/// <inheritdoc />
protected override Type StyleKeyOverride => typeof(HyperlinkIconButton);
}

124
StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml

@ -0,0 +1,124 @@
<Styles
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:StabilityMatrix.Avalonia.Controls"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
<Design.PreviewWith>
<StackPanel Width="600" Height="400">
<controls:SettingsAccountLinkExpander
Header="Service 1"
IconSource="OtherUser"
OffDescription="Manage account services like A, B, and C" />
<controls:SettingsAccountLinkExpander
Header="Service 1 (Loading)"
IconSource="OtherUser"
IsLoading="True"
OffDescription="Manage account services like A, B, and C" />
<controls:SettingsAccountLinkExpander
Header="Service 2"
IconSource="CloudFilled"
IsConnected="True"
OffDescription="Manage account services like A, B, and C" />
<controls:SettingsAccountLinkExpander
Header="Service 3"
IconSource="CloudFilled"
IsConnected="True"
OnDescriptionExtra="(account)"
OffDescription="Manage account services like A, B, and C" />
</StackPanel>
</Design.PreviewWith>
<Style Selector="controls|SettingsAccountLinkExpander">
<!-- Set Defaults -->
<Setter Property="Template">
<ControlTemplate>
<ui:SettingsExpander x:Name="PART_SettingsExpander" IconSource="{TemplateBinding IconSource}">
<ui:SettingsExpander.Header>
<StackPanel>
<TextBlock x:Name="PART_HeaderTextBlock" Text="{TemplateBinding Header}" />
<TextBlock
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
IsVisible="{TemplateBinding IsConnected,
Mode=OneWay,
Converter={x:Static BoolConverters.Not}}"
Text="{TemplateBinding OffDescription}"
TextWrapping="Wrap"
Theme="{DynamicResource CaptionTextBlockStyle}" />
<StackPanel
x:Name="PART_OnDescriptionPanel"
IsVisible="{TemplateBinding IsConnected,
Mode=OneWay}"
Orientation="Horizontal"
Spacing="4">
<Ellipse
Width="5"
Height="5"
Fill="{StaticResource ThemeMediumSeaGreenColor}" />
<TextBlock
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{TemplateBinding OnDescription}"
TextWrapping="Wrap"
Theme="{DynamicResource CaptionTextBlockStyle}" />
<TextBlock
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{TemplateBinding OnDescriptionExtra}"
TextWrapping="Wrap"
Theme="{DynamicResource CaptionTextBlockStyle}" />
</StackPanel>
</StackPanel>
</ui:SettingsExpander.Header>
<ui:SettingsExpander.Footer>
<StackPanel Margin="0,0,12,0" Orientation="Horizontal">
<!-- for some reason direct bind to IsConnected doesn't work here -->
<controls:ProgressRing
Margin="0,0,24,0"
BorderThickness="3"
IsIndeterminate="{Binding $parent[controls:SettingsAccountLinkExpander].IsLoading}"
IsVisible="{Binding $parent[controls:SettingsAccountLinkExpander].IsLoading}" />
<!-- Connect button -->
<Button
x:Name="PART_ConnectButton"
Padding="32,6"
Command="{TemplateBinding ConnectCommand}"
Content="{x:Static lang:Resources.Action_Connect}"
IsVisible="{Binding !IsVisible, ElementName=PART_OnDescriptionPanel}" />
<!-- Disconnect button -->
<Button
x:Name="PART_DisconnectButton"
Padding="6,8"
HorizontalAlignment="Right"
BorderThickness="0"
Classes="transparent"
IsVisible="{Binding IsVisible, ElementName=PART_OnDescriptionPanel}">
<ui:SymbolIcon FontSize="20" Symbol="More" />
<Button.Flyout>
<ui:FAMenuFlyout Placement="BottomEdgeAlignedLeft">
<ui:MenuFlyoutItem
x:Name="PART_DisconnectMenuItem"
Command="{TemplateBinding DisconnectCommand}"
Text="{x:Static lang:Resources.Action_Disconnect}" />
</ui:FAMenuFlyout>
</Button.Flyout>
</Button>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</ControlTemplate>
</Setter>
</Style>
</Styles>

199
StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs

@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Metadata;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Core.Processes;
namespace StabilityMatrix.Avalonia.Controls;
public class SettingsAccountLinkExpander : TemplatedControl
{
private readonly List<object?> _items = new();
[Content]
public List<object?> Items => _items;
// ReSharper disable MemberCanBePrivate.Global
public static readonly StyledProperty<object?> HeaderProperty =
HeaderedItemsControl.HeaderProperty.AddOwner<SettingsAccountLinkExpander>();
public object? Header
{
get => GetValue(HeaderProperty);
set => SetValue(HeaderProperty, value);
}
public static readonly StyledProperty<Uri?> HeaderTargetUriProperty = AvaloniaProperty.Register<
SettingsAccountLinkExpander,
Uri?
>("HeaderTargetUri");
public Uri? HeaderTargetUri
{
get => GetValue(HeaderTargetUriProperty);
set => SetValue(HeaderTargetUriProperty, value);
}
public static readonly StyledProperty<IconSource?> IconSourceProperty =
SettingsExpander.IconSourceProperty.AddOwner<SettingsAccountLinkExpander>();
public IconSource? IconSource
{
get => GetValue(IconSourceProperty);
set => SetValue(IconSourceProperty, value);
}
public static readonly StyledProperty<bool> IsConnectedProperty = AvaloniaProperty.Register<
SettingsAccountLinkExpander,
bool
>("IsConnected");
public bool IsConnected
{
get => GetValue(IsConnectedProperty);
set => SetValue(IsConnectedProperty, value);
}
public static readonly StyledProperty<object?> OnDescriptionProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, object?>(
"OnDescription",
Languages.Resources.Label_Connected
);
public object? OnDescription
{
get => GetValue(OnDescriptionProperty);
set => SetValue(OnDescriptionProperty, value);
}
public static readonly StyledProperty<object?> OnDescriptionExtraProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, object?>("OnDescriptionExtra");
public object? OnDescriptionExtra
{
get => GetValue(OnDescriptionExtraProperty);
set => SetValue(OnDescriptionExtraProperty, value);
}
public static readonly StyledProperty<object?> OffDescriptionProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, object?>("OffDescription");
public object? OffDescription
{
get => GetValue(OffDescriptionProperty);
set => SetValue(OffDescriptionProperty, value);
}
public static readonly StyledProperty<ICommand?> ConnectCommandProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, ICommand?>(
nameof(ConnectCommand),
enableDataValidation: true
);
public ICommand? ConnectCommand
{
get => GetValue(ConnectCommandProperty);
set => SetValue(ConnectCommandProperty, value);
}
public static readonly StyledProperty<ICommand?> DisconnectCommandProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, ICommand?>(
nameof(DisconnectCommand),
enableDataValidation: true
);
public ICommand? DisconnectCommand
{
get => GetValue(DisconnectCommandProperty);
set => SetValue(DisconnectCommandProperty, value);
}
/*public static readonly StyledProperty<bool> IsLoading2Property = AvaloniaProperty.Register<SettingsAccountLinkExpander, bool>(
nameof(IsLoading2));
public bool IsLoading2
{
get => GetValue(IsLoading2Property);
set => SetValue(IsLoading2Property, value);
}*/
private bool _isLoading;
public static readonly DirectProperty<SettingsAccountLinkExpander, bool> IsLoadingProperty =
AvaloniaProperty.RegisterDirect<SettingsAccountLinkExpander, bool>(
"IsLoading",
o => o.IsLoading,
(o, v) => o.IsLoading = v
);
public bool IsLoading
{
get => _isLoading;
set => SetAndRaise(IsLoadingProperty, ref _isLoading, value);
}
// ReSharper restore MemberCanBePrivate.Global
/// <inheritdoc />
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
// Bind tapped event on header
if (
HeaderTargetUri is { } headerTargetUri
&& e.NameScope.Find<TextBlock>("PART_HeaderTextBlock") is { } headerTextBlock
)
{
headerTextBlock.Tapped += (_, _) =>
{
ProcessRunner.OpenUrl(headerTargetUri.ToString());
};
}
if (e.NameScope.Find<SettingsExpander>("PART_SettingsExpander") is { } expander)
{
expander.ItemsSource = Items;
}
if (ConnectCommand is { } command)
{
var connectButton = e.NameScope.Get<Button>("PART_ConnectButton");
connectButton.Command = command;
}
if (DisconnectCommand is { } disconnectCommand)
{
var disconnectMenuItem = e.NameScope.Get<MenuFlyoutItem>("PART_DisconnectMenuItem");
disconnectMenuItem.Command = disconnectCommand;
}
}
/// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (!this.IsAttachedToVisualTree())
{
return;
}
if (change.Property == ConnectCommandProperty)
{
var button = this.GetControl<Button>("PART_ConnectButton");
button.Command = ConnectCommand;
}
if (change.Property == DisconnectCommandProperty)
{
var button = this.GetControl<Button>("PART_DisconnectButton");
button.Command = DisconnectCommand;
}
}
}

59
StabilityMatrix.Avalonia/Controls/StarsRating.axaml

@ -0,0 +1,59 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:StabilityMatrix.Avalonia.Controls"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:icons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia">
<Design.PreviewWith>
<StackPanel Width="400" Height="400" Spacing="4">
<StackPanel.Styles>
<Style Selector="controls|Card">
<Setter Property="Padding" Value="6,4"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
</StackPanel.Styles>
<controls:Card>
<controls:StarsRating Value="1" />
</controls:Card>
<controls:Card Classes="transparent">
<controls:StarsRating Value="2" />
</controls:Card>
<controls:Card Classes="transparent">
<controls:StarsRating Value="2.5" />
</controls:Card>
</StackPanel>
</Design.PreviewWith>
<!--<Styles.Resources>
<icons:SymbolIcon
x:Key="StarFilledIcon"
FontSize="15"
Margin="8,0"
VerticalAlignment="Center"
Symbol="Star"
IsFilled="True"/>
</Styles.Resources>-->
<Style Selector="controls|StarsRating">
<!-- Set Defaults -->
<Setter Property="Template">
<ControlTemplate>
<ItemsControl
x:Name="PART_StarsItemsControl">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="2" Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<!--<sg:SpacedGrid
x:Name="PART_Grid">
~1~ Stars filled dynamically @1@
</sg:SpacedGrid>-->
</ControlTemplate>
</Setter>
</Style>
</Styles>

168
StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs

@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.Markup.Xaml.MarkupExtensions;
using Avalonia.Media;
using Avalonia.VisualTree;
using FluentIcons.Common;
using FluentIcons.FluentAvalonia;
using SpacedGridControl.Avalonia;
using StabilityMatrix.Avalonia.Styles;
namespace StabilityMatrix.Avalonia.Controls;
public class StarsRating : TemplatedControl
{
private SymbolIcon? StarFilledIcon => Resources["StarFilledIcon"] as SymbolIcon;
private ItemsControl? itemsControl;
private IEnumerable<SymbolIcon> StarItems => itemsControl!.ItemsSource!.Cast<SymbolIcon>();
public static readonly StyledProperty<bool> IsEditableProperty = AvaloniaProperty.Register<
StarsRating,
bool
>("IsEditable");
public bool IsEditable
{
get => GetValue(IsEditableProperty);
set => SetValue(IsEditableProperty, value);
}
public static readonly StyledProperty<int> MaximumProperty = AvaloniaProperty.Register<
StarsRating,
int
>(nameof(Maximum), 5);
public int Maximum
{
get => GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}
public static readonly StyledProperty<double> ValueProperty = AvaloniaProperty.Register<
StarsRating,
double
>(nameof(Value));
public double Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
/// <inheritdoc />
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
itemsControl = e.NameScope.Find<ItemsControl>("PART_StarsItemsControl")!;
CreateStars();
}
/// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (!this.IsAttachedToVisualTree())
{
return;
}
if (change.Property == ValueProperty || change.Property == MaximumProperty)
{
SyncStarState();
}
}
private void CreateStars()
{
if (itemsControl is null)
{
return;
}
// Fill stars
var stars = new List<Control>();
for (var i = 0; i < Maximum; i++)
{
var star = new SymbolIcon
{
FontSize = FontSize,
Margin = new Thickness(0, 0),
Symbol = Symbol.Star,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Tag = i
};
stars.Add(star);
OnStarAdded(star);
}
itemsControl.ItemsSource = stars;
SyncStarState();
}
private void OnStarAdded(SymbolIcon item)
{
if (IsEditable)
{
item.Tapped += (sender, args) =>
{
var star = (SymbolIcon)sender!;
Value = (int)star.Tag! + 1;
};
}
}
/// <summary>
/// Round a number to the nearest 0.5
/// </summary>
private static double RoundToHalf(double value)
{
return Math.Round(value * 2, MidpointRounding.AwayFromZero) / 2;
}
private void SyncStarState()
{
// Set star to filled when Value is greater than or equal to the star index
foreach (var star in StarItems)
{
// Add 1 to tag since its index is 0-based
var tag = (int)star.Tag! + 1;
// Fill if current is equal or lower than floor of Value
if (tag <= Math.Floor(RoundToHalf(Value)))
{
star.Symbol = Symbol.Star;
star.IsFilled = true;
star.Foreground = Foreground;
}
// If current is between floor and ceil of value, use half-star
else if (tag <= Math.Ceiling(RoundToHalf(Value)))
{
star.Symbol = Symbol.StarHalf;
star.IsFilled = true;
star.Foreground = Foreground;
}
// Otherwise no fill and gray disabled color
else
{
star.Symbol = Symbol.Star;
star.IsFilled = false;
star.Foreground = new SolidColorBrush(Colors.DarkSlateGray);
}
}
}
}

30
StabilityMatrix.Avalonia/Converters/BooleanChoiceMultiConverter.cs

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
public class BooleanChoiceMultiConverter : IMultiValueConverter
{
/// <inheritdoc />
public object? Convert(
IList<object?> values,
Type targetType,
object? parameter,
CultureInfo culture
)
{
if (values.Count < 3)
{
return null;
}
if (values[0] is bool boolValue)
{
return boolValue ? values[1] : values[2];
}
return null;
}
}

32
StabilityMatrix.Avalonia/Converters/EnumStringConverter.cs

@ -0,0 +1,32 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using StabilityMatrix.Core.Extensions;
namespace StabilityMatrix.Avalonia.Converters;
public class EnumStringConverter : IValueConverter
{
/// <inheritdoc />
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not Enum enumValue)
return null;
return enumValue.GetStringValue();
}
/// <inheritdoc />
public object? ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
)
{
if (value is not string stringValue)
return null;
return Enum.Parse(targetType, stringValue);
}
}

24
StabilityMatrix.Avalonia/Converters/EnumToBooleanConverter.cs

@ -0,0 +1,24 @@
using System;
using System.Globalization;
using Avalonia.Data;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
public class EnumToBooleanConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value?.Equals(parameter);
}
public object? ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
)
{
return value?.Equals(true) == true ? parameter : BindingOperations.DoNothing;
}
}

50
StabilityMatrix.Avalonia/Converters/KiloFormatter.cs

@ -0,0 +1,50 @@
using System;
namespace StabilityMatrix.Avalonia.Converters;
public class KiloFormatter : ICustomFormatter, IFormatProvider
{
public object? GetFormat(Type? formatType)
{
return formatType == typeof(ICustomFormatter) ? this : null;
}
public string Format(string? format, object? arg, IFormatProvider? formatProvider)
{
if (format == null || !format.Trim().StartsWith('K'))
{
if (arg is IFormattable formatArg)
{
return formatArg.ToString(format, formatProvider);
}
return arg?.ToString() ?? string.Empty;
}
var value = Convert.ToInt64(arg);
return FormatNumber(value);
}
private static string FormatNumber(long num)
{
if (num >= 100000000)
{
return (num / 1000000D).ToString("0.#M");
}
if (num >= 1000000)
{
return (num / 1000000D).ToString("0.##M");
}
if (num >= 100000)
{
return (num / 1000D).ToString("0.#K");
}
if (num >= 10000)
{
return (num / 1000D).ToString("0.##K");
}
return num.ToString("#,0");
}
}

25
StabilityMatrix.Avalonia/Converters/KiloFormatterStringConverter.cs

@ -0,0 +1,25 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
public class KiloFormatterStringConverter : IValueConverter
{
/// <inheritdoc />
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is null ? null : string.Format(new KiloFormatter(), "{0:K}", value);
}
/// <inheritdoc />
public object? ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
)
{
return value is null ? null : throw new NotImplementedException();
}
}

89
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -11,6 +11,7 @@ using DynamicData.Binding;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Semver;
using StabilityMatrix.Avalonia.Controls.CodeCompletion;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.TagCompletion;
@ -36,6 +37,7 @@ using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.PackageModification;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Models.Update;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater;
@ -103,7 +105,6 @@ public static class DesignData
// General services
services
.AddLogging()
.AddSingleton<INavigationService, NavigationService>()
.AddSingleton<IPackageFactory, PackageFactory>()
.AddSingleton<IUpdateHelper, UpdateHelper>()
.AddSingleton<ModelFinder>()
@ -119,6 +120,7 @@ public static class DesignData
.AddSingleton(Substitute.For<IDiscordRichPresenceService>())
.AddSingleton(Substitute.For<ITrackedDownloadService>())
.AddSingleton(Substitute.For<ILiteDbContext>())
.AddSingleton(Substitute.For<IAccountsService>())
.AddSingleton<IInferenceClientManager, MockInferenceClientManager>()
.AddSingleton<ICompletionProvider, MockCompletionProvider>()
.AddSingleton<IModelIndexService, MockModelIndexService>()
@ -304,6 +306,34 @@ public static class DesignData
{
Name = "BB95 Furry Mix",
Description = "A furry mix of BB95",
Stats = new CivitModelStats { Rating = 3.5, RatingCount = 24 },
ModelVersions = [
new() { Name = "v1.2.2-Inpainting" }
]
};
}),
dialogFactory.Get<CheckpointBrowserCardViewModel>(vm =>
{
vm.CivitModel = new CivitModel
{
Name = "Another Model",
Description = "A mix of example",
Stats = new CivitModelStats { Rating = 5, RatingCount = 3500 },
ModelVersions = [
new()
{
Name = "v1.2.2-Inpainting",
Images = new List<CivitImage>
{
new()
{
Nsfw = "None",
Url = "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/"
+ "78fd2a0a-42b6-42b0-9815-81cb11bb3d05/00009-2423234823.jpeg"
}
}
}
]
};
})
};
@ -344,7 +374,9 @@ public static class DesignData
new ProgressReport(0.5f, "Downloading...")
)
),
new MockDownloadProgressItemViewModel("Test File 2.exe"),
new MockDownloadProgressItemViewModel(
"Very Long Test File Name Need Even More Longness Thanks That's pRobably good 2.exe"
),
new PackageInstallProgressItemViewModel(
new PackageModificationRunner
{
@ -437,6 +469,43 @@ public static class DesignData
public static InferenceSettingsViewModel InferenceSettingsViewModel =>
Services.GetRequiredService<InferenceSettingsViewModel>();
public static MainSettingsViewModel MainSettingsViewModel =>
Services.GetRequiredService<MainSettingsViewModel>();
public static AccountSettingsViewModel AccountSettingsViewModel =>
Services.GetRequiredService<AccountSettingsViewModel>();
public static UpdateSettingsViewModel UpdateSettingsViewModel
{
get
{
var vm = Services.GetRequiredService<UpdateSettingsViewModel>();
var update = new UpdateInfo
{
Version = SemVersion.Parse("2.0.1"),
ReleaseDate = DateTimeOffset.Now,
Url = new Uri("https://example.org"),
Changelog = new Uri("https://example.org"),
HashBlake3 = "",
Signature = "",
};
vm.UpdateStatus = new UpdateStatusChangedEventArgs
{
LatestUpdate = update,
UpdateChannels = new Dictionary<UpdateChannel, UpdateInfo>
{
[UpdateChannel.Stable] = update,
[UpdateChannel.Preview] = update,
[UpdateChannel.Development] = update
},
CheckedAt = DateTimeOffset.UtcNow
};
return vm;
}
}
public static CheckpointBrowserViewModel CheckpointBrowserViewModel =>
Services.GetRequiredService<CheckpointBrowserViewModel>();
@ -542,6 +611,20 @@ The gallery images are often inpainted, but you will get something very similar
);
});
public static LykosLoginViewModel LykosLoginViewModel =>
DialogFactory.Get<LykosLoginViewModel>();
public static OAuthConnectViewModel OAuthConnectViewModel =>
DialogFactory.Get<OAuthConnectViewModel>(vm =>
{
vm.Url =
"https://www.example.org/oauth2/authorize?"
+ "client_id=66ad566552679cb6e650be01ed6f8d2ae9a0f803c0369850a5c9ee82a2396062&"
+ "scope=identity%20identity.memberships&"
+ "response_type=code&state=test%40example.org&"
+ "redirect_uri=http://localhost:5022/api/oauth/patreon/callback";
});
public static InferenceTextToImageViewModel InferenceTextToImageViewModel =>
DialogFactory.Get<InferenceTextToImageViewModel>(vm =>
{
@ -734,7 +817,7 @@ The gallery images are often inpainted, but you will get something very similar
)
);
public static Indexer Types => new();
public static Indexer Types { get; } = new();
public class Indexer
{

84
StabilityMatrix.Avalonia/DialogHelper.cs

@ -13,6 +13,7 @@ using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using AvaloniaEdit.TextMate;
using CommunityToolkit.Mvvm.Input;
@ -46,10 +47,66 @@ public static class DialogHelper
string description,
IReadOnlyList<TextBoxField> textFields
)
{
return CreateTextEntryDialog(
title,
new MarkdownScrollViewer { Markdown = description },
textFields
);
}
/// <summary>
/// Create a generic textbox entry content dialog.
/// </summary>
public static BetterContentDialog CreateTextEntryDialog(
string title,
string description,
string imageSource,
IReadOnlyList<TextBoxField> textFields
)
{
var markdown = new MarkdownScrollViewer { Markdown = description };
var image = new BetterAdvancedImage((Uri?)null)
{
Source = imageSource,
Stretch = Stretch.UniformToFill,
StretchDirection = StretchDirection.Both,
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = 400,
};
Grid.SetRow(markdown, 0);
Grid.SetRow(image, 1);
var grid = new Grid
{
RowDefinitions =
{
new RowDefinition(GridLength.Star),
new RowDefinition(GridLength.Auto)
},
Children = { markdown, image }
};
return CreateTextEntryDialog(title, grid, textFields);
}
/// <summary>
/// Create a generic textbox entry content dialog.
/// </summary>
public static BetterContentDialog CreateTextEntryDialog(
string title,
Control content,
IReadOnlyList<TextBoxField> textFields
)
{
Dispatcher.UIThread.VerifyAccess();
var stackPanel = new StackPanel();
var stackPanel = new StackPanel { Spacing = 4 };
Grid.SetRow(content, 0);
Grid.SetRow(stackPanel, 1);
var grid = new Grid
{
RowDefinitions =
@ -57,17 +114,16 @@ public static class DialogHelper
new RowDefinition(GridLength.Auto),
new RowDefinition(GridLength.Star)
},
Children =
{
new TextBlock { Text = description },
stackPanel
}
Children = { content, stackPanel }
};
grid.Loaded += (_, _) =>
{
// Focus first textbox
var firstTextBox = stackPanel.Children.OfType<TextBox>().First();
firstTextBox.Focus();
// Focus first TextBox
var firstTextBox = stackPanel.Children
.OfType<StackPanel>()
.FirstOrDefault()
.FindDescendantOfType<TextBox>();
firstTextBox!.Focus();
firstTextBox.CaretIndex = firstTextBox.Text?.LastIndexOf('.') ?? 0;
};
@ -85,8 +141,7 @@ public static class DialogHelper
// Create textboxes
foreach (var field in textFields)
{
var label = new TextBlock { Text = field.Label };
stackPanel.Children.Add(label);
var label = new TextBlock { Text = field.Label, Margin = new Thickness(0, 0, 0, 4) };
var textBox = new TextBox
{
@ -106,7 +161,7 @@ public static class DialogHelper
};
}
stackPanel.Children.Add(textBox);
stackPanel.Children.Add(new StackPanel { Spacing = 4, Children = { label, textBox } });
// When IsValid property changes, update invalid count and primary button
field.PropertyChanged += (_, args) =>
@ -512,8 +567,9 @@ public static class DialogHelper
{
new TextBlock
{
Margin = new Thickness(0, 0, 0, 8),
FontSize = 16,
Margin = new Thickness(0, 2, 0, 8),
FontSize = 20,
FontWeight = FontWeight.DemiBold,
Text = title,
TextWrapping = TextWrapping.WrapWithOverflow,
},

102
StabilityMatrix.Avalonia/Helpers/UriHandler.cs

@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.Versioning;
using System.Text.Json;
using System.Threading.Tasks;
using MessagePipe;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Win32;
using StabilityMatrix.Core.Helper;
using URIScheme;
namespace StabilityMatrix.Avalonia.Helpers;
/// <summary>
/// Custom URI scheme handler for the stabilitymatrix:// protocol
/// </summary>
/// <remarks>Need to call <see cref="RegisterUriScheme"/> on App startup</remarks>
public class UriHandler
{
public const string IpcKeySend = "uri_handler_send";
public string Scheme { get; }
public string Description { get; }
public UriHandler(string scheme, string description)
{
Scheme = scheme;
Description = description;
}
/// <summary>
/// Send a received Uri over MessagePipe and exits
/// </summary>
/// <param name="uri"></param>
[DoesNotReturn]
public void SendAndExit(Uri uri)
{
var services = new ServiceCollection();
services.AddMessagePipe();
services.AddMessagePipeNamedPipeInterprocess("StabilityMatrix");
var provider = services.BuildServiceProvider();
var publisher = provider.GetRequiredService<IDistributedPublisher<string, Uri>>();
var sendTask = Task.Run(async () => await publisher.PublishAsync(IpcKeySend, uri));
sendTask.Wait();
var info = JsonSerializer.Serialize(new Dictionary<string, Uri> { [IpcKeySend] = uri });
Debug.WriteLine(info);
Console.WriteLine(info);
Environment.Exit(0);
}
public void Callback() { }
public void RegisterUriScheme()
{
if (Compat.IsWindows)
{
RegisterUriSchemeWin();
}
else
{
RegisterUriSchemeUnix();
}
}
[SupportedOSPlatform("windows")]
private void RegisterUriSchemeWin()
{
using var key = Registry.CurrentUser.CreateSubKey(@$"SOFTWARE\Classes\{Scheme}");
key.SetValue("", "URL:" + Description);
key.SetValue(null, Description);
key.SetValue("URL Protocol", "");
using (var defaultIcon = key.CreateSubKey("DefaultIcon"))
{
defaultIcon.SetValue("", Compat.AppCurrentPath.FullPath + ",1");
}
using (var commandKey = key.CreateSubKey(@"shell\open\command"))
{
commandKey.SetValue("", "\"" + Compat.AppCurrentPath.FullPath + "\" --uri \"%1\"");
}
}
private void RegisterUriSchemeUnix()
{
var service = URISchemeServiceFactory.GetURISchemeSerivce(
Scheme,
Description,
Compat.AppCurrentPath.FullPath
);
service.Set();
}
}

162
StabilityMatrix.Avalonia/Languages/Resources.Designer.cs generated

@ -176,6 +176,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Disconnect.
/// </summary>
public static string Action_Disconnect {
get {
return ResourceManager.GetString("Action_Disconnect", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Downgrade.
/// </summary>
@ -239,6 +248,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Login.
/// </summary>
public static string Action_Login {
get {
return ResourceManager.GetString("Action_Login", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to New.
/// </summary>
@ -500,6 +518,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Signup.
/// </summary>
public static string Action_Signup {
get {
return ResourceManager.GetString("Action_Signup", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Stop.
/// </summary>
@ -554,6 +581,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Accounts.
/// </summary>
public static string Label_Accounts {
get {
return ResourceManager.GetString("Label_Accounts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add Stability Matrix to the Start Menu.
/// </summary>
@ -590,6 +626,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to API Key.
/// </summary>
public static string Label_ApiKey {
get {
return ResourceManager.GetString("Label_ApiKey", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Appearance.
/// </summary>
@ -617,6 +662,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Auto Updates.
/// </summary>
public static string Label_AutoUpdates {
get {
return ResourceManager.GetString("Label_AutoUpdates", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Base Model.
/// </summary>
@ -698,6 +752,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to You must be logged in to download this checkpoint. Please enter a CivitAI API Key in the settings..
/// </summary>
public static string Label_CivitAiLoginRequired {
get {
return ResourceManager.GetString("Label_CivitAiLoginRequired", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Close dialog when finished.
/// </summary>
@ -734,6 +797,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Confirm Password.
/// </summary>
public static string Label_ConfirmPassword {
get {
return ResourceManager.GetString("Label_ConfirmPassword", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Confirm?.
/// </summary>
@ -743,6 +815,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Connected.
/// </summary>
public static string Label_Connected {
get {
return ResourceManager.GetString("Label_Connected", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Connected Model.
/// </summary>
@ -860,6 +941,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Download Failed.
/// </summary>
public static string Label_DownloadFailed {
get {
return ResourceManager.GetString("Label_DownloadFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Downloads.
/// </summary>
@ -887,6 +977,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Email.
/// </summary>
public static string Label_Email {
get {
return ResourceManager.GetString("Label_Email", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Embedded Python.
/// </summary>
@ -1364,6 +1463,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Password.
/// </summary>
public static string Label_Password {
get {
return ResourceManager.GetString("Label_Password", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Please choose a different name or select a different install location..
/// </summary>
@ -1715,6 +1823,33 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Updates.
/// </summary>
public static string Label_Updates {
get {
return ResourceManager.GetString("Label_Updates", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to For technical users. Be the first to access our Development builds from feature branches as soon as they are available. There may be some rough edges and bugs as we experiment with new features..
/// </summary>
public static string Label_UpdatesDevChannelDescription {
get {
return ResourceManager.GetString("Label_UpdatesDevChannelDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to For early adopters. Preview builds will be more reliable than those from the Dev channel, and will be available closer to stable releases. Your feedback will help us greatly in discovering issues and polishing design elements..
/// </summary>
public static string Label_UpdatesPreviewChannelDescription {
get {
return ResourceManager.GetString("Label_UpdatesPreviewChannelDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Upscale.
/// </summary>
@ -1724,6 +1859,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Username.
/// </summary>
public static string Label_Username {
get {
return ResourceManager.GetString("Label_Username", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Output Sharing.
/// </summary>
@ -1778,6 +1922,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to You&apos;re up to date.
/// </summary>
public static string Label_YouAreUpToDate {
get {
return ResourceManager.GetString("Label_YouAreUpToDate", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Download complete.
/// </summary>
@ -1940,6 +2093,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Last checked: {0}.
/// </summary>
public static string TextTemplate_LastChecked {
get {
return ResourceManager.GetString("TextTemplate_LastChecked", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} has been updated to the latest version.
/// </summary>

54
StabilityMatrix.Avalonia/Languages/Resources.resx

@ -750,4 +750,58 @@
<data name="Action_OpenGithub" xml:space="preserve">
<value>Open on GitHub</value>
</data>
<data name="Label_Connected" xml:space="preserve">
<value>Connected</value>
</data>
<data name="Action_Disconnect" xml:space="preserve">
<value>Disconnect</value>
</data>
<data name="Label_Email" xml:space="preserve">
<value>Email</value>
</data>
<data name="Label_Username" xml:space="preserve">
<value>Username</value>
</data>
<data name="Label_Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Action_Login" xml:space="preserve">
<value>Login</value>
</data>
<data name="Action_Signup" xml:space="preserve">
<value>Signup</value>
</data>
<data name="Label_ConfirmPassword" xml:space="preserve">
<value>Confirm Password</value>
</data>
<data name="Label_ApiKey" xml:space="preserve">
<value>API Key</value>
</data>
<data name="Label_Accounts" xml:space="preserve">
<value>Accounts</value>
</data>
<data name="Label_CivitAiLoginRequired" xml:space="preserve">
<value>You must be logged in to download this checkpoint. Please enter a CivitAI API Key in the settings.</value>
</data>
<data name="Label_DownloadFailed" xml:space="preserve">
<value>Download Failed</value>
</data>
<data name="Label_AutoUpdates" xml:space="preserve">
<value>Auto Updates</value>
</data>
<data name="Label_UpdatesPreviewChannelDescription" xml:space="preserve">
<value>For early adopters. Preview builds will be more reliable than those from the Dev channel, and will be available closer to stable releases. Your feedback will help us greatly in discovering issues and polishing design elements.</value>
</data>
<data name="Label_UpdatesDevChannelDescription" xml:space="preserve">
<value>For technical users. Be the first to access our Development builds from feature branches as soon as they are available. There may be some rough edges and bugs as we experiment with new features.</value>
</data>
<data name="Label_Updates" xml:space="preserve">
<value>Updates</value>
</data>
<data name="Label_YouAreUpToDate" xml:space="preserve">
<value>You're up to date</value>
</data>
<data name="TextTemplate_LastChecked" xml:space="preserve">
<value>Last checked: {0}</value>
</data>
</root>

46
StabilityMatrix.Avalonia/Models/AppArgs.cs

@ -1,43 +1,87 @@
namespace StabilityMatrix.Avalonia.Models;
using System;
using CommandLine;
namespace StabilityMatrix.Avalonia.Models;
/// <summary>
/// Command line arguments passed to the application.
/// </summary>
public class AppArgs
{
/// <summary>
/// Whether to enable debug mode
/// </summary>
[Option("debug", HelpText = "Enable debug mode")]
public bool DebugMode { get; set; }
/// <summary>
/// Whether to use the exception dialog while debugger is attached.
/// When no debugger is attached, the exception dialog is always used.
/// </summary>
[Option("debug-exception-dialog", HelpText = "Use exception dialog while debugger is attached")]
public bool DebugExceptionDialog { get; set; }
/// <summary>
/// Whether to use Sentry when a debugger is attached.
/// </summary>
[Option("debug-sentry", HelpText = "Use Sentry when debugger is attached")]
public bool DebugSentry { get; set; }
/// <summary>
/// Whether to force show the one-click install dialog.
/// </summary>
[Option("debug-one-click-install", HelpText = "Force show the one-click install dialog")]
public bool DebugOneClickInstall { get; set; }
/// <summary>
/// Whether to disable Sentry.
/// </summary>
[Option("no-sentry", HelpText = "Disable Sentry")]
public bool NoSentry { get; set; }
/// <summary>
/// Whether to disable window chrome effects
/// </summary>
[Option("no-window-chrome-effects", HelpText = "Disable window chrome effects")]
public bool NoWindowChromeEffects { get; set; }
/// <summary>
/// Flag to indicate if we should reset the saved window position back to (O,0)
/// </summary>
[Option("reset-window-position", HelpText = "Reset the saved window position back to (0,0)")]
public bool ResetWindowPosition { get; set; }
/// <summary>
/// Flag for disabling hardware acceleration / GPU rendering
/// </summary>
[Option("disable-gpu-rendering", HelpText = "Disable hardware acceleration / GPU rendering")]
public bool DisableGpuRendering { get; set; }
/// <summary>
/// Override global app home directory
/// Defaults to (%APPDATA%|~/.config)/StabilityMatrix
/// </summary>
[Option("home-dir", HelpText = "Override global app home directory")]
public string? HomeDirectoryOverride { get; set; }
/// <summary>
/// Override data directory
/// This takes precedence over relative portable directory and global directory
/// </summary>
[Option("data-dir", HelpText = "Override data directory")]
public string? DataDirectoryOverride { get; set; }
/// <summary>
/// Custom Uri protocol handler
/// This will send the Uri to the running instance of the app via IPC and exit
/// </summary>
[Option("uri", Hidden = true)]
public string? Uri { get; set; }
/// <summary>
/// If provided, the app will wait for the process with this PID to exit
/// before starting up. Mainly used by the updater.
/// </summary>
[Option("wait-for-exit-pid", Hidden = true)]
public int? WaitForExitPid { get; set; }
}

10
StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs

@ -0,0 +1,10 @@
using System;
namespace StabilityMatrix.Avalonia.Models;
public class TypedNavigationEventArgs : EventArgs
{
public required Type ViewModelType { get; init; }
public object? ViewModel { get; init; }
}

70
StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs

@ -0,0 +1,70 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using Semver;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Update;
namespace StabilityMatrix.Avalonia.Models;
public partial class UpdateChannelCard : ObservableObject
{
public UpdateChannel UpdateChannel { get; init; }
public string DisplayName => UpdateChannel.GetStringValue();
public string? Description { get; init; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LatestVersionString))]
[NotifyPropertyChangedFor(nameof(IsLatestVersionUpdateable))]
private SemVersion? latestVersion;
public string? LatestVersionString =>
LatestVersion is null ? null : $"Latest: v{LatestVersion}";
[ObservableProperty]
private bool isSelectable = true;
/// <summary>
/// Whether the <see cref="LatestVersion"/> is available for update.
/// </summary>
public bool IsLatestVersionUpdateable
{
get
{
if (LatestVersion is null)
{
return false;
}
switch (LatestVersion.ComparePrecedenceTo(Compat.AppVersion))
{
case > 0:
// Newer version available
return true;
case 0:
{
// Same version available, check if we both have commit hash metadata
var updateHash = LatestVersion.Metadata;
var appHash = Compat.AppVersion.Metadata;
// Trim both to the lower length, to a minimum of 7 characters
var minLength = Math.Min(7, Math.Min(updateHash.Length, appHash.Length));
updateHash = updateHash[..minLength];
appHash = appHash[..minLength];
// If different, we can update
if (updateHash != appHash)
{
return true;
}
break;
}
}
return false;
}
}
}

15
StabilityMatrix.Avalonia/Models/ViewModelState.cs

@ -0,0 +1,15 @@
using System;
namespace StabilityMatrix.Avalonia.Models;
/// <summary>
///
/// </summary>
[Flags]
public enum ViewModelState : uint
{
/// <summary>
/// View Model has been initially loaded
/// </summary>
InitialLoaded = 1 << 0,
}

203
StabilityMatrix.Avalonia/Program.cs

@ -2,7 +2,6 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
@ -13,12 +12,14 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using CommandLine;
using NLog;
using Polly.Contrib.WaitAndRetry;
using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome;
using Semver;
using Sentry;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views.Dialogs;
@ -27,19 +28,21 @@ using StabilityMatrix.Core.Updater;
namespace StabilityMatrix.Avalonia;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class Program
public static class Program
{
private static Logger? _logger;
private static Logger Logger => _logger ??= LogManager.GetCurrentClassLogger();
public static AppArgs Args { get; } = new();
public static AppArgs Args { get; private set; } = new();
public static bool IsDebugBuild { get; private set; }
public static Stopwatch StartupTimer { get; } = new();
public static UriHandler UriHandler { get; } = new("stabilitymatrix", "StabilityMatrix");
public static Uri MessagePipeUri { get; } = new("stabilitymatrix://app");
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
@ -48,17 +51,58 @@ public class Program
{
StartupTimer.Start();
Args.DebugExceptionDialog = args.Contains("--debug-exception-dialog");
Args.DebugSentry = args.Contains("--debug-sentry");
Args.DebugOneClickInstall = args.Contains("--debug-one-click-install");
Args.NoSentry = args.Contains("--no-sentry");
Args.NoWindowChromeEffects = args.Contains("--no-window-chrome-effects");
Args.ResetWindowPosition = args.Contains("--reset-window-position");
Args.DisableGpuRendering = args.Contains("--disable-gpu");
SetDebugBuild();
var parseResult = Parser.Default
.ParseArguments<AppArgs>(args)
.WithNotParsed(errors =>
{
foreach (var error in errors)
{
Console.Error.WriteLine(error.ToString());
}
});
Args = parseResult.Value;
if (Args.HomeDirectoryOverride is { } homeDir)
{
Compat.SetAppDataHome(homeDir);
}
// Launched for custom URI scheme, handle and exit
if (Args.Uri is { } uriArg)
{
try
{
if (
Uri.TryCreate(uriArg, UriKind.Absolute, out var uri)
&& string.Equals(
uri.Scheme,
UriHandler.Scheme,
StringComparison.OrdinalIgnoreCase
)
)
{
UriHandler.SendAndExit(uri);
}
Environment.Exit(0);
}
catch (Exception e)
{
Console.Error.WriteLine($"Uri handler encountered an error: {e.Message}");
Environment.Exit(1);
}
}
if (Args.WaitForExitPid is { } waitExitPid)
{
WaitForPidExit(waitExitPid, TimeSpan.FromSeconds(30));
}
HandleUpdateReplacement();
HandleUpdateCleanup();
var infoVersion = Assembly
.GetExecutingAssembly()
@ -126,57 +170,70 @@ public class Program
private static void HandleUpdateReplacement()
{
// Check if we're in the named update folder or the legacy update folder for 1.2.0 -> 2.0.0
if (Compat.AppCurrentDir is { Name: UpdateHelper.UpdateFolderName } or { Name: "Update" })
{
var parentDir = Compat.AppCurrentDir.Parent;
if (parentDir is null)
return;
if (Compat.AppCurrentDir.Name is not (UpdateHelper.UpdateFolderName or "Update"))
return;
var retryDelays = Backoff.DecorrelatedJitterBackoffV2(
TimeSpan.FromMilliseconds(350),
retryCount: 5
);
if (Compat.AppCurrentDir.Parent is not { } parentDir)
return;
foreach (var delay in retryDelays)
{
// Copy our current file to the parent directory, overwriting the old app file
var currentExe = Compat.AppCurrentDir.JoinFile(Compat.GetExecutableName());
var targetExe = parentDir.JoinFile(Compat.GetExecutableName());
try
{
currentExe.CopyTo(targetExe, true);
// Copy our current file to the parent directory, overwriting the old app file
var currentExe = Compat.AppCurrentDir.JoinFile(Compat.GetExecutableName());
var targetExe = parentDir.JoinFile(Compat.GetExecutableName());
// Ensure permissions are set for unix
if (Compat.IsUnix)
{
File.SetUnixFileMode(
targetExe, // 0755
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherExecute
);
}
var isCopied = false;
// Start the new app
Process.Start(targetExe);
// Shutdown the current app
Environment.Exit(0);
}
catch (Exception)
{
Thread.Sleep(delay);
}
foreach (
var delay in Backoff.DecorrelatedJitterBackoffV2(
TimeSpan.FromMilliseconds(300),
retryCount: 6,
fastFirst: true
)
)
{
try
{
currentExe.CopyTo(targetExe, true);
isCopied = true;
break;
}
catch (Exception)
{
Thread.Sleep(delay);
}
}
if (!isCopied)
{
Logger.Error("Failed to copy current executable to parent directory");
Environment.Exit(1);
}
// Ensure permissions are set for unix
if (Compat.IsUnix)
{
File.SetUnixFileMode(
targetExe, // 0755
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherExecute
);
}
// Start the new app while passing our own PID to wait for exit
Process.Start(targetExe, $"--wait-for-exit-pid {Environment.ProcessId}");
// Shutdown the current app
Environment.Exit(0);
}
private static void HandleUpdateCleanup()
{
// Delete update folder if it exists in current directory
var updateDir = UpdateHelper.UpdateFolder;
if (updateDir.Exists)
if (UpdateHelper.UpdateFolder is { Exists: true } updateDir)
{
try
{
@ -184,12 +241,42 @@ public class Program
}
catch (Exception e)
{
var logger = LogManager.GetCurrentClassLogger();
logger.Error(e, "Failed to delete update file");
Logger.Error(e, "Failed to delete update folder");
}
}
}
/// <summary>
/// Wait for an external process to exit,
/// ignores if process is not found, already exited, or throws an exception
/// </summary>
/// <param name="pid">External process PID</param>
/// <param name="timeout">Timeout to wait for process to exit</param>
private static void WaitForPidExit(int pid, TimeSpan timeout)
{
try
{
var process = Process.GetProcessById(pid);
process
.WaitForExitAsync(new CancellationTokenSource(timeout).Token)
.GetAwaiter()
.GetResult();
}
catch (OperationCanceledException)
{
Logger.Warn("Timed out ({Timeout:g}) waiting for process {Pid} to exit", timeout, pid);
}
catch (SystemException e)
{
Logger.Warn(e, "Failed to wait for process {Pid} to exit", pid);
}
catch (Exception e)
{
Logger.Error(e, "Unexpected error during WaitForPidExit");
throw;
}
}
private static void ConfigureSentry()
{
SentrySdk.Init(o =>

254
StabilityMatrix.Avalonia/Services/AccountsService.cs

@ -0,0 +1,254 @@
using System;
using System.Net;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Octokit;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Api.CivitTRPC;
using StabilityMatrix.Core.Models.Api.Lykos;
using StabilityMatrix.Core.Services;
using ApiException = Refit.ApiException;
namespace StabilityMatrix.Avalonia.Services;
[Singleton(typeof(IAccountsService))]
public class AccountsService : IAccountsService
{
private readonly ILogger<AccountsService> logger;
private readonly ISecretsManager secretsManager;
private readonly ILykosAuthApi lykosAuthApi;
private readonly ICivitTRPCApi civitTRPCApi;
/// <inheritdoc />
public event EventHandler<LykosAccountStatusUpdateEventArgs>? LykosAccountStatusUpdate;
/// <inheritdoc />
public event EventHandler<CivitAccountStatusUpdateEventArgs>? CivitAccountStatusUpdate;
public LykosAccountStatusUpdateEventArgs? LykosStatus { get; private set; }
public CivitAccountStatusUpdateEventArgs? CivitStatus { get; private set; }
public AccountsService(
ILogger<AccountsService> logger,
ISecretsManager secretsManager,
ILykosAuthApi lykosAuthApi,
ICivitTRPCApi civitTRPCApi
)
{
this.logger = logger;
this.secretsManager = secretsManager;
this.lykosAuthApi = lykosAuthApi;
this.civitTRPCApi = civitTRPCApi;
// Update our own status when the Lykos account status changes
LykosAccountStatusUpdate += (_, args) => LykosStatus = args;
}
public async Task LykosLoginAsync(string email, string password)
{
var secrets = await secretsManager.SafeLoadAsync();
var tokens = await lykosAuthApi.PostLogin(new PostLoginRequest(email, password));
secrets = secrets with { LykosAccount = tokens };
await secretsManager.SaveAsync(secrets);
await RefreshLykosAsync(secrets);
}
public async Task LykosSignupAsync(string email, string password, string username)
{
var secrets = await secretsManager.SafeLoadAsync();
var tokens = await lykosAuthApi.PostAccount(
new PostAccountRequest(email, password, password, username)
);
secrets = secrets with { LykosAccount = tokens };
await secretsManager.SaveAsync(secrets);
await RefreshLykosAsync(secrets);
}
public async Task LykosLogoutAsync()
{
var secrets = await secretsManager.SafeLoadAsync();
await secretsManager.SaveAsync(secrets with { LykosAccount = null });
OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs.Disconnected);
}
/// <inheritdoc />
public async Task LykosPatreonOAuthLogoutAsync()
{
var secrets = await secretsManager.SafeLoadAsync();
if (secrets.LykosAccount is null)
{
throw new InvalidOperationException(
"Lykos account must be connected in to manage OAuth connections"
);
}
await lykosAuthApi.DeletePatreonOAuth();
await RefreshLykosAsync(secrets);
}
public async Task CivitLoginAsync(string apiToken)
{
var secrets = await secretsManager.SafeLoadAsync();
// Get id first using the api token
var userAccount = await civitTRPCApi.GetUserAccountDefault(apiToken);
var id = userAccount.Result.Data.Json.Id;
// Then get the username using the id
var account = await civitTRPCApi.GetUserById(
new CivitGetUserByIdRequest { Id = id },
apiToken
);
var username = account.Result.Data.Json.Username;
secrets = secrets with { CivitApi = new CivitApiTokens(apiToken, username) };
await secretsManager.SaveAsync(secrets);
await RefreshCivitAsync(secrets);
}
/// <inheritdoc />
public async Task CivitLogoutAsync()
{
var secrets = await secretsManager.SafeLoadAsync();
await secretsManager.SaveAsync(secrets with { CivitApi = null });
OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs.Disconnected);
}
public async Task RefreshAsync()
{
var secrets = await secretsManager.SafeLoadAsync();
await RefreshLykosAsync(secrets);
await RefreshCivitAsync(secrets);
}
private async Task RefreshLykosAsync(Secrets secrets)
{
if (
secrets.LykosAccount is not null
&& !string.IsNullOrWhiteSpace(secrets.LykosAccount?.RefreshToken)
&& !string.IsNullOrWhiteSpace(secrets.LykosAccount?.AccessToken)
)
{
try
{
var user = await lykosAuthApi.GetUserSelf();
OnLykosAccountStatusUpdate(
new LykosAccountStatusUpdateEventArgs { IsConnected = true, User = user }
);
return;
}
catch (OperationCanceledException)
{
logger.LogWarning("Timed out while fetching Lykos Auth user info");
}
catch (InvalidOperationException e)
{
logger.LogWarning(e, "Failed to get authentication token");
}
catch (ApiException e)
{
if (e.StatusCode is HttpStatusCode.Unauthorized) { }
else
{
logger.LogWarning(e, "Failed to get user info from Lykos");
}
}
catch (Exception e)
{
logger.LogError(e, "Unknown error while refreshing Lykos account status");
}
}
OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs.Disconnected);
}
private async Task RefreshCivitAsync(Secrets secrets)
{
if (secrets.CivitApi is not null)
{
try
{
var user = await civitTRPCApi.GetUserProfile(
new CivitUserProfileRequest { Username = secrets.CivitApi.Username },
secrets.CivitApi.ApiToken
);
OnCivitAccountStatusUpdate(
new CivitAccountStatusUpdateEventArgs { IsConnected = true, UserProfile = user }
);
return;
}
catch (OperationCanceledException)
{
logger.LogWarning("Timed out while fetching Civit Auth user info");
}
catch (ApiException e)
{
if (e.StatusCode is HttpStatusCode.Unauthorized) { }
else
{
logger.LogWarning(e, "Failed to get user info from Civit");
}
}
}
OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs.Disconnected);
}
private void OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs e)
{
if (!e.IsConnected && LykosStatus?.IsConnected == true)
{
logger.LogInformation("Lykos account disconnected");
}
else if (e.IsConnected && LykosStatus?.IsConnected == false)
{
logger.LogInformation(
"Lykos account connected: {Id} ({Username})",
e.User?.Id,
e.User?.Account.Name
);
}
LykosAccountStatusUpdate?.Invoke(this, e);
}
private void OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs e)
{
if (!e.IsConnected && CivitStatus?.IsConnected == true)
{
logger.LogInformation("Civit account disconnected");
}
else if (e.IsConnected && CivitStatus?.IsConnected == false)
{
logger.LogInformation(
"Civit account connected: {Id} ({Username})",
e.UserProfile?.UserId,
e.UserProfile?.Username
);
}
CivitAccountStatusUpdate?.Invoke(this, e);
}
}

29
StabilityMatrix.Avalonia/Services/IAccountsService.cs

@ -0,0 +1,29 @@
using System;
using System.Threading.Tasks;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Api.Lykos;
namespace StabilityMatrix.Avalonia.Services;
public interface IAccountsService
{
event EventHandler<LykosAccountStatusUpdateEventArgs>? LykosAccountStatusUpdate;
event EventHandler<CivitAccountStatusUpdateEventArgs>? CivitAccountStatusUpdate;
LykosAccountStatusUpdateEventArgs? LykosStatus { get; }
Task LykosSignupAsync(string email, string password, string username);
Task LykosLoginAsync(string email, string password);
Task LykosLogoutAsync();
Task LykosPatreonOAuthLogoutAsync();
Task CivitLoginAsync(string apiToken);
Task CivitLogoutAsync();
Task RefreshAsync();
}

18
StabilityMatrix.Avalonia/Services/INavigationService.cs

@ -1,11 +1,16 @@
using FluentAvalonia.UI.Controls;
using System;
using System.Diagnostics.CodeAnalysis;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.ViewModels.Base;
namespace StabilityMatrix.Avalonia.Services;
public interface INavigationService
public interface INavigationService<[SuppressMessage("ReSharper", "UnusedTypeParameter")] T>
{
event EventHandler<TypedNavigationEventArgs>? TypedNavigation;
/// <summary>
/// Set the frame to use for navigation.
/// </summary>
@ -20,6 +25,15 @@ public interface INavigationService
)
where TViewModel : ViewModelBase;
/// <summary>
/// Navigate to the view of the given view model type.
/// </summary>
void NavigateTo(
Type viewModelType,
NavigationTransitionInfo? transitionInfo = null,
object? param = null
);
/// <summary>
/// Navigate to the view of the given view model.
/// </summary>

83
StabilityMatrix.Avalonia/Services/NavigationService.cs

@ -4,6 +4,7 @@ using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using FluentAvalonia.UI.Navigation;
using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
@ -11,11 +12,20 @@ using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.Services;
[Singleton(typeof(INavigationService))]
public class NavigationService : INavigationService
[Singleton(
ImplType = typeof(NavigationService<MainWindowViewModel>),
InterfaceType = typeof(INavigationService<MainWindowViewModel>)
)]
[Singleton(
ImplType = typeof(NavigationService<SettingsViewModel>),
InterfaceType = typeof(INavigationService<SettingsViewModel>)
)]
public class NavigationService<T> : INavigationService<T>
{
private Frame? _frame;
public event EventHandler<TypedNavigationEventArgs>? TypedNavigation;
/// <inheritdoc />
public void SetFrame(Frame frame)
{
@ -60,18 +70,60 @@ public class NavigationService : INavigationService
}
);
if (!typeof(TViewModel).IsAssignableTo(typeof(PageViewModelBase)))
return;
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) }
);
}
/// <inheritdoc />
public void NavigateTo(
Type viewModelType,
NavigationTransitionInfo? transitionInfo = null,
object? param = null
)
{
if (!viewModelType.IsAssignableTo(typeof(ViewModelBase)))
{
// ReSharper disable once LocalizableElement
throw new ArgumentException("Type must be a ViewModelBase.", nameof(viewModelType));
}
if (_frame is null)
{
throw new InvalidOperationException("SetFrame was not called before NavigateTo.");
}
if (
App.Services.GetService(typeof(MainWindowViewModel))
is MainWindowViewModel mainViewModel
)
if (App.Services.GetService(typeof(ISettingsManager)) is ISettingsManager settingsManager)
{
mainViewModel.SelectedCategory = mainViewModel.Pages.FirstOrDefault(
x => x.GetType() == typeof(TViewModel)
);
// Handle animation scale
switch (transitionInfo)
{
// If the transition info is null or animation scale is 0, suppress the transition
case null:
case BaseTransitionInfo when settingsManager.Settings.AnimationScale == 0f:
transitionInfo = new SuppressNavigationTransitionInfo();
break;
case BaseTransitionInfo baseTransitionInfo:
baseTransitionInfo.Duration *= settingsManager.Settings.AnimationScale;
break;
}
}
_frame.NavigateToType(
viewModelType,
param,
new FrameNavigationOptions
{
IsNavigationStackEnabled = true,
TransitionInfoOverride = transitionInfo ?? new SuppressNavigationTransitionInfo()
}
);
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs { ViewModelType = viewModelType }
);
}
/// <inheritdoc />
@ -106,5 +158,14 @@ public class NavigationService : INavigationService
TransitionInfoOverride = transitionInfo ?? new SuppressNavigationTransitionInfo()
}
);
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs
{
ViewModelType = viewModel.GetType(),
ViewModel = viewModel
}
);
}
}

57
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>./Assets/Icon.ico</ApplicationIcon>
<Version>2.6.0-dev.1</Version>
<Version>2.7.0-dev.3</Version>
<InformationalVersion>$(Version)</InformationalVersion>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
@ -22,7 +22,7 @@
<ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.2.1" />
<PackageReference Include="AutoComplete.Net" Version="1.2211.2014.42"/>
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.0.1" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.0.5" />
<PackageReference Include="Avalonia.Controls.PanAndZoom" Version="11.0.0" />
<PackageReference Include="Avalonia" Version="11.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.5" />
@ -30,42 +30,49 @@
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.5" />
<PackageReference Include="Avalonia.HtmlRenderer" Version="11.0.0" />
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.2" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.1" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.5" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.5" />
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Dock.Avalonia" Version="11.0.0.2" />
<PackageReference Include="Dock.Model.Avalonia" Version="11.0.0.2" />
<PackageReference Include="Dock.Serializer" Version="11.0.0.2" />
<PackageReference Include="Dock.Avalonia" Version="11.0.0.3" />
<PackageReference Include="Dock.Model.Avalonia" Version="11.0.0.3" />
<PackageReference Include="Dock.Serializer" Version="11.0.0.3" />
<PackageReference Include="DynamicData" Version="8.1.1" />
<PackageReference Include="Exceptionless.DateTimeExtensions" Version="3.4.3" />
<PackageReference Include="FluentAvalonia.BreadcrumbBar" Version="2.0.2" />
<PackageReference Include="FluentAvaloniaUI" Version="2.0.4" />
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.220" />
<PackageReference Include="FluentIcons.FluentAvalonia" Version="1.1.220" />
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.222" />
<PackageReference Include="FluentIcons.FluentAvalonia" Version="1.1.222" />
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="Markdown.Avalonia" Version="11.0.2" />
<PackageReference Include="MessagePipe" Version="1.7.4" />
<PackageReference Include="MessagePipe.Interprocess" Version="1.7.4" />
<PackageReference Include="MetadataExtractor" Version="2.8.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.12" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Nito.AsyncEx" Version="5.1.2" />
<PackageReference Include="NLog" Version="5.2.5" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.5" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="Polly" Version="8.0.0" />
<PackageReference Include="Polly" Version="8.2.0" />
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="Projektanker.Icons.Avalonia.FontAwesome" Version="8.3.0" />
<PackageReference Include="RockLib.Reflection.Optimized" Version="2.0.0" />
<PackageReference Include="Sentry" Version="3.40.1" />
<PackageReference Include="Sentry.NLog" Version="3.40.1" />
<PackageReference Include="Sentry" Version="3.41.0" />
<PackageReference Include="Sentry.NLog" Version="3.41.0" />
<PackageReference Include="SpacedGrid-Avalonia" Version="11.0.0" />
<PackageReference Include="Sylvan.Common" Version="0.4.2" />
<PackageReference Include="Sylvan.Common" Version="0.4.3" />
<PackageReference Include="Sylvan.Data" Version="0.2.12" />
<PackageReference Include="Sylvan.Data.Csv" Version="1.3.3" />
<PackageReference Include="Sylvan.Data.Csv" Version="1.3.5" />
<PackageReference Include="TextMateSharp.Grammars" Version="1.0.56" />
<PackageReference Include="URISchemeTools" Version="1.0.2" />
</ItemGroup>
@ -91,6 +98,12 @@
<AvaloniaResource Include="Assets\noimage.png" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\brands-*.png" />
<AvaloniaResource Include="Assets\brands-*.svg" />
<AvaloniaResource Include="Assets\guide-*.webp"/>
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\licenses.json" />
</ItemGroup>

44
StabilityMatrix.Avalonia/Styles/ControlThemes/HyperlinkIconButtonStyles.axaml

@ -0,0 +1,44 @@
<ResourceDictionary
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls">
<!-- ReSharper disable once Xaml.StaticResourceNotResolved -->
<ControlTheme
x:Key="{x:Type controls:HyperlinkIconButton}"
BasedOn="{StaticResource {x:Type ui:HyperlinkButton}}"
TargetType="controls:HyperlinkIconButton">
<Setter Property="ContentTemplate">
<DataTemplate DataType="x:String">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon
Margin="0,1,4,0"
FontSize="15"
Foreground="{DynamicResource HyperlinkButtonForeground}"
Symbol="Link" />
<TextBlock
Foreground="{DynamicResource HyperlinkButtonForeground}" Text="{Binding}" />
</StackPanel>
</DataTemplate>
</Setter>
<!-- Override template to not set Underline TextDecoration -->
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Padding="{TemplateBinding Padding}"
CornerRadius="{TemplateBinding CornerRadius}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
Foreground="{TemplateBinding Foreground}">
</ContentPresenter>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>

25
StabilityMatrix.Avalonia/Styles/ControlThemes/ListBoxStyles.axaml

@ -0,0 +1,25 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ListBoxItem theme that removes ListBox visuals (i.e. for using with radiobuttons) -->
<ControlTheme x:Key="ListBoxItemBorderlessTheme" TargetType="ListBoxItem">
<Setter Property="Template">
<ControlTemplate>
<Panel>
<ContentPresenter
Name="PART_ContentPresenter"
Margin="2,0"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
CornerRadius="{TemplateBinding CornerRadius}" />
</Panel>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>

2
StabilityMatrix.Avalonia/Styles/ThemeColors.axaml

@ -22,12 +22,14 @@
<Color x:Key="ThemeDarkGreenColor">#3A783C</Color>
<Color x:Key="ThemeGreenColor">#4BA04F</Color>
<Color x:Key="ThemeGreenColorTransparent">#AA4BA04F</Color>
<Color x:Key="ThemeMediumSeaGreenColor">#6CCB5F</Color>
<Color x:Key="ThemeLightGreenColor">#8BC34A</Color>
<Color x:Key="ThemeLimeColor">#CDDC39</Color>
<Color x:Key="ThemeYellowColor">#FFEB3B</Color>
<Color x:Key="ThemeAmberColor">#FFC107</Color>
<Color x:Key="ThemeOrangeColor">#FF9800</Color>
<Color x:Key="ThemeDeepOrangeColor">#FF5722</Color>
<Color x:Key="ThemeEldenRingOrangeColor">#FF4F00</Color>
<Color x:Key="ThemeBrownColor">#795548</Color>
<Color x:Key="ThemeGreyColor">#9E9E9E</Color>
<Color x:Key="ThemeBlueGreyColor">#607D8B</Color>

2
StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs

@ -127,7 +127,7 @@ public abstract partial class InferenceTabViewModelBase
// ResetViewState();
// TODO: Dock reset not working, using this hack for now to get a new view
var navService = App.Services.GetRequiredService<INavigationService>();
var navService = App.Services.GetRequiredService<INavigationService<MainWindowViewModel>>();
navService.NavigateTo<LaunchPageViewModel>(new SuppressNavigationTransitionInfo());
((IPersistentViewProvider)this).AttachedPersistentView = null;
navService.NavigateTo<InferenceViewModel>(new BetterEntranceNavigationTransition());

77
StabilityMatrix.Avalonia/ViewModels/Base/TaskDialogViewModelBase.cs

@ -0,0 +1,77 @@
using System.Threading.Tasks;
using System.Windows.Input;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Languages;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
/// <summary>
/// Base class for view models that are used in <see cref="FluentAvalonia.UI.Controls.TaskDialog"/>
/// </summary>
public abstract class TaskDialogViewModelBase : ViewModelBase
{
private TaskDialog? dialog;
protected static TaskDialogCommand GetCommandButton(string text, ICommand command)
{
return new TaskDialogCommand
{
Text = text,
DialogResult = TaskDialogStandardResult.None,
Command = command,
IsDefault = true,
ClosesOnInvoked = false
};
}
protected static TaskDialogButton GetCloseButton()
{
return new TaskDialogButton
{
Text = Resources.Action_Close,
DialogResult = TaskDialogStandardResult.Close
};
}
/// <summary>
/// Return a <see cref="TaskDialog"/> that uses this view model as its content
/// </summary>
public virtual TaskDialog GetDialog()
{
Dispatcher.UIThread.VerifyAccess();
dialog = new TaskDialog
{
Content = this,
XamlRoot = App.VisualRoot,
Buttons = { GetCloseButton() }
};
dialog.AttachedToVisualTree += (s, _) =>
{
((TaskDialog)s!).Closing += OnDialogClosing;
};
dialog.DetachedFromVisualTree += (s, _) =>
{
((TaskDialog)s!).Closing -= OnDialogClosing;
};
return dialog;
}
/// <summary>
/// Show the dialog from <see cref="GetDialog"/> and return the result
/// </summary>
public async Task<TaskDialogStandardResult> ShowDialogAsync()
{
return (TaskDialogStandardResult)await GetDialog().ShowAsync(true);
}
protected void CloseDialog(TaskDialogStandardResult result)
{
dialog?.Hide(result);
}
protected virtual async void OnDialogClosing(object? sender, TaskDialogClosingEventArgs e) { }
}

53
StabilityMatrix.Avalonia/ViewModels/Base/ViewModelBase.cs

@ -2,14 +2,18 @@
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
using JetBrains.Annotations;
using StabilityMatrix.Avalonia.Models;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
public class ViewModelBase : ObservableValidator, IRemovableListItem
{
[PublicAPI]
protected ViewModelState ViewModelState { get; private set; }
private WeakEventManager? parentListRemoveRequestedEventManager;
public event EventHandler ParentListRemoveRequested
{
add
@ -20,24 +24,51 @@ public class ViewModelBase : ObservableValidator, IRemovableListItem
remove => parentListRemoveRequestedEventManager?.RemoveEventHandler(value);
}
protected void RemoveFromParentList() => parentListRemoveRequestedEventManager?.RaiseEvent(
this, EventArgs.Empty, nameof(ParentListRemoveRequested));
protected void RemoveFromParentList() =>
parentListRemoveRequestedEventManager?.RaiseEvent(
this,
EventArgs.Empty,
nameof(ParentListRemoveRequested)
);
/// <summary>
/// Called when the view's LoadedEvent is fired.
/// </summary>
public virtual void OnLoaded()
{
if (!ViewModelState.HasFlag(ViewModelState.InitialLoaded))
{
ViewModelState |= ViewModelState.InitialLoaded;
OnInitialLoaded();
}
}
/// <summary>
/// Called the first time the view's LoadedEvent is fired.
/// Sets the <see cref="ViewModelState.InitialLoaded"/> flag.
/// </summary>
protected virtual void OnInitialLoaded() { }
/// <summary>
/// Called asynchronously when the view's LoadedEvent is fired.
/// Runs on the UI thread via Dispatcher.UIThread.InvokeAsync.
/// The view loading will not wait for this to complete.
/// </summary>
public virtual Task OnLoadedAsync()
{
return Task.CompletedTask;
}
public virtual void OnUnloaded()
{
}
/// <summary>
/// Called when the view's UnloadedEvent is fired.
/// </summary>
public virtual void OnUnloaded() { }
/// <summary>
/// Called asynchronously when the view's UnloadedEvent is fired.
/// Runs on the UI thread via Dispatcher.UIThread.InvokeAsync.
/// The view loading will not wait for this to complete.
/// </summary>
public virtual Task OnUnloadedAsync()
{
return Task.CompletedTask;

5
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs

@ -534,6 +534,11 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase
CurrentPageNumber = TotalPages;
}
public void ClearSearchQuery()
{
SearchQuery = string.Empty;
}
partial void OnShowNsfwChanged(bool value)
{
settingsManager.Transaction(s => s.ModelBrowserNsfwEnabled, value);

11
StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs

@ -55,6 +55,9 @@ public partial class CheckpointFile : ViewModelBase
[ObservableProperty]
private CivitModelType modelType;
[ObservableProperty]
private CheckpointFolder parentFolder;
public string FileName => Path.GetFileName(FilePath);
public ObservableCollection<string> Badges { get; set; } = new();
@ -136,6 +139,8 @@ public partial class CheckpointFile : ViewModelBase
RemoveFromParentList();
}
public void OnMoved() => RemoveFromParentList();
[RelayCommand]
private async Task RenameAsync()
{
@ -218,6 +223,7 @@ public partial class CheckpointFile : ViewModelBase
/// - {filename}.cm-info.json (connected model info)
/// </summary>
public static IEnumerable<CheckpointFile> FromDirectoryIndex(
CheckpointFolder parentFolder,
string directory,
SearchOption searchOption = SearchOption.TopDirectoryOnly
)
@ -266,6 +272,8 @@ public partial class CheckpointFile : ViewModelBase
checkpointFile.PreviewImagePath = Assets.NoImage.ToString();
}
checkpointFile.ParentFolder = parentFolder;
yield return checkpointFile;
}
}
@ -327,13 +335,14 @@ public partial class CheckpointFile : ViewModelBase
/// Index with progress reporting.
/// </summary>
public static IEnumerable<CheckpointFile> FromDirectoryIndex(
CheckpointFolder parentFolder,
string directory,
IProgress<ProgressReport> progress,
SearchOption searchOption = SearchOption.TopDirectoryOnly
)
{
var current = 0ul;
foreach (var checkpointFile in FromDirectoryIndex(directory, searchOption))
foreach (var checkpointFile in FromDirectoryIndex(parentFolder, directory, searchOption))
{
current++;
progress.Report(new ProgressReport(current, "Indexing", checkpointFile.FileName));

72
StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs

@ -18,6 +18,7 @@ using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
@ -212,6 +213,10 @@ public partial class CheckpointFolder : ViewModelBase
var paths = files.Select(f => f.Path.LocalPath).ToArray();
await ImportFilesAsync(paths, settingsManager.Settings.IsImportAsConnected);
}
else if (e.Data.Get("Context") is CheckpointFile file)
{
await MoveBetweenFolders(file);
}
}
catch (Exception)
{
@ -320,6 +325,71 @@ public partial class CheckpointFolder : ViewModelBase
}
}
public async Task MoveBetweenFolders(CheckpointFile sourceFile)
{
var delay = 1.5f;
try
{
Progress.Value = 0;
var sourcePath = new FilePath(sourceFile.FilePath);
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(sourcePath);
var sourceCmInfoPath = Path.Combine(
sourcePath.Directory,
$"{fileNameWithoutExt}.cm-info.json"
);
var sourcePreviewPath = Path.Combine(
sourcePath.Directory,
$"{fileNameWithoutExt}.preview.jpeg"
);
var destinationFilePath = Path.Combine(DirectoryPath, sourcePath.Name);
var destinationCmInfoPath = Path.Combine(
DirectoryPath,
$"{fileNameWithoutExt}.cm-info.json"
);
var destinationPreviewPath = Path.Combine(
DirectoryPath,
$"{fileNameWithoutExt}.preview.jpeg"
);
// Move files
if (File.Exists(sourcePath))
{
Progress.Text = $"Moving {sourcePath.Name}...";
await FileTransfers.MoveFileAsync(sourcePath, destinationFilePath);
}
Progress.Value = 33;
Progress.Text = $"Moving {sourcePath.Name} metadata...";
if (File.Exists(sourceCmInfoPath))
{
await FileTransfers.MoveFileAsync(sourceCmInfoPath, destinationCmInfoPath);
}
Progress.Value = 66;
if (File.Exists(sourcePreviewPath))
{
await FileTransfers.MoveFileAsync(sourcePreviewPath, destinationPreviewPath);
}
Progress.Value = 100;
Progress.Text = $"Moved {sourcePath.Name} to {Title}";
sourceFile.OnMoved();
BackgroundIndex();
delay = 0.5f;
}
catch (FileTransferExistsException)
{
Progress.Value = 0;
Progress.Text = "Failed to move file: destination file exists";
}
finally
{
DelayedClearProgress(TimeSpan.FromSeconds(delay));
}
}
/// <summary>
/// Imports files to the folder. Reports progress to instance properties.
/// </summary>
@ -505,7 +575,7 @@ public partial class CheckpointFolder : ViewModelBase
return Enumerable.Empty<CheckpointFile>();
}
return CheckpointFile.FromDirectoryIndex(DirectoryPath);
return CheckpointFile.FromDirectoryIndex(this, DirectoryPath);
}
/// <summary>

5
StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs

@ -124,6 +124,11 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
Logger.Info($"OnLoadedAsync in {sw.ElapsedMilliseconds}ms");
}
public void ClearSearchQuery()
{
SearchFilter = string.Empty;
}
// ReSharper disable once UnusedParameterInPartialMethod
partial void OnSearchFilterChanged(string value)
{

4
StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs

@ -27,7 +27,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private readonly IPackageFactory packageFactory;
[ObservableProperty]
@ -56,7 +56,7 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa
public InferenceConnectionHelpViewModel(
ISettingsManager settingsManager,
INavigationService navigationService,
INavigationService<MainWindowViewModel> navigationService,
IPackageFactory packageFactory
)
{

141
StabilityMatrix.Avalonia/ViewModels/Dialogs/LykosLoginViewModel.cs

@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using Refit;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Validators;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(LykosLoginDialog))]
[Transient, ManagedService]
public partial class LykosLoginViewModel : TaskDialogViewModelBase
{
private readonly IAccountsService accountsService;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
private bool isSignupMode;
[ObservableProperty]
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
[EmailAddress(ErrorMessage = "Email is not valid")]
private string? email;
[ObservableProperty]
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
[Required]
private string? username;
[ObservableProperty]
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
[Required]
private string? password;
[ObservableProperty]
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
[Required, RequiresMatch<string>(nameof(Password))]
private string? confirmPassword;
[ObservableProperty]
private AppException? loginError;
[ObservableProperty]
private AppException? signupError;
public string SignupFooterMarkdown { get; } =
"""
By signing up, you are creating a
[lykos.ai](https://lykos.ai) Account and agree to our
[Terms](https://lykos.ai/terms-and-conditions) and
[Privacy Policy](https://lykos.ai/privacy)
""";
public LykosLoginViewModel(IAccountsService accountsService)
{
this.accountsService = accountsService;
}
private bool CanExecuteContinueButtonClick()
{
return !HasErrors && IsValid();
}
[RelayCommand(CanExecute = nameof(CanExecuteContinueButtonClick))]
private Task OnContinueButtonClick()
{
return IsSignupMode ? SignupAsync() : LoginAsync();
}
private async Task LoginAsync()
{
try
{
await accountsService.LykosLoginAsync(Email!, Password!);
CloseDialog(TaskDialogStandardResult.OK);
}
catch (OperationCanceledException)
{
LoginError = new AppException("Request timed out", "Please try again later");
}
catch (ApiException e)
{
LoginError = new AppException("Failed to login", $"{e.StatusCode} - {e.Message}");
}
}
private async Task SignupAsync()
{
try
{
await accountsService.LykosSignupAsync(Email!, Password!, Username!);
CloseDialog(TaskDialogStandardResult.OK);
}
catch (OperationCanceledException)
{
SignupError = new AppException("Request timed out", "Please try again later");
}
catch (ApiException e)
{
SignupError = new AppException("Failed to signup", $"{e.StatusCode} - {e.Message}");
}
}
/// <inheritdoc />
public override TaskDialog GetDialog()
{
var dialog = base.GetDialog();
dialog.Buttons = new List<TaskDialogButton>
{
GetCommandButton(Resources.Action_Continue, ContinueButtonClickCommand),
GetCloseButton()
};
return dialog;
}
private bool IsValid()
{
if (IsSignupMode)
{
return !(
string.IsNullOrEmpty(Email)
|| string.IsNullOrEmpty(Username)
|| string.IsNullOrEmpty(Password)
|| string.IsNullOrEmpty(ConfirmPassword)
);
}
return !(string.IsNullOrEmpty(Email) || string.IsNullOrEmpty(Password));
}
}

84
StabilityMatrix.Avalonia/ViewModels/Dialogs/OAuthConnectViewModel.cs

@ -0,0 +1,84 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using MessagePipe;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(OAuthConnectDialog))]
[Transient, ManagedService]
public partial class OAuthConnectViewModel : ContentDialogViewModelBase
{
private readonly ILogger<OAuthConnectViewModel> logger;
private readonly IDistributedSubscriber<string, Uri> uriHandlerSubscriber;
private IAsyncDisposable? uriHandlerSubscription;
[ObservableProperty]
private string? title = "Connect OAuth";
[ObservableProperty]
private string? url;
[ObservableProperty]
private string? description =
"Please login and click 'Allow' in the opened browser window to connect with StabilityMatrix.\n\n"
+ "Once you have done so, close this prompt to complete the connection.";
[ObservableProperty]
private string? footer = "Once you have done so, close this prompt to complete the connection.";
public OAuthConnectViewModel(
ILogger<OAuthConnectViewModel> logger,
IDistributedSubscriber<string, Uri> uriHandlerSubscriber
)
{
this.logger = logger;
this.uriHandlerSubscriber = uriHandlerSubscriber;
}
/// <inheritdoc />
public override async Task OnLoadedAsync()
{
await base.OnLoadedAsync();
uriHandlerSubscription = await uriHandlerSubscriber.SubscribeAsync(
UriHandler.IpcKeySend,
receivedUri =>
{
logger.LogDebug("UriHandler Received URI: {Uri}", receivedUri.PathAndQuery);
if (receivedUri.PathAndQuery.StartsWith("/oauth/patreon/callback"))
{
OnPrimaryButtonClick();
}
}
);
}
/// <inheritdoc />
public override async Task OnUnloadedAsync()
{
if (uriHandlerSubscription is not null)
{
await uriHandlerSubscription.DisposeAsync();
uriHandlerSubscription = null;
}
}
public BetterContentDialog GetDialog()
{
return new BetterContentDialog
{
Title = Title,
Content = this,
CloseButtonText = Resources.Action_Close
};
}
}

4
StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs

@ -32,7 +32,7 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase
private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly ILogger<OneClickInstallViewModel> logger;
private readonly IPyRunner pyRunner;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private const string DefaultPackageName = "stable-diffusion-webui";
[ObservableProperty]
@ -73,7 +73,7 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase
IPrerequisiteHelper prerequisiteHelper,
ILogger<OneClickInstallViewModel> logger,
IPyRunner pyRunner,
INavigationService navigationService
INavigationService<MainWindowViewModel> navigationService
)
{
this.settingsManager = settingsManager;

246
StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs

@ -8,11 +8,13 @@ using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Semver;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Models.Update;
@ -26,6 +28,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[Singleton]
public partial class UpdateViewModel : ContentDialogViewModelBase
{
private readonly ILogger<UpdateViewModel> logger;
private readonly ISettingsManager settingsManager;
private readonly IHttpClientFactory httpClientFactory;
private readonly IUpdateHelper updateHelper;
@ -65,11 +68,13 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
private static partial Regex RegexChangelog();
public UpdateViewModel(
ILogger<UpdateViewModel> logger,
ISettingsManager settingsManager,
IHttpClientFactory httpClientFactory,
IUpdateHelper updateHelper
)
{
this.logger = logger;
this.settingsManager = settingsManager;
this.httpClientFactory = httpClientFactory;
this.updateHelper = updateHelper;
@ -82,6 +87,136 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
updateHelper.StartCheckingForUpdates().SafeFireAndForget();
}
public async Task Preload()
{
if (UpdateInfo is null)
return;
ReleaseNotes = await GetReleaseNotes(UpdateInfo.Changelog.ToString());
}
partial void OnUpdateInfoChanged(UpdateInfo? value)
{
CurrentVersionText = $"v{Compat.AppVersion.ToDisplayString()}";
NewVersionText = $"v{value?.Version.ToDisplayString()}";
}
public override async Task OnLoadedAsync()
{
if (!isLoaded)
{
await Preload();
}
}
/// <inheritdoc />
public override void OnUnloaded()
{
base.OnUnloaded();
isLoaded = false;
}
[RelayCommand]
private async Task InstallUpdate()
{
if (UpdateInfo == null)
{
return;
}
ShowProgressBar = true;
IsProgressIndeterminate = true;
UpdateText = string.Format(
Resources.TextTemplate_UpdatingPackage,
Resources.Label_StabilityMatrix
);
try
{
await updateHelper.DownloadUpdate(
UpdateInfo,
new Progress<ProgressReport>(report =>
{
ProgressValue = Convert.ToInt32(report.Percentage);
IsProgressIndeterminate = report.IsIndeterminate;
})
);
}
catch (Exception e)
{
logger.LogWarning(e, "Failed to download update");
var dialog = DialogHelper.CreateMarkdownDialog(
$"{e.GetType().Name}: {e.Message}",
Resources.Label_UnexpectedErrorOccurred
);
await dialog.ShowAsync();
return;
}
// On unix, we need to set the executable bit
if (Compat.IsUnix)
{
File.SetUnixFileMode(
UpdateHelper.ExecutablePath, // 0755
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherExecute
);
}
UpdateText = "Getting a few things ready...";
await using (new MinimumDelay(500, 1000))
{
Process.Start(
UpdateHelper.ExecutablePath,
$"--wait-for-exit-pid {Environment.ProcessId}"
);
}
UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds...";
await Task.Delay(1000);
UpdateText = "Update complete. Restarting Stability Matrix in 2 seconds...";
await Task.Delay(1000);
UpdateText = "Update complete. Restarting Stability Matrix in 1 second...";
await Task.Delay(1000);
UpdateText = "Update complete. Restarting Stability Matrix...";
App.Shutdown();
}
internal async Task<string> GetReleaseNotes(string changelogUrl)
{
using var client = httpClientFactory.CreateClient();
var response = await client.GetAsync(changelogUrl);
if (response.IsSuccessStatusCode)
{
var changelog = await response.Content.ReadAsStringAsync();
// Formatting for new changelog format
// https://keepachangelog.com/en/1.1.0/
if (changelogUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
return FormatChangelog(
changelog,
Compat.AppVersion,
settingsManager.Settings.PreferredUpdateChannel
) ?? "## Unable to format release notes";
}
return changelog;
}
else
{
return "## Unable to load release notes";
}
}
/// <summary>
/// Formats changelog markdown including up to the current version
/// </summary>
@ -122,6 +257,15 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
x => x.Version == currentVersion.WithoutMetadata()
);
// For mismatching build metadata, add one
if (
currentVersionBlock != -1
&& results[currentVersionBlock].Version?.Metadata != currentVersion.Metadata
)
{
currentVersionBlock++;
}
// Support for previous pre-release without changelogs
if (currentVersionBlock == -1)
{
@ -159,106 +303,4 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
return string.Join(Environment.NewLine + Environment.NewLine, blocks);
}
public async Task Preload()
{
if (UpdateInfo is null)
return;
ReleaseNotes = await GetReleaseNotes(UpdateInfo.Changelog.ToString());
}
internal async Task<string> GetReleaseNotes(string changelogUrl)
{
using var client = httpClientFactory.CreateClient();
var response = await client.GetAsync(changelogUrl);
if (response.IsSuccessStatusCode)
{
var changelog = await response.Content.ReadAsStringAsync();
// Formatting for new changelog format
// https://keepachangelog.com/en/1.1.0/
if (changelogUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
return FormatChangelog(changelog, Compat.AppVersion)
?? "## Unable to format release notes";
}
return changelog;
}
else
{
return "## Unable to load release notes";
}
}
partial void OnUpdateInfoChanged(UpdateInfo? value)
{
CurrentVersionText = $"v{Compat.AppVersion}";
NewVersionText = $"v{value?.Version}";
}
public override async Task OnLoadedAsync()
{
if (!isLoaded)
{
await Preload();
}
}
/// <inheritdoc />
public override void OnUnloaded()
{
base.OnUnloaded();
isLoaded = false;
}
[RelayCommand]
private async Task InstallUpdate()
{
if (UpdateInfo == null)
{
return;
}
ShowProgressBar = true;
UpdateText = string.Format(
Resources.TextTemplate_UpdatingPackage,
Resources.Label_StabilityMatrix
);
await updateHelper.DownloadUpdate(
UpdateInfo,
new Progress<ProgressReport>(report =>
{
ProgressValue = Convert.ToInt32(report.Percentage);
IsProgressIndeterminate = report.IsIndeterminate;
})
);
// On unix, we need to set the executable bit
if (Compat.IsUnix)
{
File.SetUnixFileMode(
UpdateHelper.ExecutablePath, // 0755
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherExecute
);
}
UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds...";
await Task.Delay(1000);
UpdateText = "Update complete. Restarting Stability Matrix in 2 seconds...";
await Task.Delay(1000);
UpdateText = "Update complete. Restarting Stability Matrix in 1 second...";
await Task.Delay(1000);
Process.Start(UpdateHelper.ExecutablePath);
App.Shutdown();
}
}

128
StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs

@ -49,7 +49,7 @@ public partial class OutputsPageViewModel : PageViewModelBase
private readonly ISettingsManager settingsManager;
private readonly IPackageFactory packageFactory;
private readonly INotificationService notificationService;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private readonly ILogger<OutputsPageViewModel> logger;
public override string Title => Resources.Label_OutputsPageTitle;
@ -69,10 +69,10 @@ public partial class OutputsPageViewModel : PageViewModelBase
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanShowOutputTypes))]
private PackageOutputCategory selectedCategory;
private PackageOutputCategory? selectedCategory;
[ObservableProperty]
private SharedOutputType selectedOutputType;
private SharedOutputType? selectedOutputType;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NumImagesSelected))]
@ -99,7 +99,7 @@ public partial class OutputsPageViewModel : PageViewModelBase
ISettingsManager settingsManager,
IPackageFactory packageFactory,
INotificationService notificationService,
INavigationService navigationService,
INavigationService<MainWindowViewModel> navigationService,
ILogger<OutputsPageViewModel> logger
)
{
@ -136,6 +136,8 @@ public partial class OutputsPageViewModel : PageViewModelBase
settings => settings.OutputsImageSize,
delay: TimeSpan.FromMilliseconds(250)
);
RefreshCategories();
}
public override void OnLoaded()
@ -148,49 +150,8 @@ public partial class OutputsPageViewModel : PageViewModelBase
Directory.CreateDirectory(settingsManager.ImagesDirectory);
var packageCategories = settingsManager.Settings.InstalledPackages
.Where(x => !x.UseSharedOutputFolder)
.Select(packageFactory.GetPackagePair)
.WhereNotNull()
.Where(
p =>
p.BasePackage.SharedOutputFolders != null
&& p.BasePackage.SharedOutputFolders.Any()
)
.Select(
pair =>
new PackageOutputCategory
{
Path = Path.Combine(
pair.InstalledPackage.FullPath!,
pair.BasePackage.OutputFolderName
),
Name = pair.InstalledPackage.DisplayName ?? ""
}
)
.ToList();
packageCategories.Insert(
0,
new PackageOutputCategory
{
Path = settingsManager.ImagesDirectory,
Name = "Shared Output Folder"
}
);
packageCategories.Insert(
1,
new PackageOutputCategory
{
Path = settingsManager.ImagesInferenceDirectory,
Name = "Inference"
}
);
Categories = new ObservableCollection<PackageOutputCategory>(packageCategories);
SelectedCategory = Categories.First();
SelectedOutputType = SharedOutputType.All;
SelectedCategory ??= Categories.First();
SelectedOutputType ??= SharedOutputType.All;
SearchQuery = string.Empty;
ImageSize = settingsManager.Settings.OutputsImageSize;
@ -216,14 +177,14 @@ public partial class OutputsPageViewModel : PageViewModelBase
GetOutputs(path);
}
partial void OnSelectedOutputTypeChanged(SharedOutputType oldValue, SharedOutputType newValue)
partial void OnSelectedOutputTypeChanged(SharedOutputType? oldValue, SharedOutputType? newValue)
{
if (oldValue == newValue)
if (oldValue == newValue || newValue == null)
return;
var path =
newValue == SharedOutputType.All
? SelectedCategory.Path
? SelectedCategory?.Path
: Path.Combine(SelectedCategory.Path, newValue.ToString());
GetOutputs(path);
}
@ -297,6 +258,12 @@ public partial class OutputsPageViewModel : PageViewModelBase
public Task OpenImage(string imagePath) => ProcessRunner.OpenFileBrowser(imagePath);
public void Refresh()
{
Dispatcher.UIThread.Post(RefreshCategories);
Dispatcher.UIThread.Post(OnLoaded);
}
public async Task DeleteImage(OutputImageViewModel? item)
{
if (item is null)
@ -497,6 +464,11 @@ public partial class OutputsPageViewModel : PageViewModelBase
IsConsolidating = false;
}
public void ClearSearchQuery()
{
SearchQuery = string.Empty;
}
private void GetOutputs(string directory)
{
if (!settingsManager.IsLibraryDirSet)
@ -528,4 +500,60 @@ public partial class OutputsPageViewModel : PageViewModelBase
OutputsCache.EditDiff(files);
}
}
private void RefreshCategories()
{
if (Design.IsDesignMode)
return;
if (!settingsManager.IsLibraryDirSet)
return;
var previouslySelectedCategory = SelectedCategory;
var packageCategories = settingsManager.Settings.InstalledPackages
.Where(x => !x.UseSharedOutputFolder)
.Select(packageFactory.GetPackagePair)
.WhereNotNull()
.Where(
p =>
p.BasePackage.SharedOutputFolders != null
&& p.BasePackage.SharedOutputFolders.Any()
)
.Select(
pair =>
new PackageOutputCategory
{
Path = Path.Combine(
pair.InstalledPackage.FullPath!,
pair.BasePackage.OutputFolderName
),
Name = pair.InstalledPackage.DisplayName ?? ""
}
)
.ToList();
packageCategories.Insert(
0,
new PackageOutputCategory
{
Path = settingsManager.ImagesDirectory,
Name = "Shared Output Folder"
}
);
packageCategories.Insert(
1,
new PackageOutputCategory
{
Path = settingsManager.ImagesInferenceDirectory,
Name = "Inference"
}
);
Categories = new ObservableCollection<PackageOutputCategory>(packageCategories);
SelectedCategory =
Categories.FirstOrDefault(x => x.Name == previouslySelectedCategory?.Name)
?? Categories.First();
}
}

4
StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs

@ -36,7 +36,7 @@ public partial class PackageCardViewModel : ProgressViewModel
private readonly IPackageFactory packageFactory;
private readonly INotificationService notificationService;
private readonly ISettingsManager settingsManager;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private readonly ServiceManager<ViewModelBase> vmFactory;
[ObservableProperty]
@ -80,7 +80,7 @@ public partial class PackageCardViewModel : ProgressViewModel
IPackageFactory packageFactory,
INotificationService notificationService,
ISettingsManager settingsManager,
INavigationService navigationService,
INavigationService<MainWindowViewModel> navigationService,
ServiceManager<ViewModelBase> vmFactory
)
{

52
StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
@ -6,9 +7,14 @@ using Avalonia.Collections;
using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Settings;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
@ -27,6 +33,8 @@ namespace StabilityMatrix.Avalonia.ViewModels.Progress;
public partial class ProgressManagerViewModel : PageViewModelBase
{
private readonly INotificationService notificationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private readonly INavigationService<SettingsViewModel> settingsNavService;
public override string Title => "Download Manager";
public override IconSource IconSource =>
@ -38,10 +46,14 @@ public partial class ProgressManagerViewModel : PageViewModelBase
public ProgressManagerViewModel(
ITrackedDownloadService trackedDownloadService,
INotificationService notificationService
INotificationService notificationService,
INavigationService<MainWindowViewModel> navigationService,
INavigationService<SettingsViewModel> settingsNavService
)
{
this.notificationService = notificationService;
this.navigationService = navigationService;
this.settingsNavService = settingsNavService;
// Attach to the event
trackedDownloadService.DownloadAdded += TrackedDownloadService_OnDownloadAdded;
@ -82,8 +94,42 @@ public partial class ProgressManagerViewModel : PageViewModelBase
var msg = "";
if (download?.Exception is { } exception)
{
msg = $"({exception.GetType().Name}) {exception.Message}";
if (
exception is UnauthorizedAccessException
|| exception.InnerException is UnauthorizedAccessException
)
{
Dispatcher.UIThread.InvokeAsync(async () =>
{
var errorDialog = new BetterContentDialog
{
Title = Resources.Label_DownloadFailed,
Content = Resources.Label_CivitAiLoginRequired,
PrimaryButtonText = "Go to Settings",
SecondaryButtonText = "Close",
DefaultButton = ContentDialogButton.Primary,
};
var result = await errorDialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
navigationService.NavigateTo<SettingsViewModel>(
new SuppressNavigationTransitionInfo()
);
await Task.Delay(100);
settingsNavService.NavigateTo<AccountSettingsViewModel>(
new SuppressNavigationTransitionInfo()
);
}
});
return;
}
msg =
$"({exception.GetType().Name}) {exception.InnerException?.Message ?? exception.Message}";
}
Dispatcher.UIThread.Post(() =>
{
notificationService.ShowPersistent(

286
StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs

@ -0,0 +1,286 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views.Settings;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Api.Lykos;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
using TeachingTip = StabilityMatrix.Core.Models.Settings.TeachingTip;
namespace StabilityMatrix.Avalonia.ViewModels.Settings;
[View(typeof(AccountSettingsPage))]
[Singleton, ManagedService]
public partial class AccountSettingsViewModel : PageViewModelBase
{
private readonly IAccountsService accountsService;
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> vmFactory;
private readonly INotificationService notificationService;
private readonly ILykosAuthApi lykosAuthApi;
/// <inheritdoc />
public override string Title => "Accounts";
/// <inheritdoc />
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true };
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ConnectLykosCommand))]
[NotifyCanExecuteChangedFor(nameof(ConnectPatreonCommand))]
[NotifyCanExecuteChangedFor(nameof(ConnectCivitCommand))]
private bool isInitialUpdateFinished;
[ObservableProperty]
private string? lykosProfileImageUrl;
[ObservableProperty]
private bool isPatreonConnected;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LykosProfileImageUrl))]
private LykosAccountStatusUpdateEventArgs lykosStatus =
LykosAccountStatusUpdateEventArgs.Disconnected;
[ObservableProperty]
private CivitAccountStatusUpdateEventArgs civitStatus =
CivitAccountStatusUpdateEventArgs.Disconnected;
public AccountSettingsViewModel(
IAccountsService accountsService,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> vmFactory,
INotificationService notificationService,
ILykosAuthApi lykosAuthApi
)
{
this.accountsService = accountsService;
this.settingsManager = settingsManager;
this.vmFactory = vmFactory;
this.notificationService = notificationService;
this.lykosAuthApi = lykosAuthApi;
accountsService.LykosAccountStatusUpdate += (_, args) =>
{
Dispatcher.UIThread.Post(() =>
{
IsInitialUpdateFinished = true;
LykosStatus = args;
IsPatreonConnected = args.IsPatreonConnected;
});
};
accountsService.CivitAccountStatusUpdate += (_, args) =>
{
Dispatcher.UIThread.Post(() =>
{
IsInitialUpdateFinished = true;
CivitStatus = args;
});
};
}
/// <inheritdoc />
public override void OnLoaded()
{
base.OnLoaded();
if (Design.IsDesignMode)
{
return;
}
accountsService.RefreshAsync().SafeFireAndForget();
}
private async Task<bool> BeforeConnectCheck()
{
// Show credentials storage notice if not seen
if (
!settingsManager.Settings.SeenTeachingTips.Contains(
TeachingTip.AccountsCredentialsStorageNotice
)
)
{
var dialog = new BetterContentDialog
{
Title = "About Account Credentials",
Content = """
Account credentials and tokens are stored locally on your computer, with at-rest AES encryption.
If you make changes to your computer hardware, you may need to re-login to your accounts.
Account tokens will not be viewable after saving, please make a note of them if you need to use them elsewhere.
""",
PrimaryButtonText = Resources.Action_Continue,
CloseButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Primary,
MaxDialogWidth = 400
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return false;
}
settingsManager.Transaction(
s => s.SeenTeachingTips.Add(TeachingTip.AccountsCredentialsStorageNotice)
);
}
return true;
}
[RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))]
private async Task ConnectLykos()
{
if (!await BeforeConnectCheck())
return;
var vm = vmFactory.Get<LykosLoginViewModel>();
await vm.ShowDialogAsync();
}
[RelayCommand]
private Task DisconnectLykos()
{
return accountsService.LykosLogoutAsync();
}
[RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))]
private async Task ConnectPatreon()
{
if (!await BeforeConnectCheck())
return;
if (LykosStatus.User?.Id is null)
return;
var urlResult = await notificationService.TryAsync(
lykosAuthApi.GetPatreonOAuthUrl(
Program.MessagePipeUri.Append("/oauth/patreon/callback").ToString()
)
);
if (!urlResult.IsSuccessful || urlResult.Result is not { } url)
{
return;
}
ProcessRunner.OpenUrl(urlResult.Result);
var dialogVm = vmFactory.Get<OAuthConnectViewModel>();
dialogVm.Title = "Connect Patreon Account";
dialogVm.Url = url;
if (await dialogVm.GetDialog().ShowAsync() == ContentDialogResult.Primary)
{
await accountsService.RefreshAsync();
// Bring main window to front since browser is probably covering
var main = App.TopLevel as Window;
main?.Activate();
}
}
[RelayCommand]
private async Task DisconnectPatreon()
{
await notificationService.TryAsync(accountsService.LykosPatreonOAuthLogoutAsync());
}
[RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))]
private async Task ConnectCivit()
{
if (!await BeforeConnectCheck())
return;
var textFields = new TextBoxField[]
{
new()
{
Label = Resources.Label_ApiKey,
Validator = s =>
{
if (string.IsNullOrWhiteSpace(s))
{
throw new ValidationException("API key is required");
}
}
}
};
var dialog = DialogHelper.CreateTextEntryDialog(
"Connect CivitAI Account",
"""
Login to [CivitAI](https://civitai.com/) and head to your [Account](https://civitai.com/user/account) page
Add a new API key and paste it below
""",
"avares://StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp",
textFields
);
dialog.PrimaryButtonText = Resources.Action_Connect;
if (
await dialog.ShowAsync() != ContentDialogResult.Primary
|| textFields[0].Text is not { } apiToken
)
{
return;
}
var result = await notificationService.TryAsync(accountsService.CivitLoginAsync(apiToken));
if (result.IsSuccessful)
{
await accountsService.RefreshAsync();
}
}
[RelayCommand]
private Task DisconnectCivit()
{
return accountsService.CivitLogoutAsync();
}
/// <summary>
/// Update the Lykos profile image URL when the user changes.
/// </summary>
partial void OnLykosStatusChanged(LykosAccountStatusUpdateEventArgs? value)
{
if (value?.User?.Id is { } userEmail)
{
userEmail = userEmail.Trim().ToLowerInvariant();
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(userEmail));
var hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
LykosProfileImageUrl = $"https://gravatar.com/avatar/{hash}?s=512&d=retro";
}
else
{
LykosProfileImageUrl = null;
}
}
}

17
StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs

@ -1,9 +1,20 @@
using StabilityMatrix.Avalonia.ViewModels.Base;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Settings;
using StabilityMatrix.Core.Attributes;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels.Settings;
[View(typeof(InferenceSettingsPage))]
[Singleton]
public partial class InferenceSettingsViewModel : ViewModelBase { }
[Singleton, ManagedService]
public class InferenceSettingsViewModel : PageViewModelBase
{
/// <inheritdoc />
public override string Title => "Inference";
/// <inheritdoc />
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
}

960
StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs

@ -0,0 +1,960 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia;
using Avalonia.Controls.Notifications;
using Avalonia.Controls.Primitives;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls;
using NLog;
using SkiaSharp;
using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Models.TagCompletion;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.Inference;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Avalonia.Views.Settings;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels.Settings;
[View(typeof(MainSettingsPage))]
[Singleton, ManagedService]
public partial class MainSettingsViewModel : PageViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly INotificationService notificationService;
private readonly ISettingsManager settingsManager;
private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly IPyRunner pyRunner;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ICompletionProvider completionProvider;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly IModelIndexService modelIndexService;
private readonly INavigationService<SettingsViewModel> settingsNavigationService;
private readonly IAccountsService accountsService;
public SharedState SharedState { get; }
public override string Title => "Settings";
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
// ReSharper disable once MemberCanBeMadeStatic.Global
public string AppVersion =>
$"Version {Compat.AppVersion.ToDisplayString()}" + (Program.IsDebugBuild ? " (Debug)" : "");
// Theme section
[ObservableProperty]
private string? selectedTheme;
public IReadOnlyList<string> AvailableThemes { get; } = new[] { "Light", "Dark", "System", };
[ObservableProperty]
private CultureInfo selectedLanguage;
// ReSharper disable once MemberCanBeMadeStatic.Global
public IReadOnlyList<CultureInfo> AvailableLanguages => Cultures.SupportedCultures;
public IReadOnlyList<float> AnimationScaleOptions { get; } =
new[] { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, };
[ObservableProperty]
private float selectedAnimationScale;
// Shared folder options
[ObservableProperty]
private bool removeSymlinksOnShutdown;
// Inference UI section
[ObservableProperty]
private bool isPromptCompletionEnabled = true;
[ObservableProperty]
private IReadOnlyList<string> availableTagCompletionCsvs = Array.Empty<string>();
[ObservableProperty]
private string? selectedTagCompletionCsv;
[ObservableProperty]
private bool isCompletionRemoveUnderscoresEnabled = true;
[ObservableProperty]
[CustomValidation(typeof(MainSettingsViewModel), nameof(ValidateOutputImageFileNameFormat))]
private string? outputImageFileNameFormat;
[ObservableProperty]
private string? outputImageFileNameFormatSample;
public IEnumerable<FileNameFormatVar> OutputImageFileNameFormatVars =>
FileNameFormatProvider
.GetSample()
.Substitutions.Select(
kv =>
new FileNameFormatVar
{
Variable = $"{{{kv.Key}}}",
Example = kv.Value.Invoke()
}
);
[ObservableProperty]
private bool isImageViewerPixelGridEnabled = true;
// Integrations section
[ObservableProperty]
private bool isDiscordRichPresenceEnabled;
// Debug section
[ObservableProperty]
private string? debugPaths;
[ObservableProperty]
private string? debugCompatInfo;
[ObservableProperty]
private string? debugGpuInfo;
// Info section
private const int VersionTapCountThreshold = 7;
[ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))]
private int versionTapCount;
[ObservableProperty]
private bool isVersionTapTeachingTipOpen;
public string VersionFlyoutText =>
$"You are {VersionTapCountThreshold - VersionTapCount} clicks away from enabling Debug options.";
public string DataDirectory =>
settingsManager.IsLibraryDirSet ? settingsManager.LibraryDir : "Not set";
public MainSettingsViewModel(
INotificationService notificationService,
ISettingsManager settingsManager,
IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner,
ServiceManager<ViewModelBase> dialogFactory,
ITrackedDownloadService trackedDownloadService,
SharedState sharedState,
ICompletionProvider completionProvider,
IModelIndexService modelIndexService,
INavigationService<SettingsViewModel> settingsNavigationService,
IAccountsService accountsService
)
{
this.notificationService = notificationService;
this.settingsManager = settingsManager;
this.prerequisiteHelper = prerequisiteHelper;
this.pyRunner = pyRunner;
this.dialogFactory = dialogFactory;
this.trackedDownloadService = trackedDownloadService;
this.completionProvider = completionProvider;
this.modelIndexService = modelIndexService;
this.settingsNavigationService = settingsNavigationService;
this.accountsService = accountsService;
SharedState = sharedState;
if (Program.Args.DebugMode)
{
SharedState.IsDebugMode = true;
}
SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1];
SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language);
RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown;
SelectedAnimationScale = settingsManager.Settings.AnimationScale;
settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsDiscordRichPresenceEnabled,
settings => settings.IsDiscordRichPresenceEnabled,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.SelectedAnimationScale,
settings => settings.AnimationScale
);
settingsManager.RelayPropertyFor(
this,
vm => vm.SelectedTagCompletionCsv,
settings => settings.TagCompletionCsv
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsPromptCompletionEnabled,
settings => settings.IsPromptCompletionEnabled,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsCompletionRemoveUnderscoresEnabled,
settings => settings.IsCompletionRemoveUnderscoresEnabled,
true
);
this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat)
.Throttle(TimeSpan.FromMilliseconds(50))
.Subscribe(formatProperty =>
{
var provider = FileNameFormatProvider.GetSample();
var template = formatProperty.Value ?? string.Empty;
if (
!string.IsNullOrEmpty(template)
&& provider.Validate(template) == ValidationResult.Success
)
{
var format = FileNameFormat.Parse(template, provider);
OutputImageFileNameFormatSample = format.GetFileName() + ".png";
}
else
{
// Use default format if empty
var defaultFormat = FileNameFormat.Parse(
FileNameFormat.DefaultTemplate,
provider
);
OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png";
}
});
settingsManager.RelayPropertyFor(
this,
vm => vm.OutputImageFileNameFormat,
settings => settings.InferenceOutputImageFileNameFormat,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsImageViewerPixelGridEnabled,
settings => settings.IsImageViewerPixelGridEnabled,
true
);
DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler(
notificationService,
LogLevel.Warn
);
ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn);
}
/// <inheritdoc />
public override async Task OnLoadedAsync()
{
await base.OnLoadedAsync();
await notificationService.TryAsync(completionProvider.Setup());
UpdateAvailableTagCompletionCsvs();
// Start accounts update
accountsService.RefreshAsync().SafeFireAndForget();
}
public static ValidationResult ValidateOutputImageFileNameFormat(
string? format,
ValidationContext context
)
{
return FileNameFormatProvider.GetSample().Validate(format ?? string.Empty);
}
partial void OnSelectedThemeChanged(string? value)
{
// In case design / tests
if (Application.Current is null)
return;
// Change theme
Application.Current.RequestedThemeVariant = value switch
{
"Dark" => ThemeVariant.Dark,
"Light" => ThemeVariant.Light,
_ => ThemeVariant.Default
};
}
partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newValue)
{
if (oldValue is null || newValue.Name == Cultures.Current?.Name)
return;
// Set locale
if (AvailableLanguages.Contains(newValue))
{
Logger.Info("Changing language from {Old} to {New}", oldValue, newValue);
Cultures.TrySetSupportedCulture(newValue);
settingsManager.Transaction(s => s.Language = newValue.Name);
var dialog = new BetterContentDialog
{
Title = Resources.Label_RelaunchRequired,
Content = Resources.Text_RelaunchRequiredToApplyLanguage,
DefaultButton = ContentDialogButton.Primary,
PrimaryButtonText = Resources.Action_Relaunch,
CloseButtonText = Resources.Action_RelaunchLater
};
Dispatcher.UIThread.InvokeAsync(async () =>
{
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
});
}
else
{
Logger.Info(
"Requested invalid language change from {Old} to {New}",
oldValue,
newValue
);
}
}
partial void OnRemoveSymlinksOnShutdownChanged(bool value)
{
settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value);
}
public async Task ResetCheckpointCache()
{
settingsManager.Transaction(s => s.InstalledModelHashes = new HashSet<string>());
await Task.Run(() => settingsManager.IndexCheckpoints());
notificationService.Show(
"Checkpoint cache reset",
"The checkpoint cache has been reset.",
NotificationType.Success
);
}
[RelayCommand]
private void NavigateToSubPage(Type viewModelType)
{
Dispatcher.UIThread.Post(
() =>
settingsNavigationService.NavigateTo(
viewModelType,
BetterSlideNavigationTransition.PageSlideFromRight
),
DispatcherPriority.Send
);
}
#region Package Environment
[RelayCommand]
private async Task OpenEnvVarsDialog()
{
var viewModel = dialogFactory.Get<EnvVarsViewModel>();
// Load current settings
var current =
settingsManager.Settings.EnvironmentVariables ?? new Dictionary<string, string>();
viewModel.EnvVars = new ObservableCollection<EnvVarKeyPair>(
current.Select(kvp => new EnvVarKeyPair(kvp.Key, kvp.Value))
);
var dialog = new BetterContentDialog
{
Content = new EnvVarsDialog { DataContext = viewModel },
PrimaryButtonText = Resources.Action_Save,
IsPrimaryButtonEnabled = true,
CloseButtonText = Resources.Action_Cancel,
};
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
// Save settings
var newEnvVars = viewModel.EnvVars
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
.GroupBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal);
settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars);
}
}
[RelayCommand]
private async Task CheckPythonVersion()
{
var isInstalled = prerequisiteHelper.IsPythonInstalled;
Logger.Debug($"Check python installed: {isInstalled}");
// Ensure python installed
if (!prerequisiteHelper.IsPythonInstalled)
{
// Need 7z as well for site packages repack
Logger.Debug("Python not installed, unpacking resources...");
await prerequisiteHelper.UnpackResourcesIfNecessary();
Logger.Debug("Unpacked resources, installing python...");
await prerequisiteHelper.InstallPythonIfNecessary();
}
// Get python version
await pyRunner.Initialize();
var result = await pyRunner.GetVersionInfo();
// Show dialog box
var dialog = new ContentDialog
{
Title = Resources.Label_PythonVersionInfo,
Content = result,
PrimaryButtonText = Resources.Action_OK,
IsPrimaryButtonEnabled = true
};
await dialog.ShowAsync();
}
#endregion
#region Inference UI
private void UpdateAvailableTagCompletionCsvs()
{
if (!settingsManager.IsLibraryDirSet)
return;
var tagsDir = settingsManager.TagsDirectory;
if (!tagsDir.Exists)
return;
var csvFiles = tagsDir.Info.EnumerateFiles("*.csv");
AvailableTagCompletionCsvs = csvFiles.Select(f => f.Name).ToImmutableArray();
// Set selected to current if exists
var settingsCsv = settingsManager.Settings.TagCompletionCsv;
if (settingsCsv is not null && AvailableTagCompletionCsvs.Contains(settingsCsv))
{
SelectedTagCompletionCsv = settingsCsv;
}
}
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task ImportTagCsv()
{
var storage = App.StorageProvider;
var files = await storage.OpenFilePickerAsync(
new FilePickerOpenOptions
{
FileTypeFilter = new List<FilePickerFileType>
{
new("CSV") { Patterns = new[] { "*.csv" }, }
}
}
);
if (files.Count == 0)
return;
var sourceFile = new FilePath(files[0].TryGetLocalPath()!);
var tagsDir = settingsManager.TagsDirectory;
tagsDir.Create();
// Copy to tags directory
var targetFile = tagsDir.JoinFile(sourceFile.Name);
await sourceFile.CopyToAsync(targetFile);
// Update index
UpdateAvailableTagCompletionCsvs();
// Trigger load
completionProvider.BackgroundLoadFromFile(targetFile, true);
notificationService.Show(
$"Imported {sourceFile.Name}",
$"The {sourceFile.Name} file has been imported.",
NotificationType.Success
);
}
#endregion
#region System
/// <summary>
/// Adds Stability Matrix to Start Menu for the current user.
/// </summary>
[RelayCommand]
private async Task AddToStartMenu()
{
if (!Compat.IsWindows)
{
notificationService.Show("Not supported", "This feature is only supported on Windows.");
return;
}
await using var _ = new MinimumDelay(200, 300);
var shortcutDir = new DirectoryPath(
Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),
"Programs"
);
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk");
var appPath = Compat.AppCurrentPath;
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico");
await Assets.AppIcon.ExtractTo(iconPath);
WindowsShortcuts.CreateShortcut(shortcutLink, appPath, iconPath, "Stability Matrix");
notificationService.Show(
"Added to Start Menu",
"Stability Matrix has been added to the Start Menu.",
NotificationType.Success
);
}
/// <summary>
/// Add Stability Matrix to Start Menu for all users.
/// <remarks>Requires Admin elevation.</remarks>
/// </summary>
[RelayCommand]
private async Task AddToGlobalStartMenu()
{
if (!Compat.IsWindows)
{
notificationService.Show("Not supported", "This feature is only supported on Windows.");
return;
}
// Confirmation dialog
var dialog = new BetterContentDialog
{
Title =
"This will create a shortcut for Stability Matrix in the Start Menu for all users",
Content = "You will be prompted for administrator privileges. Continue?",
PrimaryButtonText = Resources.Action_Yes,
CloseButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Primary
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
await using var _ = new MinimumDelay(200, 300);
var shortcutDir = new DirectoryPath(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs"
);
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk");
var appPath = Compat.AppCurrentPath;
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico");
// We can't directly write to the targets, so extract to temporary directory first
using var tempDir = new TempDirectoryPath();
await Assets.AppIcon.ExtractTo(tempDir.JoinFile("Stability Matrix.ico"));
WindowsShortcuts.CreateShortcut(
tempDir.JoinFile("Stability Matrix.lnk"),
appPath,
iconPath,
"Stability Matrix"
);
// Move to target
try
{
var moveLinkResult = await WindowsElevated.MoveFiles(
(tempDir.JoinFile("Stability Matrix.lnk"), shortcutLink),
(tempDir.JoinFile("Stability Matrix.ico"), iconPath)
);
if (moveLinkResult != 0)
{
notificationService.ShowPersistent(
"Failed to create shortcut",
$"Could not copy shortcut",
NotificationType.Error
);
}
}
catch (Win32Exception e)
{
// We'll get this exception if user cancels UAC
Logger.Warn(e, "Could not create shortcut");
notificationService.Show("Could not create shortcut", "", NotificationType.Warning);
return;
}
notificationService.Show(
"Added to Start Menu",
"Stability Matrix has been added to the Start Menu for all users.",
NotificationType.Success
);
}
public async Task PickNewDataDirectory()
{
var viewModel = dialogFactory.Get<SelectDataDirectoryViewModel>();
var dialog = new BetterContentDialog
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new SelectDataDirectoryDialog { DataContext = viewModel }
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// 1. For portable mode, call settings.SetPortableMode()
if (viewModel.IsPortableMode)
{
settingsManager.SetPortableMode();
}
// 2. For custom path, call settings.SetLibraryPath(path)
else
{
settingsManager.SetLibraryPath(viewModel.DataDirectory);
}
// Restart
var restartDialog = new BetterContentDialog
{
Title = "Restart required",
Content = "Stability Matrix must be restarted for the changes to take effect.",
PrimaryButtonText = Resources.Action_Restart,
DefaultButton = ContentDialogButton.Primary,
IsSecondaryButtonEnabled = false,
};
await restartDialog.ShowAsync();
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
}
#endregion
#region Debug Section
public void LoadDebugInfo()
{
var assembly = Assembly.GetExecutingAssembly();
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
DebugPaths = $"""
Current Working Directory [Environment.CurrentDirectory]
"{Environment.CurrentDirectory}"
App Directory [Assembly.GetExecutingAssembly().Location]
"{assembly.Location}"
App Directory [AppContext.BaseDirectory]
"{AppContext.BaseDirectory}"
AppData Directory [SpecialFolder.ApplicationData]
"{appData}"
""";
// 1. Check portable mode
var appDir = Compat.AppCurrentDir;
var expectedPortableFile = Path.Combine(appDir, "Data", ".sm-portable");
var isPortableMode = File.Exists(expectedPortableFile);
DebugCompatInfo = $"""
Platform: {Compat.Platform}
AppData: {Compat.AppData}
AppDataHome: {Compat.AppDataHome}
AppCurrentDir: {Compat.AppCurrentDir}
ExecutableName: {Compat.GetExecutableName()}
-- Settings --
Expected Portable Marker file: {expectedPortableFile}
Portable Marker file exists: {isPortableMode}
IsLibraryDirSet = {settingsManager.IsLibraryDirSet}
IsPortableMode = {settingsManager.IsPortableMode}
""";
// Get Gpu info
var gpuInfo = "";
foreach (var (i, gpu) in HardwareHelper.IterGpuInfo().Enumerate())
{
gpuInfo += $"[{i + 1}] {gpu}\n";
}
DebugGpuInfo = gpuInfo;
}
// Debug buttons
[RelayCommand]
private void DebugNotification()
{
notificationService.Show(
new Notification(
title: "Test Notification",
message: "Here is some message",
type: NotificationType.Information
)
);
}
[RelayCommand]
private async Task DebugContentDialog()
{
var dialog = new ContentDialog
{
DefaultButton = ContentDialogButton.Primary,
Title = "Test title",
PrimaryButtonText = Resources.Action_OK,
CloseButtonText = Resources.Action_Close
};
var result = await dialog.ShowAsync();
notificationService.Show(new Notification("Content dialog closed", $"Result: {result}"));
}
[RelayCommand]
private void DebugThrowException()
{
throw new OperationCanceledException("Example Message");
}
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task DebugThrowAsyncException()
{
await Task.Yield();
throw new ApplicationException("Example Message");
}
[RelayCommand]
private async Task DebugMakeImageGrid()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(
new FilePickerOpenOptions() { AllowMultiple = true }
);
if (files.Count == 0)
return;
var images = await files.SelectAsync(
async f => SKImage.FromEncodedData(await f.OpenReadAsync())
);
var grid = ImageProcessor.CreateImageGrid(images.ToImmutableArray());
// Show preview
using var peekPixels = grid.PeekPixels();
using var data = peekPixels.Encode(SKEncodedImageFormat.Jpeg, 100);
await using var stream = data.AsStream();
var bitmap = WriteableBitmap.Decode(stream);
var galleryImages = new List<ImageSource> { new(bitmap), };
galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString())));
var imageBox = new ImageGalleryCard
{
Width = 1000,
Height = 900,
DataContext = dialogFactory.Get<ImageGalleryCardViewModel>(vm =>
{
vm.ImageSources.AddRange(galleryImages);
})
};
var dialog = new BetterContentDialog
{
MaxDialogWidth = 1000,
MaxDialogHeight = 1000,
FullSizeDesired = true,
Content = imageBox,
CloseButtonText = "Close",
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
};
await dialog.ShowAsync();
}
[RelayCommand]
private async Task DebugLoadCompletionCsv()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions());
if (files.Count == 0)
return;
await completionProvider.LoadFromFile(files[0].TryGetLocalPath()!, true);
notificationService.Show("Loaded completion file", "");
}
[RelayCommand]
private async Task DebugImageMetadata()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions());
if (files.Count == 0)
return;
var metadata = ImageMetadata.ParseFile(files[0].TryGetLocalPath()!);
var textualTags = metadata.GetTextualData()?.ToArray();
if (textualTags is null)
{
notificationService.Show("No textual data found", "");
return;
}
if (metadata.GetGenerationParameters() is { } parameters)
{
var parametersJson = JsonSerializer.Serialize(parameters);
var dialog = DialogHelper.CreateJsonDialog(parametersJson, "Generation Parameters");
await dialog.ShowAsync();
}
}
[RelayCommand]
private async Task DebugRefreshModelsIndex()
{
await modelIndexService.RefreshIndex();
}
[RelayCommand]
private async Task DebugTrackedDownload()
{
var textFields = new TextBoxField[]
{
new() { Label = "Url", },
new() { Label = "File path" }
};
var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields);
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
var url = textFields[0].Text;
var filePath = textFields[1].Text;
var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath));
download.Start();
}
}
#endregion
#region Info Section
public void OnVersionClick()
{
// Ignore if already enabled
if (SharedState.IsDebugMode)
return;
VersionTapCount++;
switch (VersionTapCount)
{
// Reached required threshold
case >= VersionTapCountThreshold:
{
IsVersionTapTeachingTipOpen = false;
// Enable debug options
SharedState.IsDebugMode = true;
notificationService.Show(
"Debug options enabled",
"Warning: Improper use may corrupt application state or cause loss of data."
);
VersionTapCount = 0;
break;
}
// Open teaching tip above 3rd click
case >= 3:
IsVersionTapTeachingTipOpen = true;
break;
}
}
[RelayCommand]
private async Task ShowLicensesDialog()
{
try
{
var markdown = GetLicensesMarkdown();
var dialog = DialogHelper.CreateMarkdownDialog(markdown, "Licenses");
dialog.MaxDialogHeight = 600;
await dialog.ShowAsync();
}
catch (Exception e)
{
notificationService.Show(
"Failed to read licenses information",
$"{e}",
NotificationType.Error
);
}
}
private static string GetLicensesMarkdown()
{
// Read licenses.json
using var reader = new StreamReader(Assets.LicensesJson.Open());
var licenses =
JsonSerializer.Deserialize<IReadOnlyList<LicenseInfo>>(reader.ReadToEnd())
?? throw new InvalidOperationException("Failed to read licenses.json");
// Generate markdown
var builder = new StringBuilder();
foreach (var license in licenses)
{
builder.AppendLine(
$"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}"
);
builder.AppendLine();
builder.AppendLine(license.Description);
builder.AppendLine();
builder.AppendLine($"[{license.LicenseUrl}]({license.LicenseUrl})");
builder.AppendLine();
}
return builder.ToString();
}
#endregion
}

236
StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs

@ -0,0 +1,236 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Exceptionless.DateTimeExtensions;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using Semver;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Settings;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models.Update;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels.Settings;
[View(typeof(UpdateSettingsPage))]
[Singleton, ManagedService]
public partial class UpdateSettingsViewModel : PageViewModelBase
{
private readonly IUpdateHelper updateHelper;
private readonly IAccountsService accountsService;
private readonly INavigationService<SettingsViewModel> settingsNavService;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsUpdateAvailable))]
[NotifyPropertyChangedFor(nameof(HeaderText))]
[NotifyPropertyChangedFor(nameof(SubtitleText))]
private UpdateStatusChangedEventArgs? updateStatus;
public bool IsUpdateAvailable => UpdateStatus?.LatestUpdate != null;
public string HeaderText =>
IsUpdateAvailable ? Resources.Label_UpdateAvailable : Resources.Label_YouAreUpToDate;
public string? SubtitleText =>
UpdateStatus is null
? null
: string.Format(
Resources.TextTemplate_LastChecked,
UpdateStatus.CheckedAt.ToApproximateAgeString()
);
[ObservableProperty]
private bool isAutoCheckUpdatesEnabled = true;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SelectedUpdateChannelCard))]
private UpdateChannel preferredUpdateChannel = UpdateChannel.Stable;
public UpdateChannelCard? SelectedUpdateChannelCard
{
get => AvailableUpdateChannelCards.First(c => c.UpdateChannel == PreferredUpdateChannel);
set => PreferredUpdateChannel = value?.UpdateChannel ?? UpdateChannel.Stable;
}
public IReadOnlyList<UpdateChannelCard> AvailableUpdateChannelCards { get; } =
new UpdateChannelCard[]
{
new()
{
UpdateChannel = UpdateChannel.Development,
Description = Resources.Label_UpdatesDevChannelDescription
},
new()
{
UpdateChannel = UpdateChannel.Preview,
Description = Resources.Label_UpdatesPreviewChannelDescription
},
new() { UpdateChannel = UpdateChannel.Stable }
};
public UpdateSettingsViewModel(
ISettingsManager settingsManager,
IUpdateHelper updateHelper,
IAccountsService accountsService,
INavigationService<SettingsViewModel> settingsNavService
)
{
this.updateHelper = updateHelper;
this.accountsService = accountsService;
this.settingsNavService = settingsNavService;
settingsManager.RelayPropertyFor(
this,
vm => vm.PreferredUpdateChannel,
settings => settings.PreferredUpdateChannel,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsAutoCheckUpdatesEnabled,
settings => settings.CheckForUpdates,
true
);
accountsService.LykosAccountStatusUpdate += (_, args) =>
{
var isBetaChannelsEnabled = args.User?.IsActiveSupporter == true;
foreach (
var card in AvailableUpdateChannelCards.Where(
c => c.UpdateChannel > UpdateChannel.Stable
)
)
{
card.IsSelectable = isBetaChannelsEnabled;
}
};
// On update status changed
updateHelper.UpdateStatusChanged += (_, args) =>
{
UpdateStatus = args;
};
}
/// <inheritdoc />
public override async Task OnLoadedAsync()
{
if (UpdateStatus is null)
{
await CheckForUpdates();
}
OnPropertyChanged(nameof(SubtitleText));
}
[RelayCommand]
private async Task CheckForUpdates()
{
if (Design.IsDesignMode)
{
return;
}
await updateHelper.CheckForUpdate();
}
/// <summary>
/// Verify a new channel selection is valid, else returns false.
/// </summary>
/// <param name="card"></param>
/// <returns></returns>
public bool VerifyChannelSelection(UpdateChannelCard card)
{
if (card.UpdateChannel == UpdateChannel.Stable)
{
return true;
}
if (accountsService.LykosStatus?.User?.IsActiveSupporter == true)
{
return true;
}
return false;
}
public void ShowLoginRequiredDialog()
{
Dispatcher.UIThread
.InvokeAsync(async () =>
{
var dialog = DialogHelper.CreateTaskDialog(
"Become a Supporter",
""
+ "Support the Stability Matrix Team and get access to early development builds and be the first to test new features. "
);
dialog.Buttons = new[]
{
new(Resources.Label_Accounts, TaskDialogStandardResult.OK),
TaskDialogButton.CloseButton
};
dialog.Commands = new[]
{
new TaskDialogCommand
{
Text = "Patreon",
Description = "https://patreon.com/StabilityMatrix",
Command = new RelayCommand(() =>
{
ProcessRunner.OpenUrl("https://patreon.com/StabilityMatrix");
})
}
};
if (await dialog.ShowAsync(true) is TaskDialogStandardResult.OK)
{
settingsNavService.NavigateTo<AccountSettingsViewModel>(
new SuppressNavigationTransitionInfo()
);
}
})
.SafeFireAndForget();
}
partial void OnUpdateStatusChanged(UpdateStatusChangedEventArgs? value)
{
// Update the update channel cards
// Use maximum version from platforms equal or lower than current
foreach (var card in AvailableUpdateChannelCards)
{
card.LatestVersion = value?.UpdateChannels
.Where(kv => kv.Key <= card.UpdateChannel)
.Select(kv => kv.Value)
.MaxBy(info => info.Version, SemVersion.PrecedenceComparer)
?.Version;
}
}
partial void OnPreferredUpdateChannelChanged(UpdateChannel value)
{
CheckForUpdatesCommand.ExecuteAsync(null).SafeFireAndForget();
}
/// <inheritdoc />
public override string Title => "Updates";
/// <inheritdoc />
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
}

910
StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs

@ -1,51 +1,13 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.Notifications;
using Avalonia.Controls.Primitives;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DynamicData.Binding;
using DynamicData;
using FluentAvalonia.UI.Controls;
using NLog;
using SkiaSharp;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Models.TagCompletion;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.Inference;
using StabilityMatrix.Avalonia.ViewModels.Settings;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -55,877 +17,49 @@ namespace StabilityMatrix.Avalonia.ViewModels;
[Singleton]
public partial class SettingsViewModel : PageViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly INotificationService notificationService;
private readonly ISettingsManager settingsManager;
private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly IPyRunner pyRunner;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ICompletionProvider completionProvider;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly IModelIndexService modelIndexService;
public SharedState SharedState { get; }
public override string Title => "Settings";
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
// ReSharper disable once MemberCanBeMadeStatic.Global
public string AppVersion =>
$"Version {Compat.AppVersion}" + (Program.IsDebugBuild ? " (Debug)" : "");
// Theme section
[ObservableProperty]
private string? selectedTheme;
public IReadOnlyList<string> AvailableThemes { get; } = new[] { "Light", "Dark", "System", };
[ObservableProperty]
private CultureInfo selectedLanguage;
// ReSharper disable once MemberCanBeMadeStatic.Global
public IReadOnlyList<CultureInfo> AvailableLanguages => Cultures.SupportedCultures;
public IReadOnlyList<float> AnimationScaleOptions { get; } =
new[] { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, };
[ObservableProperty]
private float selectedAnimationScale;
// Shared folder options
[ObservableProperty]
private bool removeSymlinksOnShutdown;
// Inference UI section
[ObservableProperty]
private bool isPromptCompletionEnabled = true;
[ObservableProperty]
private IReadOnlyList<string> availableTagCompletionCsvs = Array.Empty<string>();
[ObservableProperty]
private string? selectedTagCompletionCsv;
[ObservableProperty]
private bool isCompletionRemoveUnderscoresEnabled = true;
[ObservableProperty]
[CustomValidation(typeof(SettingsViewModel), nameof(ValidateOutputImageFileNameFormat))]
private string? outputImageFileNameFormat;
[ObservableProperty]
private string? outputImageFileNameFormatSample;
public IEnumerable<FileNameFormatVar> OutputImageFileNameFormatVars =>
FileNameFormatProvider
.GetSample()
.Substitutions.Select(
kv =>
new FileNameFormatVar
{
Variable = $"{{{kv.Key}}}",
Example = kv.Value.Invoke()
}
);
[ObservableProperty]
private bool isImageViewerPixelGridEnabled = true;
// Integrations section
[ObservableProperty]
private bool isDiscordRichPresenceEnabled;
// Debug section
[ObservableProperty]
private string? debugPaths;
public IReadOnlyList<PageViewModelBase> SubPages { get; }
[ObservableProperty]
private string? debugCompatInfo;
private ObservableCollection<PageViewModelBase> currentPagePath = new();
[ObservableProperty]
private string? debugGpuInfo;
private PageViewModelBase? currentPage;
// Info section
private const int VersionTapCountThreshold = 7;
[ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))]
private int versionTapCount;
[ObservableProperty]
private bool isVersionTapTeachingTipOpen;
public string VersionFlyoutText =>
$"You are {VersionTapCountThreshold - VersionTapCount} clicks away from enabling Debug options.";
public string DataDirectory =>
settingsManager.IsLibraryDirSet ? settingsManager.LibraryDir : "Not set";
public SettingsViewModel(
INotificationService notificationService,
ISettingsManager settingsManager,
IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner,
ServiceManager<ViewModelBase> dialogFactory,
ITrackedDownloadService trackedDownloadService,
SharedState sharedState,
ICompletionProvider completionProvider,
IModelIndexService modelIndexService
)
{
this.notificationService = notificationService;
this.settingsManager = settingsManager;
this.prerequisiteHelper = prerequisiteHelper;
this.pyRunner = pyRunner;
this.dialogFactory = dialogFactory;
this.trackedDownloadService = trackedDownloadService;
this.completionProvider = completionProvider;
this.modelIndexService = modelIndexService;
SharedState = sharedState;
SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1];
SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language);
RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown;
SelectedAnimationScale = settingsManager.Settings.AnimationScale;
settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsDiscordRichPresenceEnabled,
settings => settings.IsDiscordRichPresenceEnabled,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.SelectedAnimationScale,
settings => settings.AnimationScale
);
settingsManager.RelayPropertyFor(
this,
vm => vm.SelectedTagCompletionCsv,
settings => settings.TagCompletionCsv
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsPromptCompletionEnabled,
settings => settings.IsPromptCompletionEnabled,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsCompletionRemoveUnderscoresEnabled,
settings => settings.IsCompletionRemoveUnderscoresEnabled,
true
);
this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat)
.Throttle(TimeSpan.FromMilliseconds(50))
.Subscribe(formatProperty =>
{
var provider = FileNameFormatProvider.GetSample();
var template = formatProperty.Value ?? string.Empty;
if (
!string.IsNullOrEmpty(template)
&& provider.Validate(template) == ValidationResult.Success
)
{
var format = FileNameFormat.Parse(template, provider);
OutputImageFileNameFormatSample = format.GetFileName() + ".png";
}
else
{
// Use default format if empty
var defaultFormat = FileNameFormat.Parse(
FileNameFormat.DefaultTemplate,
provider
);
OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png";
}
});
settingsManager.RelayPropertyFor(
this,
vm => vm.OutputImageFileNameFormat,
settings => settings.InferenceOutputImageFileNameFormat,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsImageViewerPixelGridEnabled,
settings => settings.IsImageViewerPixelGridEnabled,
true
);
DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler(
notificationService,
LogLevel.Warn
);
ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn);
}
/// <inheritdoc />
public override async Task OnLoadedAsync()
{
await base.OnLoadedAsync();
await notificationService.TryAsync(completionProvider.Setup());
UpdateAvailableTagCompletionCsvs();
}
public static ValidationResult ValidateOutputImageFileNameFormat(
string? format,
ValidationContext context
)
{
return FileNameFormatProvider.GetSample().Validate(format ?? string.Empty);
}
partial void OnSelectedThemeChanged(string? value)
public SettingsViewModel(ServiceManager<ViewModelBase> vmFactory)
{
// In case design / tests
if (Application.Current is null)
return;
// Change theme
Application.Current.RequestedThemeVariant = value switch
SubPages = new PageViewModelBase[]
{
"Dark" => ThemeVariant.Dark,
"Light" => ThemeVariant.Light,
_ => ThemeVariant.Default
vmFactory.Get<MainSettingsViewModel>(),
vmFactory.Get<InferenceSettingsViewModel>(),
vmFactory.Get<AccountSettingsViewModel>(),
vmFactory.Get<UpdateSettingsViewModel>()
};
}
partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newValue)
{
if (oldValue is null || newValue.Name == Cultures.Current?.Name)
return;
// Set locale
if (AvailableLanguages.Contains(newValue))
{
Logger.Info("Changing language from {Old} to {New}", oldValue, newValue);
Cultures.TrySetSupportedCulture(newValue);
settingsManager.Transaction(s => s.Language = newValue.Name);
var dialog = new BetterContentDialog
{
Title = Resources.Label_RelaunchRequired,
Content = Resources.Text_RelaunchRequiredToApplyLanguage,
DefaultButton = ContentDialogButton.Primary,
PrimaryButtonText = Resources.Action_Relaunch,
CloseButtonText = Resources.Action_RelaunchLater
};
CurrentPagePath.AddRange(SubPages);
Dispatcher.UIThread.InvokeAsync(async () =>
{
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
});
}
else
{
Logger.Info(
"Requested invalid language change from {Old} to {New}",
oldValue,
newValue
);
}
CurrentPage = SubPages[0];
}
partial void OnRemoveSymlinksOnShutdownChanged(bool value)
partial void OnCurrentPageChanged(PageViewModelBase? value)
{
settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value);
}
public async Task ResetCheckpointCache()
{
settingsManager.Transaction(s => s.InstalledModelHashes = new HashSet<string>());
await Task.Run(() => settingsManager.IndexCheckpoints());
notificationService.Show(
"Checkpoint cache reset",
"The checkpoint cache has been reset.",
NotificationType.Success
);
}
#region Package Environment
[RelayCommand]
private async Task OpenEnvVarsDialog()
{
var viewModel = dialogFactory.Get<EnvVarsViewModel>();
// Load current settings
var current =
settingsManager.Settings.EnvironmentVariables ?? new Dictionary<string, string>();
viewModel.EnvVars = new ObservableCollection<EnvVarKeyPair>(
current.Select(kvp => new EnvVarKeyPair(kvp.Key, kvp.Value))
);
var dialog = new BetterContentDialog
{
Content = new EnvVarsDialog { DataContext = viewModel },
PrimaryButtonText = Resources.Action_Save,
IsPrimaryButtonEnabled = true,
CloseButtonText = Resources.Action_Cancel,
};
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
// Save settings
var newEnvVars = viewModel.EnvVars
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
.GroupBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal);
settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars);
}
}
[RelayCommand]
private async Task CheckPythonVersion()
{
var isInstalled = prerequisiteHelper.IsPythonInstalled;
Logger.Debug($"Check python installed: {isInstalled}");
// Ensure python installed
if (!prerequisiteHelper.IsPythonInstalled)
{
// Need 7z as well for site packages repack
Logger.Debug("Python not installed, unpacking resources...");
await prerequisiteHelper.UnpackResourcesIfNecessary();
Logger.Debug("Unpacked resources, installing python...");
await prerequisiteHelper.InstallPythonIfNecessary();
}
// Get python version
await pyRunner.Initialize();
var result = await pyRunner.GetVersionInfo();
// Show dialog box
var dialog = new ContentDialog
if (value is null)
{
Title = Resources.Label_PythonVersionInfo,
Content = result,
PrimaryButtonText = Resources.Action_OK,
IsPrimaryButtonEnabled = true
};
await dialog.ShowAsync();
}
#endregion
#region Inference UI
private void UpdateAvailableTagCompletionCsvs()
{
if (!settingsManager.IsLibraryDirSet)
return;
var tagsDir = settingsManager.TagsDirectory;
if (!tagsDir.Exists)
return;
var csvFiles = tagsDir.Info.EnumerateFiles("*.csv");
AvailableTagCompletionCsvs = csvFiles.Select(f => f.Name).ToImmutableArray();
// Set selected to current if exists
var settingsCsv = settingsManager.Settings.TagCompletionCsv;
if (settingsCsv is not null && AvailableTagCompletionCsvs.Contains(settingsCsv))
{
SelectedTagCompletionCsv = settingsCsv;
}
}
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task ImportTagCsv()
{
var storage = App.StorageProvider;
var files = await storage.OpenFilePickerAsync(
new FilePickerOpenOptions
{
FileTypeFilter = new List<FilePickerFileType>
{
new("CSV") { Patterns = new[] { "*.csv" }, }
}
}
);
if (files.Count == 0)
return;
var sourceFile = new FilePath(files[0].TryGetLocalPath()!);
var tagsDir = settingsManager.TagsDirectory;
tagsDir.Create();
// Copy to tags directory
var targetFile = tagsDir.JoinFile(sourceFile.Name);
await sourceFile.CopyToAsync(targetFile);
// Update index
UpdateAvailableTagCompletionCsvs();
// Trigger load
completionProvider.BackgroundLoadFromFile(targetFile, true);
notificationService.Show(
$"Imported {sourceFile.Name}",
$"The {sourceFile.Name} file has been imported.",
NotificationType.Success
);
}
#endregion
#region System
/// <summary>
/// Adds Stability Matrix to Start Menu for the current user.
/// </summary>
[RelayCommand]
private async Task AddToStartMenu()
{
if (!Compat.IsWindows)
if (value is MainSettingsViewModel)
{
notificationService.Show("Not supported", "This feature is only supported on Windows.");
return;
CurrentPagePath.Clear();
CurrentPagePath.Add(value);
}
await using var _ = new MinimumDelay(200, 300);
var shortcutDir = new DirectoryPath(
Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),
"Programs"
);
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk");
var appPath = Compat.AppCurrentPath;
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico");
await Assets.AppIcon.ExtractTo(iconPath);
WindowsShortcuts.CreateShortcut(shortcutLink, appPath, iconPath, "Stability Matrix");
notificationService.Show(
"Added to Start Menu",
"Stability Matrix has been added to the Start Menu.",
NotificationType.Success
);
}
/// <summary>
/// Add Stability Matrix to Start Menu for all users.
/// <remarks>Requires Admin elevation.</remarks>
/// </summary>
[RelayCommand]
private async Task AddToGlobalStartMenu()
{
if (!Compat.IsWindows)
{
notificationService.Show("Not supported", "This feature is only supported on Windows.");
return;
}
// Confirmation dialog
var dialog = new BetterContentDialog
{
Title =
"This will create a shortcut for Stability Matrix in the Start Menu for all users",
Content = "You will be prompted for administrator privileges. Continue?",
PrimaryButtonText = Resources.Action_Yes,
CloseButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Primary
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
await using var _ = new MinimumDelay(200, 300);
var shortcutDir = new DirectoryPath(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs"
);
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk");
var appPath = Compat.AppCurrentPath;
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico");
// We can't directly write to the targets, so extract to temporary directory first
using var tempDir = new TempDirectoryPath();
await Assets.AppIcon.ExtractTo(tempDir.JoinFile("Stability Matrix.ico"));
WindowsShortcuts.CreateShortcut(
tempDir.JoinFile("Stability Matrix.lnk"),
appPath,
iconPath,
"Stability Matrix"
);
// Move to target
try
{
var moveLinkResult = await WindowsElevated.MoveFiles(
(tempDir.JoinFile("Stability Matrix.lnk"), shortcutLink),
(tempDir.JoinFile("Stability Matrix.ico"), iconPath)
);
if (moveLinkResult != 0)
{
notificationService.ShowPersistent(
"Failed to create shortcut",
$"Could not copy shortcut",
NotificationType.Error
);
}
}
catch (Win32Exception e)
{
// We'll get this exception if user cancels UAC
Logger.Warn(e, "Could not create shortcut");
notificationService.Show("Could not create shortcut", "", NotificationType.Warning);
return;
}
notificationService.Show(
"Added to Start Menu",
"Stability Matrix has been added to the Start Menu for all users.",
NotificationType.Success
);
}
public async Task PickNewDataDirectory()
{
var viewModel = dialogFactory.Get<SelectDataDirectoryViewModel>();
var dialog = new BetterContentDialog
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new SelectDataDirectoryDialog { DataContext = viewModel }
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// 1. For portable mode, call settings.SetPortableMode()
if (viewModel.IsPortableMode)
{
settingsManager.SetPortableMode();
}
// 2. For custom path, call settings.SetLibraryPath(path)
else
{
settingsManager.SetLibraryPath(viewModel.DataDirectory);
}
// Restart
var restartDialog = new BetterContentDialog
{
Title = "Restart required",
Content = "Stability Matrix must be restarted for the changes to take effect.",
PrimaryButtonText = Resources.Action_Restart,
DefaultButton = ContentDialogButton.Primary,
IsSecondaryButtonEnabled = false,
};
await restartDialog.ShowAsync();
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
}
#endregion
#region Debug Section
public void LoadDebugInfo()
{
var assembly = Assembly.GetExecutingAssembly();
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
DebugPaths = $"""
Current Working Directory [Environment.CurrentDirectory]
"{Environment.CurrentDirectory}"
App Directory [Assembly.GetExecutingAssembly().Location]
"{assembly.Location}"
App Directory [AppContext.BaseDirectory]
"{AppContext.BaseDirectory}"
AppData Directory [SpecialFolder.ApplicationData]
"{appData}"
""";
// 1. Check portable mode
var appDir = Compat.AppCurrentDir;
var expectedPortableFile = Path.Combine(appDir, "Data", ".sm-portable");
var isPortableMode = File.Exists(expectedPortableFile);
DebugCompatInfo = $"""
Platform: {Compat.Platform}
AppData: {Compat.AppData}
AppDataHome: {Compat.AppDataHome}
AppCurrentDir: {Compat.AppCurrentDir}
ExecutableName: {Compat.GetExecutableName()}
-- Settings --
Expected Portable Marker file: {expectedPortableFile}
Portable Marker file exists: {isPortableMode}
IsLibraryDirSet = {settingsManager.IsLibraryDirSet}
IsPortableMode = {settingsManager.IsPortableMode}
""";
// Get Gpu info
var gpuInfo = "";
foreach (var (i, gpu) in HardwareHelper.IterGpuInfo().Enumerate())
{
gpuInfo += $"[{i + 1}] {gpu}\n";
}
DebugGpuInfo = gpuInfo;
}
// Debug buttons
[RelayCommand]
private void DebugNotification()
{
notificationService.Show(
new Notification(
title: "Test Notification",
message: "Here is some message",
type: NotificationType.Information
)
);
}
[RelayCommand]
private async Task DebugContentDialog()
{
var dialog = new ContentDialog
{
DefaultButton = ContentDialogButton.Primary,
Title = "Test title",
PrimaryButtonText = Resources.Action_OK,
CloseButtonText = Resources.Action_Close
};
var result = await dialog.ShowAsync();
notificationService.Show(new Notification("Content dialog closed", $"Result: {result}"));
}
[RelayCommand]
private void DebugThrowException()
{
throw new OperationCanceledException("Example Message");
}
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task DebugThrowAsyncException()
{
await Task.Yield();
throw new ApplicationException("Example Message");
}
[RelayCommand]
private async Task DebugMakeImageGrid()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(
new FilePickerOpenOptions() { AllowMultiple = true }
);
if (files.Count == 0)
return;
var images = await files.SelectAsync(
async f => SKImage.FromEncodedData(await f.OpenReadAsync())
);
var grid = ImageProcessor.CreateImageGrid(images.ToImmutableArray());
// Show preview
using var peekPixels = grid.PeekPixels();
using var data = peekPixels.Encode(SKEncodedImageFormat.Jpeg, 100);
await using var stream = data.AsStream();
var bitmap = WriteableBitmap.Decode(stream);
var galleryImages = new List<ImageSource> { new(bitmap), };
galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString())));
var imageBox = new ImageGalleryCard
{
Width = 1000,
Height = 900,
DataContext = dialogFactory.Get<ImageGalleryCardViewModel>(vm =>
{
vm.ImageSources.AddRange(galleryImages);
})
};
var dialog = new BetterContentDialog
{
MaxDialogWidth = 1000,
MaxDialogHeight = 1000,
FullSizeDesired = true,
Content = imageBox,
CloseButtonText = "Close",
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
};
await dialog.ShowAsync();
}
[RelayCommand]
private async Task DebugLoadCompletionCsv()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions());
if (files.Count == 0)
return;
await completionProvider.LoadFromFile(files[0].TryGetLocalPath()!, true);
notificationService.Show("Loaded completion file", "");
}
[RelayCommand]
private async Task DebugImageMetadata()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions());
if (files.Count == 0)
return;
var metadata = ImageMetadata.ParseFile(files[0].TryGetLocalPath()!);
var textualTags = metadata.GetTextualData()?.ToArray();
if (textualTags is null)
{
notificationService.Show("No textual data found", "");
return;
}
if (metadata.GetGenerationParameters() is { } parameters)
{
var parametersJson = JsonSerializer.Serialize(parameters);
var dialog = DialogHelper.CreateJsonDialog(parametersJson, "Generation Parameters");
await dialog.ShowAsync();
}
}
[RelayCommand]
private async Task DebugRefreshModelsIndex()
{
await modelIndexService.RefreshIndex();
}
[RelayCommand]
private async Task DebugTrackedDownload()
{
var textFields = new TextBoxField[]
{
new() { Label = "Url", },
new() { Label = "File path" }
};
var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields);
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
var url = textFields[0].Text;
var filePath = textFields[1].Text;
var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath));
download.Start();
}
}
#endregion
#region Info Section
public void OnVersionClick()
{
// Ignore if already enabled
if (SharedState.IsDebugMode)
return;
VersionTapCount++;
switch (VersionTapCount)
{
// Reached required threshold
case >= VersionTapCountThreshold:
{
IsVersionTapTeachingTipOpen = false;
// Enable debug options
SharedState.IsDebugMode = true;
notificationService.Show(
"Debug options enabled",
"Warning: Improper use may corrupt application state or cause loss of data."
);
VersionTapCount = 0;
break;
}
// Open teaching tip above 3rd click
case >= 3:
IsVersionTapTeachingTipOpen = true;
break;
}
}
[RelayCommand]
private async Task ShowLicensesDialog()
{
try
{
var markdown = GetLicensesMarkdown();
var dialog = DialogHelper.CreateMarkdownDialog(markdown, "Licenses");
dialog.MaxDialogHeight = 600;
await dialog.ShowAsync();
}
catch (Exception e)
{
notificationService.Show(
"Failed to read licenses information",
$"{e}",
NotificationType.Error
);
}
}
private static string GetLicensesMarkdown()
{
// Read licenses.json
using var reader = new StreamReader(Assets.LicensesJson.Open());
var licenses =
JsonSerializer.Deserialize<IReadOnlyList<LicenseInfo>>(reader.ReadToEnd())
?? throw new InvalidOperationException("Failed to read licenses.json");
// Generate markdown
var builder = new StringBuilder();
foreach (var license in licenses)
else
{
builder.AppendLine(
$"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}"
);
builder.AppendLine();
builder.AppendLine(license.Description);
builder.AppendLine();
builder.AppendLine($"[{license.LicenseUrl}]({license.LicenseUrl})");
builder.AppendLine();
CurrentPagePath.Clear();
CurrentPagePath.AddRange(new[] { SubPages[0], value });
}
return builder.ToString();
}
#endregion
}

533
StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml

@ -1,300 +1,402 @@
<UserControl 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:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:designData="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointManager"
xmlns:checkpointBrowser="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser"
xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="700"
x:DataType="viewModels:CheckpointBrowserViewModel"
d:DataContext="{x:Static designData:DesignData.CheckpointBrowserViewModel}"
x:CompileBindings="True"
x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage">
<UserControl
x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:checkpointBrowser="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:designData="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointManager"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
d:DataContext="{x:Static designData:DesignData.CheckpointBrowserViewModel}"
d:DesignHeight="700"
d:DesignWidth="800"
x:CompileBindings="True"
x:DataType="viewModels:CheckpointBrowserViewModel"
mc:Ignorable="d">
<UserControl.Styles>
<Style Selector="Border#HoverBorder">
<Setter Property="Transitions">
<Transitions>
<BoxShadowsTransition Property="BoxShadow" Duration="0:0:0.237"/>
</Transitions>
</Setter>
<Style Selector="^ asyncImageLoader|AdvancedImage">
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform"
Duration="0:0:0.237">
<TransformOperationsTransition.Easing>
<QuadraticEaseInOut/>
</TransformOperationsTransition.Easing>
</TransformOperationsTransition>
</Transitions>
</Setter>
</Style>
<Style Selector="^:pointerover">
<Setter Property="BoxShadow" Value="0 0 40 0 #60000000"/>
<Setter Property="Cursor" Value="Hand" />
<Style Selector="^ asyncImageLoader|AdvancedImage">
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="RenderTransform" Value="scale(1.03, 1.03)"/>
</Style>
<Style Selector="^ Border#ModelCardBottom">
<Setter Property="Background" Value="#CC000000" />
</Style>
</Style>
<Style Selector="^:not(:pointerover)">
<Setter Property="BoxShadow" Value="0 0 20 0 #60000000"/>
<Setter Property="Cursor" Value="Arrow" />
<Style Selector="^ asyncImageLoader|AdvancedImage">
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="RenderTransform" Value="scale(1, 1)"/>
</Style>
<Style Selector="^ Border#ModelCardBottom">
<Setter Property="Background" Value="#99000000" />
</Style>
</Style>
</Style>
</UserControl.Styles>
<UserControl.Resources>
<DataTemplate DataType="{x:Type checkpointBrowser:CheckpointBrowserCardViewModel}" x:Key="CivitModelTemplate">
<controls:Card
<converters:KiloFormatterStringConverter x:Key="KiloFormatterConverter"/>
<DataTemplate x:Key="CivitModelTemplate" DataType="{x:Type checkpointBrowser:CheckpointBrowserCardViewModel}">
<Border
Name="HoverBorder"
Padding="0"
BorderThickness="0"
Margin="8"
MaxHeight="450"
CornerRadius="12"
Name="ModelCard"
Width="330">
<StackPanel Orientation="Vertical">
<Grid ColumnDefinitions="*, Auto"
Margin="0,-4,0,0">
<TextBlock
Foreground="{DynamicResource TextControlForeground}"
Text="{Binding CivitModel.Name}"
VerticalAlignment="Center" />
<Button
Grid.Column="1"
HorizontalContentAlignment="Right"
HorizontalAlignment="Right"
VerticalContentAlignment="Top"
VerticalAlignment="Top"
Padding="4,2"
Margin="4,0,0,0"
Classes="transparent"
Width="24"
BorderThickness="0">
<ui:SymbolIcon FontSize="18" Symbol="MoreVertical" />
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="{x:Static lang:Resources.Action_OpenOnCivitAi}"
Command="{Binding OpenModelCommand}">
<MenuItem.Icon>
<ui:SymbolIcon Symbol="Open" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
<TextBlock
FontSize="11"
Foreground="{DynamicResource TextControlForeground}"
Margin="0,2,0,0"
Text="{Binding CivitModel.LatestModelVersionName, FallbackValue=''}"
VerticalAlignment="Center" />
<Grid>
ClipToBounds="True"
CornerRadius="8">
<Button
Name="ModelCard"
Classes="transparent-full"
Padding="0"
BorderThickness="0"
VerticalContentAlignment="Top"
CornerRadius="8"
Command="{Binding ShowVersionDialogCommand}"
CommandParameter="{Binding CivitModel}"
IsEnabled="{Binding !IsImporting}">
<Grid RowDefinitions="*, Auto">
<controls:BetterAdvancedImage
Margin="0,8,0,8"
Height="300"
Width="300"
StretchDirection="Both"
Grid.RowSpan="2"
CornerRadius="8"
VerticalContentAlignment="Top"
HorizontalContentAlignment="Center"
Width="330"
Height="400"
Source="{Binding CardImage}"
Stretch="UniformToFill" />
Stretch="UniformToFill"
StretchDirection="Both"/>
<Button
Command="{Binding ToggleFavoriteCommand}"
<StackPanel
Grid.Row="0"
HorizontalAlignment="Right"
Margin="0,16,8,0"
FontSize="20"
Classes="info"
IsVisible="{Binding !IsFavorite}"
VerticalAlignment="Top">
Orientation="Horizontal">
<Button
Margin="0,8,8,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Classes="transparent-info"
Command="{Binding ToggleFavoriteCommand}"
FontSize="20"
IsVisible="{Binding !IsFavorite}">
<Grid>
<ui:SymbolIcon Symbol="StarAdd" />
</Grid>
</Button>
<Button
Command="{Binding ToggleFavoriteCommand}"
HorizontalAlignment="Right"
Margin="0,16,8,0"
FontSize="20"
Classes="success"
IsVisible="{Binding IsFavorite}"
VerticalAlignment="Top">
<Grid>
<ui:SymbolIcon Symbol="StarFilled" />
</Grid>
</Button>
<StackPanel Orientation="Horizontal">
</Button>
<Button
Margin="0,8,8,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Classes="success"
Command="{Binding ToggleFavoriteCommand}"
FontSize="20"
IsVisible="{Binding IsFavorite}">
<Grid>
<ui:SymbolIcon Symbol="StarFilled" />
</Grid>
</Button>
</StackPanel>
<StackPanel Grid.Row="0" Orientation="Horizontal">
<controls:Card
Classes="info"
Height="24"
HorizontalAlignment="Left"
Margin="8,8,0,0"
Padding="4"
Margin="4,16,0,0"
VerticalAlignment="Top">
HorizontalAlignment="Left"
VerticalAlignment="Top"
Classes="info">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="11"
FontWeight="Medium"
HorizontalAlignment="Center"
Text="{Binding CivitModel.Type}"
VerticalAlignment="Center" />
Text="{Binding CivitModel.Type}" />
</controls:Card>
<controls:Card
Classes="info"
Height="24"
HorizontalAlignment="Left"
Margin="4,8,0,0"
Padding="4"
Margin="4,16,0,0"
VerticalAlignment="Top">
HorizontalAlignment="Left"
VerticalAlignment="Top"
Classes="info">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="11"
FontWeight="Medium"
HorizontalAlignment="Center"
Text="{Binding CivitModel.BaseModelType}"
VerticalAlignment="Center" />
Text="{Binding CivitModel.BaseModelType}" />
</controls:Card>
<controls:Card
Classes="success"
Height="24"
HorizontalAlignment="Left"
Margin="4,8,0,0"
Padding="4"
IsVisible="{Binding ShowUpdateCard}"
Margin="4,16,0,0"
VerticalAlignment="Top">
HorizontalAlignment="Left"
VerticalAlignment="Top"
Classes="success"
IsVisible="{Binding ShowUpdateCard}">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="11"
FontWeight="Medium"
HorizontalAlignment="Center"
Text="{Binding UpdateCardText}"
VerticalAlignment="Center" />
Text="{Binding UpdateCardText}" />
</controls:Card>
</StackPanel>
<Rectangle
Fill="#DD000000"
RadiusX="8"
RadiusY="8"
<Border
Grid.Row="0"
Grid.RowSpan="2"
Margin="0,0,0,0"
HorizontalAlignment="Stretch"
Margin="0,8,0,8"
VerticalAlignment="Stretch"
IsVisible="{Binding IsImporting}" />
Background="#DD000000"
CornerRadius="8"
IsVisible="{Binding IsImporting}"
ZIndex="1" />
<StackPanel
Grid.Row="0"
Grid.RowSpan="2"
HorizontalAlignment="Stretch"
Orientation="Vertical"
VerticalAlignment="Center"
IsVisible="{Binding IsImporting}">
IsVisible="{Binding IsImporting}"
Orientation="Vertical"
ZIndex="2">
<controls:ProgressRing
HorizontalAlignment="Center"
IsIndeterminate="False"
Width="120"
Height="120"
StartAngle="90"
HorizontalAlignment="Center"
VerticalAlignment="Center"
EndAngle="450"
Value="{Binding Value}"
VerticalAlignment="Center" />
IsIndeterminate="False"
StartAngle="90"
Value="{Binding Value}" />
<TextBlock
HorizontalAlignment="Center"
Margin="0,8,0,0"
Text="{Binding Text, TargetNullValue=Importing...}"
VerticalAlignment="Center" />
HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="{Binding Text, TargetNullValue=Importing...}" />
</StackPanel>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button
Classes="accent"
Margin="0,8,0,0"
Command="{Binding ImportCommand}"
CommandParameter="{Binding CivitModel}"
IsEnabled="{Binding !IsImporting}"
CornerRadius="6"
HorizontalAlignment="Stretch">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{x:Static lang:Resources.Label_ImportLatest}" />
<TextBlock Margin="4,0,0,0"
Text="{Binding CivitModel.FullFilesSize}" />
<Border
Name="ModelCardBottom"
Grid.Row="1">
<Grid ColumnDefinitions="*, Auto" RowDefinitions="Auto, Auto, Auto">
<TextBlock
Grid.ColumnSpan="2"
MaxWidth="250"
Margin="8,0,8,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="{DynamicResource TextControlForeground}"
LetterSpacing="0.33"
Text="{Binding CivitModel.Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
ToolTip.Tip="{Binding CivitModel.Name}" />
<TextBlock
Grid.Row="1"
Grid.Column="0"
Margin="8,-4,0,0"
VerticalAlignment="Center"
FontSize="11"
Foreground="{DynamicResource TextControlForeground}"
Text="{Binding CivitModel.LatestModelVersionName, FallbackValue=''}" />
<StackPanel
Grid.Row="2"
Grid.Column="0"
Orientation="Horizontal">
<controls:StarsRating
Margin="8,8,0,8"
Background="#66000000"
FontSize="16"
Foreground="{DynamicResource ThemeEldenRingOrangeColor}"
Value="{Binding CivitModel.Stats.Rating}" />
<TextBlock
Margin="4,0,0,0"
VerticalAlignment="Center"
Text="{Binding CivitModel.Stats.RatingCount}"
TextAlignment="Center" />
</StackPanel>
</Button.Content>
</Button>
<Button Grid.Column="1"
CornerRadius="6"
Margin="8,8,0,0"
Classes="accent"
IsEnabled="{Binding !IsImporting}"
Command="{Binding ShowVersionDialogCommand}"
CommandParameter="{Binding CivitModel}"
Content="{x:Static lang:Resources.Label_AllVersions}" />
<StackPanel
Grid.Row="2"
Grid.Column="1"
HorizontalAlignment="Right"
Orientation="Horizontal">
<avalonia:Icon Value="fa-solid fa-heart" />
<TextBlock
Margin="4,0"
VerticalAlignment="Center"
Text="{Binding CivitModel.Stats.FavoriteCount, Converter={StaticResource KiloFormatterConverter}}" />
<avalonia:Icon Margin="4,0" Value="fa-solid fa-download" />
<TextBlock
Margin="0,0,4,0"
VerticalAlignment="Center"
Text="{Binding CivitModel.Stats.DownloadCount, Converter={StaticResource KiloFormatterConverter}}" />
</StackPanel>
<Button
Grid.Row="0"
Grid.Column="1"
Width="32"
Margin="0,4,4,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
HorizontalContentAlignment="Right"
VerticalContentAlignment="Top"
BorderThickness="0"
Classes="transparent">
<ui:SymbolIcon FontSize="18" Symbol="MoreVertical" />
<Button.Flyout>
<MenuFlyout>
<MenuItem Command="{Binding OpenModelCommand}" Header="{x:Static lang:Resources.Action_OpenOnCivitAi}">
<MenuItem.Icon>
<ui:SymbolIcon Symbol="Open" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
</Border>
</Grid>
</StackPanel>
</controls:Card>
</Button>
</Border>
</DataTemplate>
</UserControl.Resources>
<Grid RowDefinitions="Auto,*,Auto" Margin="0,8,0,0">
<Grid Margin="0,8,0,0" RowDefinitions="Auto,*,Auto">
<StackPanel Margin="8" Orientation="Vertical">
<Grid ColumnDefinitions="*,Auto">
<TextBox
HorizontalAlignment="Stretch"
Margin="8,0,0,0"
Watermark="{x:Static lang:Resources.Label_ModelSearchWatermark}"
Text="{Binding SearchQuery, Mode=TwoWay}" />
HorizontalAlignment="Stretch"
KeyDown="InputElement_OnKeyDown"
Text="{Binding SearchQuery, Mode=TwoWay}"
Watermark="{x:Static lang:Resources.Label_ModelSearchWatermark}">
<TextBox.InnerRightContent>
<Button
Classes="transparent-full"
Command="{Binding ClearSearchQuery}"
IsVisible="{Binding SearchQuery.Length}">
<ui:SymbolIcon Symbol="Cancel" />
</Button>
</TextBox.InnerRightContent>
</TextBox>
<Button
Classes="accent"
Command="{Binding SearchModelsCommand}"
Grid.Column="1"
IsDefault="True"
Width="80"
Margin="8,0,8,0"
VerticalAlignment="Stretch"
Width="80">
Classes="accent"
Command="{Binding SearchModelsCommand}"
IsDefault="True">
<Grid>
<controls:ProgressRing
MinWidth="16"
MinHeight="16"
IsIndeterminate="True"
VerticalAlignment="Center"
BorderThickness="4"
IsVisible="{Binding SearchModelsCommand.IsRunning}"
MinWidth="16" />
IsIndeterminate="True"
IsVisible="{Binding SearchModelsCommand.IsRunning}" />
<TextBlock
Text="{x:Static lang:Resources.Action_Search}"
VerticalAlignment="Center"
IsVisible="{Binding !SearchModelsCommand.IsRunning}" />
IsVisible="{Binding !SearchModelsCommand.IsRunning}"
Text="{x:Static lang:Resources.Action_Search}" />
</Grid>
</Button>
</Grid>
<DockPanel>
<StackPanel Margin="8, 8,4,8" Orientation="Vertical">
<StackPanel Margin="8,8,4,8" Orientation="Vertical">
<Label Content="{x:Static lang:Resources.Label_Sort}" />
<ComboBox
ItemsSource="{Binding AllSortModes}"
MinWidth="100"
ItemsSource="{Binding AllSortModes}"
SelectedItem="{Binding SortMode}" />
</StackPanel>
<StackPanel Margin="4, 8" Orientation="Vertical">
<StackPanel Margin="4,8" Orientation="Vertical">
<Label Content="{x:Static lang:Resources.Label_TimePeriod}" />
<ComboBox
ItemsSource="{Binding AllCivitPeriods}"
MinWidth="100"
ItemsSource="{Binding AllCivitPeriods}"
SelectedItem="{Binding SelectedPeriod}" />
</StackPanel>
<StackPanel Margin="4, 8" Orientation="Vertical">
<StackPanel Margin="4,8" Orientation="Vertical">
<Label Content="{x:Static lang:Resources.Label_ModelType}" />
<ComboBox
ItemsSource="{Binding AllModelTypes}"
MinWidth="100"
ItemsSource="{Binding AllModelTypes}"
SelectedItem="{Binding SelectedModelType}" />
</StackPanel>
<StackPanel Margin="4, 8" Orientation="Vertical">
<StackPanel Margin="4,8" Orientation="Vertical">
<Label Content="{x:Static lang:Resources.Label_BaseModel}" />
<ComboBox
ItemsSource="{Binding BaseModelOptions}"
MinWidth="100"
ItemsSource="{Binding BaseModelOptions}"
SelectedItem="{Binding SelectedBaseModelType}" />
</StackPanel>
<CheckBox
Content="{x:Static lang:Resources.Label_ShowNsfwContent}"
Margin="8,8,8,0"
HorizontalAlignment="Right"
IsChecked="{Binding ShowNsfw, Mode=TwoWay}"
Margin="8,8,8,0" />
Content="{x:Static lang:Resources.Label_ShowNsfwContent}"
IsChecked="{Binding ShowNsfw, Mode=TwoWay}" />
</DockPanel>
</StackPanel>
<ScrollViewer Grid.Row="1"
Margin="8,0,8,0">
<ItemsRepeater ItemTemplate="{StaticResource CivitModelTemplate}"
ItemsSource="{Binding ModelCards}">
<ScrollViewer
Grid.Row="1"
Margin="8,0,8,0"
ScrollChanged="ScrollViewer_OnScrollChanged">
<ItemsRepeater ItemTemplate="{StaticResource CivitModelTemplate}" ItemsSource="{Binding ModelCards}">
<ItemsRepeater.Layout>
<UniformGridLayout Orientation="Horizontal" />
</ItemsRepeater.Layout>
@ -304,15 +406,15 @@
<TextBlock
Grid.Row="2"
Margin="16,8"
Text="{x:Static lang:Resources.Label_DataProvidedByCivitAi}"
VerticalAlignment="Bottom" />
VerticalAlignment="Bottom"
Text="{x:Static lang:Resources.Label_DataProvidedByCivitAi}" />
<StackPanel
Grid.Row="2"
HorizontalAlignment="Center"
Margin="8"
Orientation="Vertical"
IsVisible="{Binding HasSearched}">
HorizontalAlignment="Center"
IsVisible="{Binding HasSearched}"
Orientation="Vertical">
<TextBlock Margin="0,0,4,4" TextAlignment="Center">
<Run Text="{x:Static lang:Resources.Label_Page}" />
<Run Text="{Binding CurrentPageNumber, FallbackValue=1}" />
@ -321,30 +423,31 @@
</TextBlock>
<StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
<Button
Margin="0,0,8,0"
Command="{Binding FirstPage}"
IsEnabled="{Binding CanGoToFirstPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_FirstPage}"
Margin="0,0,8,0">
<avalonia:Icon Value="fa-solid fa-backward-fast"/>
ToolTip.Tip="{x:Static lang:Resources.Label_FirstPage}">
<avalonia:Icon Value="fa-solid fa-backward-fast" />
</Button>
<Button
Margin="0,0,8,0"
Command="{Binding PreviousPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_PreviousPage}"
IsEnabled="{Binding CanGoToPreviousPage}"
Margin="0,0,8,0">
<avalonia:Icon Value="fa-solid fa-caret-left"/>
ToolTip.Tip="{x:Static lang:Resources.Label_PreviousPage}">
<avalonia:Icon Value="fa-solid fa-caret-left" />
</Button>
<Button Command="{Binding NextPage}"
IsEnabled="{Binding CanGoToNextPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_NextPage}"
Margin="0,0,8,0">
<avalonia:Icon Value="fa-solid fa-caret-right"/>
<Button
Margin="0,0,8,0"
Command="{Binding NextPage}"
IsEnabled="{Binding CanGoToNextPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_NextPage}">
<avalonia:Icon Value="fa-solid fa-caret-right" />
</Button>
<Button
Command="{Binding LastPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_LastPage}"
IsEnabled="{Binding CanGoToLastPage}">
<avalonia:Icon Value="fa-solid fa-forward-fast"/>
IsEnabled="{Binding CanGoToLastPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_LastPage}">
<avalonia:Icon Value="fa-solid fa-forward-fast" />
</Button>
</StackPanel>
</StackPanel>
@ -352,19 +455,19 @@
<TextBlock
Grid.Row="0"
Grid.RowSpan="3"
FontSize="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="{Binding NoResultsText, FallbackValue=No results found}"
IsVisible="{Binding NoResultsFound}" />
FontSize="20"
IsVisible="{Binding NoResultsFound}"
Text="{Binding NoResultsText, FallbackValue=No results found}" />
<controls:ProgressRing
Grid.Row="1"
IsIndeterminate="True"
Width="128"
Height="128"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
IsIndeterminate="True"
IsVisible="{Binding ShowMainLoadingSpinner, FallbackValue=False}" />
</Grid>
</UserControl>

23
StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml.cs

@ -1,5 +1,9 @@
using Avalonia.Markup.Xaml;
using System.Diagnostics;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views;
@ -16,4 +20,21 @@ public partial class CheckpointBrowserPage : UserControlBase
{
AvaloniaXamlLoader.Load(this);
}
private void ScrollViewer_OnScrollChanged(object? sender, ScrollChangedEventArgs e)
{
if (sender is not ScrollViewer scrollViewer)
return;
var isAtEnd = scrollViewer.Offset == scrollViewer.ScrollBarMaximum;
Debug.WriteLine($"IsAtEnd: {isAtEnd}");
}
private void InputElement_OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Escape && DataContext is CheckpointBrowserViewModel viewModel)
{
viewModel.ClearSearchQuery();
}
}
}

35
StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml

@ -44,10 +44,15 @@
<!-- Checkpoint File Card -->
<DataTemplate DataType="{x:Type checkpointManager:CheckpointFile}" x:Key="CheckpointFileDataTemplate">
<Border
DragDrop.AllowDrop="False"
DragDrop.AllowDrop="True"
Background="Transparent"
BorderThickness="2"
Margin="4">
<Interaction.Behaviors>
<BehaviorCollection>
<ContextDragBehavior HorizontalDragThreshold="6" VerticalDragThreshold="6"/>
</BehaviorCollection>
</Interaction.Behaviors>
<controls:Card MaxWidth="330" Width="330"
CornerRadius="12">
@ -215,6 +220,7 @@
<!-- Checkpoint Folder Expander -->
<DataTemplate DataType="{x:Type checkpointManager:CheckpointFolder}" x:Key="CheckpointFolderGridDataTemplate">
<Expander
DragDrop.AllowDrop="True"
CornerRadius="8"
IsExpanded="{Binding IsExpanded}"
Margin="4"
@ -268,6 +274,7 @@
Background="Transparent"
DragDrop.AllowDrop="True">
<ItemsRepeater
Name="FilesRepeater"
ItemTemplate="{StaticResource CheckpointFileDataTemplate}"
ItemsSource="{Binding DisplayedCheckpointFiles}">
<ItemsRepeater.Layout>
@ -368,7 +375,8 @@
</controls:UserControlBase.Resources>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,*" Margin="4, 0">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,*" Margin="4, 0"
x:Name="ParentGrid">
<!-- Top settings bar -->
<StackPanel Grid.Column="0" Grid.Row="0">
<ToggleSwitch
@ -392,11 +400,27 @@
HorizontalAlignment="Right"
Margin="16,0"
Orientation="Horizontal">
<TextBox Margin="16, 16"
<TextBox Margin="8, 16"
Watermark="{x:Static lang:Resources.Action_Search}"
Height="16"
MinWidth="150"
Text="{Binding SearchFilter, Mode=TwoWay}"/>
MinWidth="220"
KeyDown="InputElement_OnKeyDown"
Text="{Binding SearchFilter, Mode=TwoWay}">
<TextBox.InnerRightContent>
<Grid>
<Button Classes="transparent-full"
IsVisible="{Binding SearchFilter.Length}"
Command="{Binding ClearSearchQuery}">
<ui:SymbolIcon Symbol="Cancel" />
</Button>
<ui:SymbolIcon
IsVisible="{Binding !SearchFilter.Length}"
Margin="0,0,10,0"
FontSize="16"
Symbol="Find" />
</Grid>
</TextBox.InnerRightContent>
</TextBox>
<controls:ProgressRing
Width="16"
Height="16"
@ -435,6 +459,7 @@
Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="1"
x:Name="MainScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<Grid>

69
StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs

@ -1,15 +1,16 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.VisualTree;
using DynamicData.Binding;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
using CheckpointFolder = StabilityMatrix.Avalonia.ViewModels.CheckpointManager.CheckpointFolder;
namespace StabilityMatrix.Avalonia.Views;
@ -61,12 +62,36 @@ public partial class CheckpointsPage : UserControlBase
}
}
private static async void OnDrop(object? sender, DragEventArgs e)
private async void OnDrop(object? sender, DragEventArgs e)
{
var sourceDataContext = (e.Source as Control)?.DataContext;
if (sourceDataContext is CheckpointFolder folder)
switch (sourceDataContext)
{
await folder.OnDrop(e);
case CheckpointFolder folder:
{
if (e.Data.Get("Context") is not CheckpointFile file)
return;
var filePath = new FilePath(file.FilePath);
if (filePath.Directory?.FullPath != folder.DirectoryPath)
{
await folder.OnDrop(e);
}
break;
}
case CheckpointFile file:
{
if (e.Data.Get("Context") is not CheckpointFile dragFile)
return;
var parentFolder = file.ParentFolder;
var dragFilePath = new FilePath(dragFile.FilePath);
if (dragFilePath.Directory?.FullPath != parentFolder.DirectoryPath)
{
await parentFolder.OnDrop(e);
}
break;
}
}
}
@ -79,7 +104,7 @@ public partial class CheckpointsPage : UserControlBase
}
}
private static void OnDragEnter(object? sender, DragEventArgs e)
private void OnDragEnter(object? sender, DragEventArgs e)
{
// Only allow Copy or Link as Drop Operations.
e.DragEffects &= DragDropEffects.Copy | DragDropEffects.Link;
@ -92,9 +117,37 @@ public partial class CheckpointsPage : UserControlBase
// Forward to view model
var sourceDataContext = (e.Source as Control)?.DataContext;
if (sourceDataContext is CheckpointFolder folder)
switch (sourceDataContext)
{
case CheckpointFolder folder:
{
folder.IsExpanded = true;
if (e.Data.Get("Context") is not CheckpointFile file)
return;
var filePath = new FilePath(file.FilePath);
folder.IsCurrentDragTarget = filePath.Directory?.FullPath != folder.DirectoryPath;
break;
}
case CheckpointFile file:
{
if (e.Data.Get("Context") is not CheckpointFile dragFile)
return;
var parentFolder = file.ParentFolder;
var dragFilePath = new FilePath(dragFile.FilePath);
parentFolder.IsCurrentDragTarget =
dragFilePath.Directory?.FullPath != parentFolder.DirectoryPath;
break;
}
}
}
private void InputElement_OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Escape && DataContext is CheckpointsPageViewModel vm)
{
folder.IsCurrentDragTarget = true;
vm.ClearSearchQuery();
}
}
}

7
StabilityMatrix.Avalonia/Views/Dialogs/InstallerDialog.axaml

@ -148,7 +148,6 @@
</Grid>
<Grid Grid.Column="0" Grid.Row="2"
Grid.ColumnSpan="2"
Margin="16,16,16,0"
RowDefinitions="Auto, Auto, Auto, Auto"
HorizontalAlignment="Center"
@ -289,7 +288,7 @@
</Grid>
<!-- Install Button -->
<UniformGrid Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2"
<UniformGrid Grid.Row="3" Grid.Column="0"
Columns="2"
Margin="0,16,0,0"
HorizontalAlignment="Center">
@ -322,8 +321,8 @@
<!-- Available Packages -->
<ScrollViewer Grid.Column="1" Grid.Row="0"
Grid.RowSpan="4"
Margin="8, 16"
MaxHeight="400"
VerticalScrollBarVisibility="Auto">
<ListBox
ItemsSource="{Binding AvailablePackages}"
@ -331,7 +330,7 @@
CornerRadius="8">
<ListBox.Template>
<ControlTemplate>
<ItemsPresenter />
<ItemsPresenter Margin="8,0" />
</ControlTemplate>
</ListBox.Template>
<ListBox.ItemTemplate>

130
StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml

@ -0,0 +1,130 @@
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Dialogs.LykosLoginDialog"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia"
Focusable="True"
d:DataContext="{x:Static mocks:DesignData.LykosLoginViewModel}"
d:DesignHeight="350"
d:DesignWidth="400"
x:DataType="vmDialogs:LykosLoginViewModel"
mc:Ignorable="d">
<sg:SpacedGrid MinWidth="400" MinHeight="300">
<!--<TextBlock Text="Log in or sign up" Theme="{DynamicResource SubtitleTextBlockStyle}" />-->
<TabControl SelectedIndex="{Binding IsSignupMode}">
<TabItem
FontSize="20"
Header="Login">
<sg:SpacedGrid
Margin="-4,32,-4,8"
RowDefinitions="Auto,Auto,Auto,*"
RowSpacing="8">
<!-- Username -->
<TextBox
Watermark="{x:Static lang:Resources.Label_Email}"
Text="{Binding Email}"
UseFloatingWatermark="True"
Grid.Row="0" />
<!-- Password -->
<MaskedTextBox
PasswordChar="*"
Text="{Binding Password}"
Watermark="{x:Static lang:Resources.Label_Password}"
Grid.Row="1" />
<ui:InfoBar
Severity="Error"
IsOpen="{Binding LoginError, Converter={x:Static ObjectConverters.IsNotNull}}"
Title="{Binding LoginError.Message}"
Message="{Binding LoginError.Details}"
Grid.Row="2"/>
<controls:HyperlinkIconButton
VerticalAlignment="Bottom"
Content="Forgot Password?"
NavigateUri="{x:Static sm:Assets.LykosForgotPasswordUrl}"
Grid.Row="3"/>
</sg:SpacedGrid>
</TabItem>
<TabItem
FontSize="20"
Header="Sign up">
<sg:SpacedGrid
Margin="-4,32,-4,8"
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"
RowSpacing="8">
<!-- Email -->
<TextBox
Watermark="{x:Static lang:Resources.Label_Email}"
Text="{Binding Email}"
UseFloatingWatermark="True"
Grid.Row="0" />
<!-- Username -->
<TextBox
Watermark="{x:Static lang:Resources.Label_Username}"
Text="{Binding Username}"
UseFloatingWatermark="True"
Grid.Row="1" />
<!-- Password -->
<TextBox
PasswordChar="*"
Text="{Binding Password}"
Watermark="{x:Static lang:Resources.Label_Password}"
UseFloatingWatermark="True"
Grid.Row="2" />
<!-- Confirm Password -->
<TextBox
PasswordChar="*"
Text="{Binding ConfirmPassword}"
Watermark="{x:Static lang:Resources.Label_ConfirmPassword}"
UseFloatingWatermark="True"
Grid.Row="3" />
<ui:InfoBar
Severity="Error"
IsOpen="{Binding SignupError, Converter={x:Static ObjectConverters.IsNotNull}}"
Title="{Binding SignupError.Message}"
Message="{Binding SignupError.Details}"
Grid.Row="4"/>
<mdxaml:MarkdownScrollViewer
Grid.Row="5"
Margin="4,0"
VerticalAlignment="Bottom"
MaxWidth="380"
TextElement.Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Markdown="{Binding SignupFooterMarkdown}">
<mdxaml:MarkdownScrollViewer.Styles>
<Style Selector="ctxt|CHyperlink">
<Style.Setters>
<Setter Property="IsUnderline" Value="False"/>
</Style.Setters>
</Style>
<Style Selector="ctxt|CHyperlink:pointerover">
<Setter Property="IsUnderline" Value="True"/>
<Setter Property="Foreground" Value="{StaticResource ThemeEldenRingOrangeColor}"/>
</Style>
</mdxaml:MarkdownScrollViewer.Styles>
</mdxaml:MarkdownScrollViewer>
</sg:SpacedGrid>
</TabItem>
</TabControl>
</sg:SpacedGrid>
</controls:UserControlBase>

13
StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml.cs

@ -0,0 +1,13 @@
using Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Dialogs;
[Transient]
public partial class LykosLoginDialog : UserControl
{
public LykosLoginDialog()
{
InitializeComponent();
}
}

40
StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml

@ -0,0 +1,40 @@
<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: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"
xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
d:DataContext="{x:Static mocks:DesignData.OAuthConnectViewModel}"
x:DataType="vmDialogs:OAuthConnectViewModel"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="400"
x:Class="StabilityMatrix.Avalonia.Views.Dialogs.OAuthConnectDialog">
<sg:SpacedGrid RowDefinitions="Auto,Auto,Auto" Margin="8,4" RowSpacing="12">
<sg:SpacedGrid.Styles>
<Style Selector="ui|HyperlinkButton > TextBlock">
<Setter Property="TextWrapping" Value="WrapWithOverflow"/>
</Style>
</sg:SpacedGrid.Styles>
<ui:HyperlinkButton
Padding="6,0"
Content="{Binding Url}"
NavigateUri="{Binding Url}"/>
<TextBlock
Grid.Row="1"
Margin="6,0"
TextWrapping="WrapWithOverflow"
Text="{Binding Description}"/>
<ProgressBar
IsIndeterminate="True"
Grid.Row="2"/>
</sg:SpacedGrid>
</controls:UserControlBase>

13
StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml.cs

@ -0,0 +1,13 @@
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Dialogs;
[Transient]
public partial class OAuthConnectDialog : UserControlBase
{
public OAuthConnectDialog()
{
InitializeComponent();
}
}

17
StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using AsyncImageLoader;
using Avalonia;
using Avalonia.Controls;
@ -26,6 +27,7 @@ using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base;
@ -44,7 +46,7 @@ namespace StabilityMatrix.Avalonia.Views;
public partial class MainWindow : AppWindowBase
{
private readonly INotificationService notificationService;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private FlyoutBase? progressFlyout;
@ -58,7 +60,7 @@ public partial class MainWindow : AppWindowBase
public MainWindow(
INotificationService notificationService,
INavigationService navigationService
INavigationService<MainWindowViewModel> navigationService
)
{
this.notificationService = notificationService;
@ -74,6 +76,8 @@ public partial class MainWindow : AppWindowBase
TitleBar.ExtendsContentIntoTitleBar = true;
TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex;
navigationService.TypedNavigation += NavigationService_OnTypedNavigation;
EventManager.Instance.ToggleProgressFlyout += (_, _) => progressFlyout?.Hide();
EventManager.Instance.CultureChanged += (_, _) => SetDefaultFonts();
EventManager.Instance.UpdateAvailable += OnUpdateAvailable;
@ -158,6 +162,15 @@ public partial class MainWindow : AppWindowBase
}
}
private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e)
{
var mainViewModel = (MainWindowViewModel)DataContext!;
mainViewModel.SelectedCategory = mainViewModel.Pages
.Concat(mainViewModel.FooterPages)
.FirstOrDefault(x => x.GetType() == e.ViewModelType);
}
private void OnUpdateAvailable(object? sender, UpdateInfo? updateInfo)
{
Dispatcher.UIThread.Post(() =>

317
StabilityMatrix.Avalonia/Views/OutputsPage.axaml

@ -2,226 +2,243 @@
x:Class="StabilityMatrix.Avalonia.Views.OutputsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:avaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:models1="clr-namespace:StabilityMatrix.Avalonia.Models"
xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:outputsPage="clr-namespace:StabilityMatrix.Avalonia.ViewModels.OutputsPage"
xmlns:selectableImageCard="clr-namespace:StabilityMatrix.Avalonia.Controls.SelectableImageCard"
xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:scroll="clr-namespace:StabilityMatrix.Avalonia.Controls.Scroll"
xmlns:selectableImageCard="clr-namespace:StabilityMatrix.Avalonia.Controls.SelectableImageCard"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
d:DataContext="{x:Static mocks:DesignData.OutputsPageViewModel}"
d:DesignHeight="450"
d:DesignWidth="700"
d:DesignHeight="650"
d:DesignWidth="800"
x:CompileBindings="True"
x:DataType="vm:OutputsPageViewModel"
Focusable="True"
mc:Ignorable="d">
<Grid RowDefinitions="Auto, *" Margin="16">
<Grid Grid.Row="0" Margin="0,0,0,16"
HorizontalAlignment="Stretch"
RowDefinitions="Auto, Auto"
ColumnDefinitions="Auto, Auto, Auto, Auto, *, Auto, Auto">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{x:Static lang:Resources.Label_OutputFolder}"
Margin="4" />
<ComboBox Grid.Column="0"
Grid.Row="1" ItemsSource="{Binding Categories}"
Margin="4,0"
SelectedItem="{Binding SelectedCategory}"
VerticalAlignment="Stretch"
MinWidth="150">
<Grid Margin="16" RowDefinitions="Auto,*">
<Grid
Grid.Row="0"
Margin="0,0,0,16"
ColumnDefinitions="Auto,Auto,*,Auto"
RowDefinitions="Auto,*">
<Grid.Styles>
<Style Selector="TextBox">
<Setter Property="MinHeight" Value="40" />
</Style>
<Style Selector="ComboBox">
<Setter Property="MinHeight" Value="40" />
</Style>
</Grid.Styles>
<TextBlock
Grid.Row="0"
Grid.Column="0"
Margin="4"
Text="{x:Static lang:Resources.Label_OutputFolder}" />
<ComboBox
Grid.Row="1"
Grid.Column="0"
MinWidth="150"
Margin="4,0"
VerticalAlignment="Center"
ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory}">
<ComboBox.Styles>
<Style
Selector="ComboBox /template/ ContentControl#ContentPresenter &gt; StackPanel &gt; TextBlock:nth-child(2)">
<Style Selector="ComboBox /template/ ContentControl#ContentPresenter &gt; StackPanel &gt; TextBlock:nth-child(2)">
<Setter Property="IsVisible" Value="False" />
</Style>
</ComboBox.Styles>
<ComboBox.ItemTemplate>
<DataTemplate DataType="{x:Type models1:PackageOutputCategory}">
<StackPanel>
<TextBlock
Margin="0,4,0,4"
Text="{Binding Name, Mode=OneWay}" />
<TextBlock Margin="0,4,0,4" Text="{Binding Name, Mode=OneWay}" />
<TextBlock Text="{Binding Path, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Row="0"
Grid.Column="1"
Text="{x:Static lang:Resources.Label_OutputType}"
Margin="4"
IsVisible="{Binding CanShowOutputTypes}" />
<ComboBox Grid.Column="1" Grid.Row="1" ItemsSource="{Binding OutputTypes}"
IsVisible="{Binding CanShowOutputTypes}"
SelectedItem="{Binding SelectedOutputType}"
MinWidth="150"
Margin="4,0"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center" />
<TextBlock Grid.Row="0"
Grid.Column="2"
Margin="4"
Text="{x:Static lang:Resources.Action_Search}"/>
<TextBox Grid.Row="1"
Grid.Column="2"
Text="{Binding SearchQuery, Mode=TwoWay}"
Watermark="{x:Static lang:Resources.Action_Search}"
Margin="4, 0"
VerticalContentAlignment="Center"
MinWidth="150"/>
<Button Grid.Row="1"
Grid.Column="3"
VerticalAlignment="Stretch"
Margin="4, 0"
IsEnabled="{Binding !IsConsolidating}"
Command="{Binding ConsolidateImages}">
<StackPanel Orientation="Horizontal">
<TextBlock
Text="{x:Static lang:Resources.Action_Consolidate}"
VerticalAlignment="Center"/>
<controls:ProgressRing
MinHeight="16"
Margin="8, 0"
IsIndeterminate="True"
VerticalAlignment="Center"
BorderThickness="4"
IsVisible="{Binding IsConsolidating}"
MinWidth="16" />
</StackPanel>
</Button>
<TextBlock Grid.Row="1"
Grid.Column="4"
IsVisible="{Binding !!NumItemsSelected}"
FontSize="16"
Margin="8, 0"
VerticalAlignment="Center"
TextAlignment="Center"
HorizontalAlignment="Right"
Text="{Binding NumImagesSelected, FallbackValue=1234 images selected}" />
<TextBlock
Grid.Row="0"
Grid.Column="1"
Margin="4"
IsVisible="{Binding CanShowOutputTypes}"
Text="{x:Static lang:Resources.Label_OutputType}" />
<ComboBox
Grid.Row="1"
Grid.Column="1"
MinWidth="150"
Margin="4,0"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
IsVisible="{Binding CanShowOutputTypes}"
ItemsSource="{Binding OutputTypes}"
SelectedItem="{Binding SelectedOutputType}" />
<Button Grid.Row="1"
Grid.Column="5"
VerticalAlignment="Bottom"
Padding="12, 0"
Height="40"
Classes="danger"
Command="{Binding DeleteAllSelected}"
IsVisible="{Binding !!NumItemsSelected}" >
<avalonia:Icon Value="fa-solid fa-trash-can"/>
</Button>
<Button Grid.Row="1"
Grid.Column="6"
Content="{x:Static lang:Resources.Action_ClearSelection}"
VerticalAlignment="Bottom"
Margin="8, 0"
Height="40"
Command="{Binding ClearSelection}"
IsVisible="{Binding !!NumItemsSelected}" />
<Button Grid.Row="1"
Grid.Column="6"
Content="{x:Static lang:Resources.Action_SelectAll}"
VerticalAlignment="Bottom"
Classes="accent"
Margin="8, 0"
Height="40"
Command="{Binding SelectAll}"
IsVisible="{Binding !NumItemsSelected}" />
<TextBlock
Grid.Row="0"
Grid.Column="2"
Margin="4"
Text="{x:Static lang:Resources.Action_Search}" />
<TextBox
Grid.Row="1"
Grid.Column="2"
MinWidth="150"
Margin="4,0"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
Text="{Binding SearchQuery, Mode=TwoWay}"
Watermark="{x:Static lang:Resources.Action_Search}"
KeyDown="InputElement_OnKeyDown">
<TextBox.InnerRightContent>
<Grid>
<Button Classes="transparent-full"
IsVisible="{Binding SearchQuery.Length}"
Command="{Binding ClearSearchQuery}">
<ui:SymbolIcon Symbol="Cancel" />
</Button>
<ui:SymbolIcon
IsVisible="{Binding !SearchQuery.Length}"
Margin="0,0,10,0"
FontSize="16"
Symbol="Find" />
</Grid>
</TextBox.InnerRightContent>
</TextBox>
<TextBlock
Grid.Row="0"
Grid.Column="3"
Margin="4"
IsVisible="{Binding !!NumItemsSelected}"
Text="{Binding NumImagesSelected}" />
<ui:CommandBar
Grid.Row="1"
Grid.Column="3"
Margin="8,0,0,0"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
DefaultLabelPosition="Right">
<ui:CommandBar.PrimaryCommands>
<ui:CommandBarButton
IsEnabled="{Binding !!NumItemsSelected}"
Command="{Binding DeleteAllSelected}"
IconSource="Delete" />
<ui:CommandBarSeparator />
<ui:CommandBarButton
Command="{Binding SelectAll}"
IconSource="SelectAll"
Label="{x:Static lang:Resources.Action_SelectAll}" />
<ui:CommandBarButton
Command="{Binding ClearSelection}"
IconSource="ClearSelection"
IsEnabled="{Binding !!NumItemsSelected}"
Label="{x:Static lang:Resources.Action_ClearSelection}" />
<ui:CommandBarSeparator />
<ui:CommandBarButton
IconSource="Refresh"
Command="{Binding Refresh}"
Label="{x:Static lang:Resources.Action_Refresh}" />
</ui:CommandBar.PrimaryCommands>
<ui:CommandBar.SecondaryCommands>
<ui:CommandBarButton
Command="{Binding ConsolidateImages}"
IconSource="MoveToFolder"
Label="{x:Static lang:Resources.Action_Consolidate}" />
</ui:CommandBar.SecondaryCommands>
</ui:CommandBar>
</Grid>
<scroll:BetterScrollViewer Grid.Row="1"
PointerWheelChanged="ScrollViewer_MouseWheelChanged">
<scroll:BetterScrollViewer Grid.Row="1" PointerWheelChanged="ScrollViewer_MouseWheelChanged">
<ItemsRepeater
x:Name="ImageRepeater"
ItemsSource="{Binding Outputs}"
VerticalAlignment="Top">
VerticalAlignment="Top"
ItemsSource="{Binding Outputs}">
<ItemsRepeater.Layout>
<UniformGridLayout MinColumnSpacing="8" MinRowSpacing="8"/>
<UniformGridLayout MinColumnSpacing="8" MinRowSpacing="8" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate DataType="{x:Type outputsPage:OutputImageViewModel}">
<selectableImageCard:SelectableImageButton
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).OnImageClick}"
CommandParameter="{Binding}"
ImageHeight="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).ImageSize.Height}"
ImageWidth="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).ImageSize.Width}"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).OnImageClick}"
CommandParameter="{Binding }"
IsSelected="{Binding IsSelected}"
Source="{Binding ImageFile.AbsolutePath}">
<selectableImageCard:SelectableImageButton.ContextFlyout>
<ui:FAMenuFlyout>
<ui:MenuFlyoutItem
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).CopyImage}"
CommandParameter="{Binding ImageFile.AbsolutePath}"
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Action_Copy}"
IconSource="Copy"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).CopyImage}"
CommandParameter="{Binding ImageFile.AbsolutePath}" />
Text="{x:Static lang:Resources.Action_Copy}" />
<ui:MenuFlyoutItem
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).OpenImage}"
CommandParameter="{Binding ImageFile.AbsolutePath}"
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Action_OpenInExplorer}"
IconSource="Folder"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).OpenImage}"
CommandParameter="{Binding ImageFile.AbsolutePath}" />
Text="{x:Static lang:Resources.Action_OpenInExplorer}" />
<ui:MenuFlyoutItem
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).ShowImageDialog}"
CommandParameter="{Binding}"
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Action_OpenInViewer}"
IconSource="Image"
IsVisible="{Binding !!$parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).NumItemsSelected}"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).ShowImageDialog}"
CommandParameter="{Binding }" />
Text="{x:Static lang:Resources.Action_OpenInViewer}" />
<ui:MenuFlyoutItem
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).DeleteImage}"
CommandParameter="{Binding}"
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Action_Delete}"
IconSource="Delete"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).DeleteImage}"
CommandParameter="{Binding }" />
Text="{x:Static lang:Resources.Action_Delete}" />
<ui:MenuFlyoutSeparator
IsVisible="{Binding ImageFile.GenerationParameters, Converter={x:Static ObjectConverters.IsNotNull}}" />
<ui:MenuFlyoutSeparator IsVisible="{Binding ImageFile.GenerationParameters, Converter={x:Static ObjectConverters.IsNotNull}}" />
<ui:MenuFlyoutSubItem Text="{x:Static lang:Resources.Action_SendToInference}"
IconSource="Share"
IsVisible="{Binding ImageFile.GenerationParameters, Converter={x:Static ObjectConverters.IsNotNull}}">
<ui:MenuFlyoutSubItem
IconSource="Share"
IsVisible="{Binding ImageFile.GenerationParameters, Converter={x:Static ObjectConverters.IsNotNull}}"
Text="{x:Static lang:Resources.Action_SendToInference}">
<ui:MenuFlyoutItem
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).SendToTextToImage}"
CommandParameter="{Binding}"
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Label_TextToImage}"
IconSource="FullScreenMaximize"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).SendToTextToImage}"
CommandParameter="{Binding }" />
Text="{x:Static lang:Resources.Label_TextToImage}" />
<ui:MenuFlyoutItem
CommandParameter="{Binding}"
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Label_ImageToImage}"
IconSource="ImageCopy"
IsEnabled="False"
IsVisible="False"
IconSource="ImageCopy"
CommandParameter="{Binding }" />
Text="{x:Static lang:Resources.Label_ImageToImage}" />
<ui:MenuFlyoutItem
Text="{x:Static lang:Resources.Label_Inpainting}"
CommandParameter="{Binding}"
HotKey="{x:Null}"
IconSource="ImageEdit"
IsEnabled="False"
IsVisible="False"
HotKey="{x:Null}"
CommandParameter="{Binding }" />
Text="{x:Static lang:Resources.Label_Inpainting}" />
<ui:MenuFlyoutItem
Text="{x:Static lang:Resources.Label_Upscale}"
HotKey="{x:Null}"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).SendToUpscale}"
CommandParameter="{Binding }">
CommandParameter="{Binding}"
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Label_Upscale}">
<ui:MenuFlyoutItem.IconSource>
<fluentAvalonia:SymbolIconSource
FontSize="10"
Symbol="ResizeImage" />
<fluentAvalonia:SymbolIconSource FontSize="10" Symbol="ResizeImage" />
</ui:MenuFlyoutItem.IconSource>
</ui:MenuFlyoutItem>
</ui:MenuFlyoutSubItem>

8
StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs

@ -39,4 +39,12 @@ public partial class OutputsPage : UserControlBase
e.Handled = true;
}
private void InputElement_OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Escape && DataContext is OutputsPageViewModel viewModel)
{
viewModel.ClearSearchQuery();
}
}
}

16
StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml

@ -51,13 +51,17 @@
BorderBrush="#33000000"
BorderThickness="2"
CornerRadius="8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
<Grid ColumnDefinitions="Auto,Auto" RowDefinitions="Auto,Auto">
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Margin="0,0" Text="{Binding Name, Mode=OneWay}" />
<Grid Grid.Row="0" Grid.Column="0" RowDefinitions="Auto, Auto">
<TextBlock
MaxWidth="200"
Text="{Binding Name, Mode=OneWay}"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis"/>
<!-- non-indeterminate progress -->
<TextBlock
<TextBlock Grid.Row="1"
Margin="0,0"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding Progress.Text, Mode=OneWay}" />
@ -65,11 +69,11 @@
</TextBlock>
<!-- indeterminate progress -->
<TextBlock
<TextBlock Grid.Row="1"
Margin="0,4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding Progress.Text, Mode=OneWay}" />
</StackPanel>
</Grid>
<!-- Buttons -->

179
StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml

@ -0,0 +1,179 @@
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Settings.AccountSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia"
d:DataContext="{x:Static mocks:DesignData.AccountSettingsViewModel}"
d:DesignHeight="450"
d:DesignWidth="800"
x:DataType="vmSettings:AccountSettingsViewModel"
mc:Ignorable="d">
<controls:UserControlBase.Resources>
<SolidColorBrush x:Key="Brush0" Color="#FF3C3C46" />
<SolidColorBrush x:Key="Brush1" Color="#FF3E4B77" />
<SolidColorBrush x:Key="Brush4" Color="#FF1375D5" />
<SolidColorBrush x:Key="Brush7" Color="#FF1B96E3" />
<SolidColorBrush x:Key="Brush13" Color="#FF5486BC" />
<DrawingImage x:Key="BrandsLykos">
<DrawingGroup>
<GeometryDrawing Brush="Transparent" Geometry="F1M0,0L587,0L587,618L0,618z" />
<DrawingGroup>
<DrawingGroup.Transform>
<MatrixTransform Matrix="0.99998295,0,0,0.99998295,0,0.24023438" />
</DrawingGroup.Transform>
<GeometryDrawing Brush="{DynamicResource Brush0}" Geometry="F0 M359.12 181.81L290.92 263.57L202.31 422.8L296.41 349.51L341.33 282.47L359.12 181.81Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L296.41 349.51L287.33 463.38L402.14 326.47L405.15 227.55L342.3 165.1L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L373.25 246.84L402.14 326.47L475.55 241.31L506.38 172.37L432.6 170.07L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush0}" Geometry="F0 M506.38 172.37L402.14 326.47L431.69 421.7L493.66 289.28L506.38 172.37Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M354.51 195.18L431.14 183.67L506.38 172.37L581.91 114.38L577.7 83.96L459.71 128.22L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L577.7 83.96L587.01 36.06L555.98 24.72L444.52 90.52L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M354.51 195.18L451.44 116.55L551.26 35.59L570.72 16.37L543.04 5.86L469.78 26.31L346.91 82.41L330.57 157.07L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush7}" Geometry="F0 M258.82 209.64L283.43 257.76L354.51 195.18L354.73 143.18L354.97 88.91L300.3 145.74L258.82 209.64Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M284.89 201.03L283.82 132.98L245.45 133.94L209.32 211.39L209.53 265.05L202.31 422.8L277.33 274.62L284.89 201.03Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L318.27 198.22L284.89 201.03L202.31 422.8L268.99 323.08L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M209.53 265.05L225.86 205.45L245.45 133.94L157.16 269.41L209.53 265.05Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M245.45 133.94L301.48 136.45L354.97 88.91L275.66 59.42L139 44.21L245.45 133.94Z" />
<GeometryDrawing Brush="{DynamicResource Brush7}" Geometry="F0 M139 44.21L287.77 75.01L354.97 88.91L348.14 53.88L306.32 49.68L139 44.21Z" />
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M393.94 11.53L306.32 49.68L354.97 88.91L412.38 57.61L441.91 26.85L393.94 11.53Z" />
<GeometryDrawing Brush="{DynamicResource Brush7}" Geometry="F0 M441.91 26.85L354.97 88.91L543.04 5.86L571.25 0L441.91 26.85Z" />
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M571.25 0L543.04 5.86L551.26 35.59L587.01 36.06L571.25 0Z" />
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M151.49 224.62L33.55 321.2L103.23 316.08L151.49 224.62Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M145.63 400.32L200.67 282.86L0 447.89L145.63 400.32Z" />
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M262.66 413.33L132.89 597.28L236.55 530.32L262.66 413.33Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M374.93 617.53L350.75 441.52L396.59 364.19L412.64 459.88L374.93 617.53Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M277.5 507.65L312.09 557.16L324.24 472.27L277.5 507.65Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M185.82 455.66L119.65 488.21L183.32 389.39L185.82 455.66Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M441.13 526.17L473.16 366.86L489.5 454.43L441.13 526.17Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.97 88.91L354.97 88.91L245.45 133.94L284.89 201.03L316.79 149.99L354.97 88.91Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M353.83 211.72L346.35 393L402.14 326.47L373.25 246.84L356.02 199.33L354.51 195.18L353.83 211.72Z" />
</DrawingGroup>
</DrawingGroup>
</DrawingImage>
<SolidColorBrush x:Key="BrushB0" Color="#FFFFFFFF" />
<DrawingImage x:Key="BrandsPatreonSymbolWhite">
<DrawingGroup>
<GeometryDrawing Brush="{DynamicResource BrushB0}" Geometry="F1 M1033.05 324.45C1032.86 186.55 925.46 73.53 799.45 32.75C642.97 -17.89 436.59 -10.55 287.17 59.95C106.07 145.41 49.18 332.61 47.06 519.31C45.32 672.81 60.64 1077.1 288.68 1079.98C458.12 1082.13 483.35 863.8 561.75 758.65C617.53 683.84 689.35 662.71 777.76 640.83C929.71 603.22 1033.27 483.3 1033.05 324.45Z" />
</DrawingGroup>
</DrawingImage>
</controls:UserControlBase.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16,16" Spacing="2">
<sg:SpacedGrid Margin="0,4,0,16" ColumnDefinitions="*,*">
<StackPanel Orientation="Horizontal" Spacing="16">
<fluentIcons:SymbolIcon
IsVisible="{Binding LykosProfileImageUrl, Converter={x:Static StringConverters.IsNullOrEmpty}}"
Width="64"
Height="64"
IsFilled="True"
FontSize="64"
Symbol="Person" />
<ui:HyperlinkButton
ToolTip.Tip="Edit on Gravatar"
NavigateUri="http://gravatar.com/emails/"
IsVisible="{Binding LykosProfileImageUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Padding="2"
CornerRadius="8"
HorizontalAlignment="Left"
Classes="transparent-full">
<controls:BetterAdvancedImage
Source="{Binding LykosProfileImageUrl}"
Width="64"
Height="64"
CornerRadius="8" />
</ui:HyperlinkButton>
<StackPanel>
<TextBlock
Margin="-1,0,0,0"
Text="{Binding LykosStatus.User.Account.Name, FallbackValue=''}"
Theme="{DynamicResource SubtitleTextBlockStyle}" />
<TextBlock Text="{Binding LykosStatus.User.Id, FallbackValue=''}" />
</StackPanel>
</StackPanel>
</sg:SpacedGrid>
<controls:SettingsAccountLinkExpander
ConnectCommand="{Binding ConnectLykosCommand}"
DisconnectCommand="{Binding DisconnectLykosCommand}"
Header="Lykos"
HeaderTargetUri="{x:Static sm:Assets.LykosUrl}"
IsConnected="{Binding LykosStatus.IsConnected}"
OffDescription="Manage connected features in Stability Matrix">
<controls:SettingsAccountLinkExpander.IconSource>
<ui:ImageIconSource Source="{StaticResource BrandsLykos}" />
</controls:SettingsAccountLinkExpander.IconSource>
<controls:SettingsAccountLinkExpander.IsLoading>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="ConnectLykosCommand.IsRunning" />
<Binding Path="!IsInitialUpdateFinished" />
</MultiBinding>
</controls:SettingsAccountLinkExpander.IsLoading>
</controls:SettingsAccountLinkExpander>
<controls:SettingsAccountLinkExpander
ConnectCommand="{Binding ConnectPatreonCommand}"
DisconnectCommand="{Binding DisconnectPatreonCommand}"
Header="Patreon"
IsEnabled="{Binding LykosStatus.IsConnected}"
HeaderTargetUri="{x:Static sm:Assets.PatreonUrl}"
IsConnected="{Binding IsPatreonConnected}"
OffDescription="Access Preview and Dev release channels for auto-updates">
<controls:SettingsAccountLinkExpander.IconSource>
<ui:ImageIconSource Source="{StaticResource BrandsPatreonSymbolWhite}" />
</controls:SettingsAccountLinkExpander.IconSource>
<controls:SettingsAccountLinkExpander.IsLoading>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="ConnectPatreonCommand.IsRunning" />
<Binding Path="!IsInitialUpdateFinished" />
</MultiBinding>
</controls:SettingsAccountLinkExpander.IsLoading>
</controls:SettingsAccountLinkExpander>
<sg:SpacedGrid RowDefinitions="Auto,*,*" RowSpacing="4" Margin="0,6,0,0">
<TextBlock
Margin="0,0,0,4"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Integrations}" />
<controls:SettingsAccountLinkExpander
Grid.Row="2"
ConnectCommand="{Binding ConnectCivitCommand}"
DisconnectCommand="{Binding DisconnectCivitCommand}"
Header="CivitAI"
HeaderTargetUri="{x:Static sm:Assets.CivitAIUrl}"
IsConnected="{Binding CivitStatus.IsConnected}"
OnDescriptionExtra="{Binding CivitStatus.UsernameWithParentheses}"
OffDescription="Connect to Download Models that require login">
<controls:SettingsAccountLinkExpander.IconSource>
<ui:BitmapIconSource UriSource="avares://StabilityMatrix.Avalonia/Assets/brands-civitai.png" />
</controls:SettingsAccountLinkExpander.IconSource>
<controls:SettingsAccountLinkExpander.IsLoading>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="ConnectCivitCommand.IsRunning" />
<Binding Path="!IsInitialUpdateFinished" />
</MultiBinding>
</controls:SettingsAccountLinkExpander.IsLoading>
</controls:SettingsAccountLinkExpander>
</sg:SpacedGrid>
</StackPanel>
</ScrollViewer>
</controls:UserControlBase>

13
StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml.cs

@ -0,0 +1,13 @@
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Settings;
[Singleton]
public partial class AccountSettingsPage : UserControlBase
{
public AccountSettingsPage()
{
InitializeComponent();
}
}

83
StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml

@ -1,16 +1,69 @@
<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: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"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
d:DataContext="{x:Static mocks:DesignData.InferenceSettingsViewModel}"
x:DataType="vmSettings:InferenceSettingsViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StabilityMatrix.Avalonia.Views.Settings.InferenceSettingsPage">
Welcome to Avalonia!
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Settings.InferenceSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
d:DataContext="{x:Static mocks:DesignData.InferenceSettingsViewModel}"
d:DesignHeight="450"
d:DesignWidth="800"
x:DataType="vmSettings:InferenceSettingsViewModel"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,16" Spacing="8">
<!-- Theme -->
<Grid RowDefinitions="Auto,*,*,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Stuff" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Theme}"
IconSource="WeatherMoon">
<ui:SettingsExpander.Footer>
<ComboBox MinWidth="100" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Language}"
IconSource="Character">
<ui:SettingsExpander.Footer>
<ComboBox MinWidth="100" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="3"
Margin="8,0,8,4"
Header="Inference"
IconSource="Code" />
</Grid>
<!-- Checkpoints Manager Options -->
<Grid RowDefinitions="auto,*,Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Other stuff" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
IconSource="Folder">
<ui:SettingsExpander.Footer>
<CheckBox Margin="8" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
</StackPanel>
</ScrollViewer>
</controls:UserControlBase>

10
StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs

@ -1,19 +1,13 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Settings;
[Singleton]
public partial class InferenceSettingsPage : UserControl
public partial class InferenceSettingsPage : UserControlBase
{
public InferenceSettingsPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

572
StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml

@ -0,0 +1,572 @@
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Settings.MainSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:avalonia="https://github.com/projektanker/icons.avalonia"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:inference="clr-namespace:StabilityMatrix.Avalonia.Models.Inference"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:update="clr-namespace:StabilityMatrix.Core.Models.Update;assembly=StabilityMatrix.Core"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
d:DataContext="{x:Static mocks:DesignData.MainSettingsViewModel}"
d:DesignHeight="700"
d:DesignWidth="800"
x:CompileBindings="True"
x:DataType="vmSettings:MainSettingsViewModel"
Focusable="True"
mc:Ignorable="d">
<controls:UserControlBase.Resources>
<converters:CultureInfoDisplayConverter x:Key="CultureInfoDisplayConverter" />
<converters:EnumStringConverter x:Key="EnumStringConverter" />
<converters:EnumToBooleanConverter x:Key="EnumBoolConverter" />
</controls:UserControlBase.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,16" Spacing="8">
<!-- Theme -->
<Grid RowDefinitions="Auto,*,*,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Appearance}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Theme}"
IconSource="WeatherMoon">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
ItemsSource="{Binding AvailableThemes}"
SelectedItem="{Binding SelectedTheme}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Language}"
IconSource="Character">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
DisplayMemberBinding="{Binding Converter={StaticResource CultureInfoDisplayConverter}}"
ItemsSource="{Binding AvailableLanguages}"
SelectedItem="{Binding SelectedLanguage}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="3"
Margin="8,0,8,4"
ActionIconSource="ChevronRight"
Command="{Binding NavigateToSubPageCommand}"
CommandParameter="{x:Type vmSettings:InferenceSettingsViewModel}"
Header="Inference (Test)"
IconSource="Code"
IsClickEnabled="True"
IsVisible="{Binding SharedState.IsDebugMode}" />
</Grid>
<!-- Checkpoints Manager Options -->
<Grid RowDefinitions="auto,*,Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_CheckpointManager}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
Description="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown_Details}"
Header="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown}"
IconSource="Folder">
<ui:SettingsExpander.Footer>
<CheckBox Margin="8" IsChecked="{Binding RemoveSymlinksOnShutdown}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,4"
Description="{x:Static lang:Resources.Label_ResetCheckpointsCache_Details}"
Header="{x:Static lang:Resources.Label_ResetCheckpointsCache}"
IconSource="Refresh">
<ui:SettingsExpander.Footer>
<Button Command="{Binding ResetCheckpointCache}" Content="{x:Static lang:Resources.Label_ResetCheckpointsCache}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Inference UI -->
<Grid Margin="0,8,0,0" RowDefinitions="auto,*,*,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Inference" />
<!-- Auto Completion -->
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="Prompt Auto Completion">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-solid fa-wand-magic-sparkles" />
</ui:SettingsExpander.IconSource>
<!-- Enable toggle -->
<ui:SettingsExpanderItem Content="Enable">
<ui:SettingsExpanderItem.Footer>
<ToggleSwitch IsChecked="{Binding IsPromptCompletionEnabled}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Tag csv selection -->
<ui:SettingsExpanderItem
Content="Tag Source"
Description="Tags to use for completion in .csv format (Compatible with a1111-sd-webui-tagcomplete)"
IconSource="Tag"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<ui:FAComboBox ItemsSource="{Binding AvailableTagCompletionCsvs}" SelectedItem="{Binding SelectedTagCompletionCsv}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Tag csv import -->
<ui:SettingsExpanderItem
Content="Import Tag Source .csv"
IconSource="Add"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding ImportTagCsvCommand}" Content="Import" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Remove underscores -->
<ui:SettingsExpanderItem
Content="Replace underscores with spaces when inserting completions"
IconSource="Underline"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<CheckBox Margin="8" IsChecked="{Binding IsCompletionRemoveUnderscoresEnabled}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- Image Viewer -->
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="Image Viewer"
IconSource="Image">
<!-- Pixel grid -->
<ui:SettingsExpanderItem Content="Show pixel grid at high zoom levels" IconSource="ViewAll">
<ui:SettingsExpanderItem.Footer>
<CheckBox Margin="8" IsChecked="{Binding IsImageViewerPixelGridEnabled}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- Output Image Files -->
<ui:SettingsExpander
Grid.Row="3"
Margin="8,0,8,4"
Header="Output Image Files">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource Symbol="TabDesktopImage" />
</ui:SettingsExpander.IconSource>
<!-- File name pattern -->
<ui:SettingsExpanderItem
Content="File name pattern"
Description="{Binding OutputImageFileNameFormatSample}"
IconSource="Rename">
<ui:SettingsExpanderItem.Footer>
<TextBox
Name="OutputImageFileNameFormatTextBox"
MinWidth="150"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
FontSize="13"
Text="{Binding OutputImageFileNameFormat}"
Watermark="{x:Static inference:FileNameFormat.DefaultTemplate}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:TeachingTip
Title="Format Variables"
Grid.Row="3"
IsOpen="{Binding #OutputImageFileNameFormatTextBox.IsFocused}"
PreferredPlacement="Top"
Target="{Binding #OutputImageFileNameFormatTextBox, Mode=OneWay}">
<DataGrid AutoGenerateColumns="True" ItemsSource="{Binding OutputImageFileNameFormatVars}" />
<!--<mdxaml:MarkdownScrollViewer
Markdown="{Binding OutputImageFileNameFormatGuideMarkdown}"/>-->
</ui:TeachingTip>
</Grid>
<!-- Environment Options -->
<Grid RowDefinitions="Auto, Auto, Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_PackageEnvironment}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
Header="{x:Static lang:Resources.Label_EnvironmentVariables}"
IconSource="OtherUser">
<ui:SettingsExpander.Footer>
<Button Command="{Binding OpenEnvVarsDialogCommand}" Content="{x:Static lang:Resources.Action_Edit}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,4"
Header="{x:Static lang:Resources.Label_EmbeddedPython}">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-python" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="16">
<controls:ProgressRing
BorderThickness="3"
IsEnabled="{Binding CheckPythonVersionCommand.IsRunning}"
IsIndeterminate="True"
IsVisible="{Binding CheckPythonVersionCommand.IsRunning}" />
<Button Command="{Binding CheckPythonVersionCommand}" Content="{x:Static lang:Resources.Action_CheckVersion}" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Integrations -->
<sg:SpacedGrid RowDefinitions="Auto,*,*" RowSpacing="4">
<TextBlock
Margin="0,0,0,4"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Integrations}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
ActionIconSource="ChevronRight"
Command="{Binding NavigateToSubPageCommand}"
CommandParameter="{x:Type vmSettings:AccountSettingsViewModel}"
Header="{x:Static lang:Resources.Label_Accounts}"
IsClickEnabled="True">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource
FontSize="10"
IsFilled="True"
Symbol="Person" />
</ui:SettingsExpander.IconSource>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_DiscordRichPresence}">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-discord" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsDiscordRichPresenceEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</sg:SpacedGrid>
<!-- System Options -->
<sg:SpacedGrid RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="4">
<TextBlock
Margin="0,0,0,4"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_System}" />
<!-- Updates page -->
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
ActionIconSource="ChevronRight"
Command="{Binding NavigateToSubPageCommand}"
CommandParameter="{x:Type vmSettings:UpdateSettingsViewModel}"
Header="{x:Static lang:Resources.Label_Updates}"
IsClickEnabled="True">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource
FontSize="10"
IsFilled="True"
Symbol="ArrowSync" />
</ui:SettingsExpander.IconSource>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0"
Description="{x:Static lang:Resources.Label_AddToStartMenu_Details}"
Header="{x:Static lang:Resources.Label_AddToStartMenu}"
IconSource="StarAdd"
ToolTip.Tip="{OnPlatform Default={x:Static lang:Resources.Label_OnlyAvailableOnWindows},
Windows={x:Null}}">
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="8">
<controls:ProgressRing
BorderThickness="3"
IsEnabled="{Binding IsVisible, RelativeSource={RelativeSource Self}}"
IsIndeterminate="True">
<controls:ProgressRing.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="AddToStartMenuCommand.IsRunning" />
<Binding Path="AddToGlobalStartMenuCommand.IsRunning" />
</MultiBinding>
</controls:ProgressRing.IsVisible>
</controls:ProgressRing>
<SplitButton
Command="{Binding AddToStartMenuCommand}"
Content="{x:Static lang:Resources.Action_AddForCurrentUser}"
IsEnabled="{OnPlatform Default=False,
Windows=True}">
<SplitButton.Flyout>
<ui:FAMenuFlyout Placement="Bottom">
<ui:MenuFlyoutItem
Command="{Binding AddToGlobalStartMenuCommand}"
IconSource="Admin"
Text="{x:Static lang:Resources.Action_AddForAllUsers}" />
</ui:FAMenuFlyout>
</SplitButton.Flyout>
</SplitButton>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="3"
Margin="8,0"
Description="{x:Static lang:Resources.Label_SelectNewDataDirectory_Details}"
IconSource="MoveToFolder">
<ui:SettingsExpander.Header>
<StackPanel Orientation="Vertical">
<TextBlock Text="{x:Static lang:Resources.Label_SelectNewDataDirectory}" />
<TextBlock FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}">
<Run Text="{x:Static lang:Resources.Label_CurrentDirectory}" />
<Run Text="{Binding DataDirectory}" />
</TextBlock>
</StackPanel>
</ui:SettingsExpander.Header>
<ui:SettingsExpander.Footer>
<Button Command="{Binding PickNewDataDirectory}">
<Grid ColumnDefinitions="Auto, Auto">
<avalonia:Icon
Grid.Row="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Value="fa-solid fa-folder-open" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Action_SelectDirectory}" />
</Grid>
</Button>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</sg:SpacedGrid>
<!-- Debug Options -->
<Grid IsVisible="{Binding SharedState.IsDebugMode}" RowDefinitions="auto,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Debug Options" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,0"
Command="{Binding LoadDebugInfo}"
Header="Debug Options"
IconSource="Code">
<ui:SettingsExpanderItem
Margin="4,0"
Description="Paths"
IconSource="Folder">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugPaths}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="Compat Info"
IconSource="StarFilled">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugCompatInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="GPU Info"
IconSource="FullScreenMaximize">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugGpuInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Animation Scale"
Description="Lower values = faster animations. 0x means animations are instant."
IconSource="Clock">
<ui:SettingsExpanderItem.Footer>
<ComboBox ItemsSource="{Binding AnimationScaleOptions}" SelectedItem="{Binding SelectedAnimationScale}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding}" /><Run Text="x" />
</TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Notification"
IconSource="CommentAdd">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugNotificationCommand}" Content="New Notification" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Content Dialog"
IconSource="NewWindow">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugContentDialogCommand}" Content="Show Dialog" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Exceptions"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugThrowExceptionCommand}" Content="Unhandled Exception" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Download Manager tests"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugTrackedDownloadCommand}"
Content="Add Tracked Download" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Refresh Models Index"
IconSource="SyncFolder">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugRefreshModelsIndexCommand}"
Content="Refresh Index" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Make image grid"
IconSource="Image">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugMakeImageGridCommand}"
Content="Select images" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Image metadata parser"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugImageMetadataCommand}"
Content="Choose image" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Grid>
<!-- TODO: Directories card -->
<Grid RowDefinitions="auto,*">
<StackPanel
Grid.Row="1"
HorizontalAlignment="Left"
Orientation="Vertical">
<TextBlock
Margin="0,8"
FontSize="15"
FontWeight="Bold"
Text="{x:Static lang:Resources.Label_About}" />
<Image
Width="112"
Height="112"
Margin="8"
HorizontalAlignment="Left"
Source="/Assets/Icon.png" />
<TextBlock
Margin="8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_StabilityMatrix}" />
<Panel>
<Button
Name="VersionButton"
Margin="8,0,8,8"
Padding="2,0,2,0"
BorderThickness="0"
Classes="transparent"
Command="{Binding OnVersionClick}"
Content="{Binding AppVersion}" />
<ui:TeachingTip
Title="{Binding VersionFlyoutText}"
IsOpen="{Binding IsVersionTapTeachingTipOpen}"
PreferredPlacement="RightTop"
Target="{Binding #VersionButton}" />
</Panel>
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal">
<Button
Margin="8"
HorizontalAlignment="Left"
Command="{Binding ShowLicensesDialogCommand}"
Content="{x:Static lang:Resources.Label_LicenseAndOpenSourceNotices}" />
</StackPanel>
</StackPanel>
</Grid>
<!-- Extra space at the bottom -->
<Panel Margin="0,0,0,16" />
</StackPanel>
</ScrollViewer>
</controls:UserControlBase>

13
StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml.cs

@ -0,0 +1,13 @@
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Settings;
[Singleton]
public partial class MainSettingsPage : UserControlBase
{
public MainSettingsPage()
{
InitializeComponent();
}
}

154
StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml

@ -0,0 +1,154 @@
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Settings.UpdateSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
d:DataContext="{x:Static mocks:DesignData.UpdateSettingsViewModel}"
d:DesignHeight="550"
d:DesignWidth="800"
x:DataType="vmSettings:UpdateSettingsViewModel"
mc:Ignorable="d">
<controls:UserControlBase.Resources>
<converters:BooleanChoiceMultiConverter x:Key="BoolChoiceMultiConverter" />
</controls:UserControlBase.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16,16,24,16" Spacing="2">
<sg:SpacedGrid Margin="0,4,0,16" ColumnDefinitions="*,Auto">
<StackPanel Orientation="Horizontal" Spacing="16">
<fluentIcons:SymbolIcon
Width="64"
Height="64"
FontSize="64"
IsFilled="True"
Symbol="ArrowSync" />
<StackPanel VerticalAlignment="Center">
<TextBlock
Margin="-1,0,0,0"
HorizontalAlignment="Left"
Text="{Binding HeaderText}"
Theme="{DynamicResource SubtitleTextBlockStyle}" />
<TextBlock
HorizontalAlignment="Left"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Text="{Binding SubtitleText}" />
</StackPanel>
</StackPanel>
<Button
Grid.Column="1"
Classes="accent"
Content="{x:Static lang:Resources.Action_CheckForUpdates}"
Command="{Binding CheckForUpdatesCommand}"
HorizontalAlignment="Right">
</Button>
</sg:SpacedGrid>
<!-- Auto updates toggle -->
<ui:SettingsExpander
Description="Periodically checks for updates"
Header="Notify when updates are ready to install">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource Symbol="Megaphone" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsAutoCheckUpdatesEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- Channel radio buttons -->
<ui:SettingsExpander Header="Preferred Update Channel" IsExpanded="True">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource Symbol="Branch" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem>
<ListBox
SelectionChanged="ChannelListBox_OnSelectionChanged"
IsEnabled="{Binding IsAutoCheckUpdatesEnabled}"
ItemContainerTheme="{DynamicResource ListBoxItemBorderlessTheme}"
ItemsSource="{Binding AvailableUpdateChannelCards}"
SelectedItem="{Binding SelectedUpdateChannelCard}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type models:UpdateChannelCard}">
<RadioButton
GroupName="UpdateChannel"
IsChecked="{Binding $parent[ListBoxItem].IsSelected}" >
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock
VerticalAlignment="Center"
Text="{Binding DisplayName}">
<TextBlock.Foreground>
<MultiBinding Converter="{StaticResource BoolChoiceMultiConverter}">
<Binding Path="IsSelectable" />
<DynamicResource ResourceKey="TextFillColorPrimaryBrush"/>
<DynamicResource ResourceKey="TextFillColorTertiaryBrush"/>
</MultiBinding>
</TextBlock.Foreground>
</TextBlock>
<Ellipse
HorizontalAlignment="Left"
VerticalAlignment="Center"
IsVisible="{Binding LatestVersionString, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Width="4"
Height="4"
Fill="{DynamicResource TextFillColorTertiaryBrush}" />
<TextBlock
VerticalAlignment="Center"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
IsVisible="{Binding LatestVersionString, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Text="{Binding LatestVersionString}" />
<!-- Update available icon -->
<fluentIcons:SymbolIcon
ToolTip.Tip="{x:Static lang:Resources.Label_UpdateAvailable}"
IsVisible="{Binding IsLatestVersionUpdateable}"
VerticalAlignment="Center"
FontSize="16"
Foreground="#3592c4"
Symbol="ArrowCircleUpRight" />
</StackPanel>
<TextBlock
FontSize="13"
TextAlignment="Start"
Margin="0,0,0,0"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{Binding Description}"
Theme="{DynamicResource CaptionTextBlockStyle}" />
</StackPanel>
</RadioButton>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="8" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</controls:UserControlBase>

41
StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml.cs

@ -0,0 +1,41 @@
using System.Linq;
using Avalonia.Controls;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.ViewModels.Settings;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models.Update;
namespace StabilityMatrix.Avalonia.Views.Settings;
[Singleton]
public partial class UpdateSettingsPage : UserControlBase
{
public UpdateSettingsPage()
{
InitializeComponent();
}
private void ChannelListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
var listBox = (ListBox)sender!;
if (e.AddedItems.Count == 0 || e.AddedItems[0] is not UpdateChannelCard item)
{
return;
}
var vm = (UpdateSettingsViewModel)DataContext!;
if (!vm.VerifyChannelSelection(item))
{
listBox.Selection.Clear();
listBox.Selection.SelectedItem = vm.AvailableUpdateChannelCards.First(
c => c.UpdateChannel == UpdateChannel.Stable
);
vm.ShowLoginRequiredDialog();
}
}
}

541
StabilityMatrix.Avalonia/Views/SettingsPage.axaml

@ -2,19 +2,14 @@
x:Class="StabilityMatrix.Avalonia.Views.SettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit"
xmlns:inference="clr-namespace:StabilityMatrix.Avalonia.Models.Inference"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
xmlns:vmBase="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Base"
xmlns:local="clr-namespace:StabilityMatrix.Avalonia"
Focusable="True"
d:DataContext="{x:Static mocks:DesignData.SettingsViewModel}"
d:DesignHeight="700"
@ -24,513 +19,33 @@
mc:Ignorable="d">
<controls:UserControlBase.Resources>
<converters:CultureInfoDisplayConverter x:Key="CultureInfoDisplayConverter" />
<x:Double x:Key="BreadcrumbBarItemThemeFontSize">24</x:Double>
<x:Double x:Key="BreadcrumbBarChevronFontSize">17</x:Double>
<Thickness x:Key="BreadcrumbBarChevronPadding">6,3</Thickness>
<FontWeight x:Key="BreadcrumbBarItemFontWeight">Medium</FontWeight>
</controls:UserControlBase.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,16" Spacing="8">
<!-- Theme -->
<Grid RowDefinitions="Auto,*,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Appearance}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Theme}"
IconSource="WeatherMoon">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
ItemsSource="{Binding AvailableThemes}"
SelectedItem="{Binding SelectedTheme}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Language}"
IconSource="Character">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
ItemsSource="{Binding AvailableLanguages}"
DisplayMemberBinding="{Binding Converter={StaticResource CultureInfoDisplayConverter}}"
SelectedItem="{Binding SelectedLanguage}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Checkpoints Manager Options -->
<Grid RowDefinitions="auto,*,Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_CheckpointManager}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
Description="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown_Details}"
Header="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown}"
IconSource="Folder">
<ui:SettingsExpander.Footer>
<CheckBox Margin="8" IsChecked="{Binding RemoveSymlinksOnShutdown}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,4"
Description="{x:Static lang:Resources.Label_ResetCheckpointsCache_Details}"
Header="{x:Static lang:Resources.Label_ResetCheckpointsCache}"
IconSource="Refresh">
<ui:SettingsExpander.Footer>
<Button
Command="{Binding ResetCheckpointCache}"
Content="{x:Static lang:Resources.Label_ResetCheckpointsCache}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Inference UI -->
<Grid Margin="0,8,0,0" RowDefinitions="auto,*,*,*">
<TextBlock
FontWeight="Medium"
Text="Inference"
Margin="0,0,0,8" />
<!-- Auto Completion -->
<ui:SettingsExpander Grid.Row="1"
Header="Prompt Auto Completion"
Margin="8,0,8,4">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-solid fa-wand-magic-sparkles"/>
</ui:SettingsExpander.IconSource>
<!-- Enable toggle -->
<ui:SettingsExpanderItem Content="Enable">
<ui:SettingsExpanderItem.Footer>
<ToggleSwitch
IsChecked="{Binding IsPromptCompletionEnabled}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Tag csv selection -->
<ui:SettingsExpanderItem Content="Tag Source"
IconSource="Tag"
IsEnabled="{Binding IsPromptCompletionEnabled}"
Description="Tags to use for completion in .csv format (Compatible with a1111-sd-webui-tagcomplete)">
<ui:SettingsExpanderItem.Footer>
<ui:FAComboBox
ItemsSource="{Binding AvailableTagCompletionCsvs}"
SelectedItem="{Binding SelectedTagCompletionCsv}"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Tag csv import -->
<ui:SettingsExpanderItem Content="Import Tag Source .csv"
IconSource="Add"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<Button
Command="{Binding ImportTagCsvCommand}"
Content="Import"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Remove underscores -->
<ui:SettingsExpanderItem
Content="Replace underscores with spaces when inserting completions"
IconSource="Underline"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<CheckBox Margin="8"
IsChecked="{Binding IsCompletionRemoveUnderscoresEnabled}"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- Image Viewer -->
<ui:SettingsExpander Grid.Row="2"
Header="Image Viewer"
IconSource="Image"
Margin="8,0,8,4">
<!-- Pixel grid -->
<ui:SettingsExpanderItem
Content="Show pixel grid at high zoom levels"
IconSource="ViewAll">
<ui:SettingsExpanderItem.Footer>
<CheckBox Margin="8"
IsChecked="{Binding IsImageViewerPixelGridEnabled}"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- Output Image Files -->
<ui:SettingsExpander Grid.Row="3"
Header="Output Image Files"
Margin="8,0,8,4">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource Symbol="TabDesktopImage"/>
</ui:SettingsExpander.IconSource>
<!-- File name pattern -->
<ui:SettingsExpanderItem
Content="File name pattern"
Description="{Binding OutputImageFileNameFormatSample}"
IconSource="Rename">
<ui:SettingsExpanderItem.Footer>
<TextBox
Name="OutputImageFileNameFormatTextBox"
Watermark="{x:Static inference:FileNameFormat.DefaultTemplate}"
FontSize="13"
MinWidth="150"
Text="{Binding OutputImageFileNameFormat}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:TeachingTip
IsOpen="{Binding #OutputImageFileNameFormatTextBox.IsFocused}"
Target="{Binding #OutputImageFileNameFormatTextBox, Mode=OneWay}"
PreferredPlacement="Top"
Title="Format Variables"
Grid.Row="3">
<DataGrid
AutoGenerateColumns="True"
ItemsSource="{Binding OutputImageFileNameFormatVars}" />
<!--<mdxaml:MarkdownScrollViewer
Markdown="{Binding OutputImageFileNameFormatGuideMarkdown}"/>-->
</ui:TeachingTip>
</Grid>
<!-- Environment Options -->
<Grid RowDefinitions="Auto, Auto, Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_PackageEnvironment}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
Header="{x:Static lang:Resources.Label_EnvironmentVariables}"
IconSource="OtherUser">
<ui:SettingsExpander.Footer>
<Button
Command="{Binding OpenEnvVarsDialogCommand}"
Content="{x:Static lang:Resources.Action_Edit}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,4"
Header="{x:Static lang:Resources.Label_EmbeddedPython}">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-python" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="16">
<controls:ProgressRing
BorderThickness="3"
IsEnabled="{Binding CheckPythonVersionCommand.IsRunning}"
IsIndeterminate="True"
IsVisible="{Binding CheckPythonVersionCommand.IsRunning}" />
<Button Command="{Binding CheckPythonVersionCommand}" Content="{x:Static lang:Resources.Action_CheckVersion}" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Integrations -->
<Grid RowDefinitions="auto,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Integrations}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_DiscordRichPresence}">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-discord" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsDiscordRichPresenceEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- System Options -->
<Grid RowDefinitions="auto, auto, auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_System}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Description="{x:Static lang:Resources.Label_AddToStartMenu_Details}"
Header="{x:Static lang:Resources.Label_AddToStartMenu}"
IconSource="StarAdd"
ToolTip.Tip="{OnPlatform Default={x:Static lang:Resources.Label_OnlyAvailableOnWindows}, Windows={x:Null}}">
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="8">
<controls:ProgressRing
BorderThickness="3"
IsEnabled="{Binding IsVisible, RelativeSource={RelativeSource Self}}"
IsIndeterminate="True">
<controls:ProgressRing.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="AddToStartMenuCommand.IsRunning" />
<Binding Path="AddToGlobalStartMenuCommand.IsRunning" />
</MultiBinding>
</controls:ProgressRing.IsVisible>
</controls:ProgressRing>
<SplitButton
Command="{Binding AddToStartMenuCommand}"
Content="{x:Static lang:Resources.Action_AddForCurrentUser}"
IsEnabled="{OnPlatform Default=False, Windows=True}">
<SplitButton.Flyout>
<ui:FAMenuFlyout Placement="Bottom">
<ui:MenuFlyoutItem
Command="{Binding AddToGlobalStartMenuCommand}"
IconSource="Admin"
Text="{x:Static lang:Resources.Action_AddForAllUsers}" />
</ui:FAMenuFlyout>
</SplitButton.Flyout>
</SplitButton>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0"
Description="{x:Static lang:Resources.Label_SelectNewDataDirectory_Details}"
IconSource="MoveToFolder">
<ui:SettingsExpander.Header>
<StackPanel Orientation="Vertical">
<TextBlock Text="{x:Static lang:Resources.Label_SelectNewDataDirectory}"/>
<TextBlock Foreground="{DynamicResource TextFillColorSecondaryBrush}"
FontSize="12">
<Run Text="{x:Static lang:Resources.Label_CurrentDirectory}"/>
<Run Text="{Binding DataDirectory}"/>
</TextBlock>
</StackPanel>
</ui:SettingsExpander.Header>
<ui:SettingsExpander.Footer>
<Button Command="{Binding PickNewDataDirectory}">
<Grid ColumnDefinitions="Auto, Auto">
<avalonia:Icon
Grid.Row="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Value="fa-solid fa-folder-open" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Action_SelectDirectory}" />
</Grid>
</Button>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Debug Options -->
<Grid
IsVisible="{Binding SharedState.IsDebugMode}"
RowDefinitions="auto,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Debug Options" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,0"
Command="{Binding LoadDebugInfo}"
Header="Debug Options"
IconSource="Code">
<ui:SettingsExpanderItem
Margin="4,0"
Description="Paths"
IconSource="Folder">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugPaths}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="Compat Info"
IconSource="StarFilled">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugCompatInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="GPU Info"
IconSource="FullScreenMaximize">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugGpuInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Animation Scale"
Description="Lower values = faster animations. 0x means animations are instant."
IconSource="Clock">
<ui:SettingsExpanderItem.Footer>
<ComboBox ItemsSource="{Binding AnimationScaleOptions}" SelectedItem="{Binding SelectedAnimationScale}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding}" /><Run Text="x" />
</TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Notification"
IconSource="CommentAdd">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugNotificationCommand}" Content="New Notification" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Content Dialog"
IconSource="NewWindow">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugContentDialogCommand}" Content="Show Dialog" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Exceptions"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugThrowExceptionCommand}" Content="Unhandled Exception" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Download Manager tests"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugTrackedDownloadCommand}"
Content="Add Tracked Download" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Refresh Models Index"
IconSource="SyncFolder">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugRefreshModelsIndexCommand}"
Content="Refresh Index" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Make image grid"
IconSource="Image">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugMakeImageGridCommand}"
Content="Select images" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Image metadata parser"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugImageMetadataCommand}"
Content="Choose image" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Grid>
<!-- TODO: Directories card -->
<Grid RowDefinitions="auto,*">
<StackPanel
Grid.Row="1"
HorizontalAlignment="Left"
Orientation="Vertical">
<TextBlock
Margin="0,8"
FontSize="15"
FontWeight="Bold"
Text="{x:Static lang:Resources.Label_About}" />
<Image
Width="112"
Height="112"
Margin="8"
HorizontalAlignment="Left"
Source="/Assets/Icon.png" />
<TextBlock
Margin="8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_StabilityMatrix}" />
<Panel>
<Button
Name="VersionButton"
Margin="8,0,8,8"
Padding="2,0,2,0"
BorderThickness="0"
Classes="transparent"
Command="{Binding OnVersionClick}"
Content="{Binding AppVersion}" />
<ui:TeachingTip
Title="{Binding VersionFlyoutText}"
IsOpen="{Binding IsVersionTapTeachingTipOpen}"
PreferredPlacement="RightTop"
Target="{Binding #VersionButton}" />
</Panel>
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal">
<Button
Margin="8"
HorizontalAlignment="Left"
Command="{Binding ShowLicensesDialogCommand}"
Content="{x:Static lang:Resources.Label_LicenseAndOpenSourceNotices}" />
</StackPanel>
</StackPanel>
</Grid>
<!-- Extra space at the bottom -->
<Panel Margin="0,0,0,16" />
</StackPanel>
</ScrollViewer>
<Grid RowDefinitions="Auto,*">
<ui:BreadcrumbBar
Grid.Row="0"
Margin="16,8"
x:Name="BreadcrumbBar"
ItemsSource="{Binding CurrentPagePath}">
<ui:BreadcrumbBar.ItemTemplate>
<DataTemplate x:DataType="vmBase:PageViewModelBase">
<ui:BreadcrumbBarItem Content="{Binding Title}" />
</DataTemplate>
</ui:BreadcrumbBar.ItemTemplate>
</ui:BreadcrumbBar>
<ui:Frame
Grid.Row="1"
Name="FrameView">
<ui:Frame.NavigationPageFactory>
<local:ViewLocator/>
</ui:Frame.NavigationPageFactory>
</ui:Frame>
</Grid>
</controls:UserControlBase>

87
StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs

@ -1,5 +1,19 @@
using Avalonia.Markup.Xaml;
using System;
using System.ComponentModel;
using System.Linq;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using FluentAvalonia.UI.Navigation;
using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views;
@ -7,13 +21,80 @@ namespace StabilityMatrix.Avalonia.Views;
[Singleton]
public partial class SettingsPage : UserControlBase
{
private readonly INavigationService<SettingsViewModel> settingsNavigationService;
private bool hasLoaded;
private SettingsViewModel ViewModel => (SettingsViewModel)DataContext!;
[DesignOnly(true)]
[Obsolete("For XAML use only", true)]
public SettingsPage()
: this(App.Services.GetRequiredService<INavigationService<SettingsViewModel>>()) { }
public SettingsPage(INavigationService<SettingsViewModel> settingsNavigationService)
{
this.settingsNavigationService = settingsNavigationService;
InitializeComponent();
settingsNavigationService.SetFrame(FrameView);
settingsNavigationService.TypedNavigation += NavigationService_OnTypedNavigation;
FrameView.Navigated += FrameView_Navigated;
BreadcrumbBar.ItemClicked += BreadcrumbBar_ItemClicked;
}
private void InitializeComponent()
/// <inheritdoc />
protected override void OnLoaded(RoutedEventArgs e)
{
AvaloniaXamlLoader.Load(this);
base.OnLoaded(e);
if (!hasLoaded)
{
// Initial load, navigate to first page
Dispatcher.UIThread.Post(
() =>
settingsNavigationService.NavigateTo(
ViewModel.SubPages[0],
new SuppressNavigationTransitionInfo()
)
);
hasLoaded = true;
}
}
private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e)
{
ViewModel.CurrentPage = ViewModel.SubPages.FirstOrDefault(
x => x.GetType() == e.ViewModelType
);
}
private async void FrameView_Navigated(object? sender, NavigationEventArgs args)
{
if (args.Content is not PageViewModelBase vm)
{
return;
}
ViewModel.CurrentPage = vm;
}
private async void BreadcrumbBar_ItemClicked(
BreadcrumbBar sender,
BreadcrumbBarItemClickedEventArgs args
)
{
// Skip if already on same page
if (args.Item is not PageViewModelBase viewModel || viewModel == ViewModel.CurrentPage)
{
return;
}
settingsNavigationService.NavigateTo(
viewModel,
BetterSlideNavigationTransition.PageSlideFromLeft
);
}
}

55
StabilityMatrix.Core/Api/ICivitTRPCApi.cs

@ -0,0 +1,55 @@
using Refit;
using StabilityMatrix.Core.Models.Api.CivitTRPC;
namespace StabilityMatrix.Core.Api;
[Headers(
"Content-Type: application/x-www-form-urlencoded",
"Referer: https://civitai.com",
"Origin: https://civitai.com"
)]
public interface ICivitTRPCApi
{
[QueryUriFormat(UriFormat.UriEscaped)]
[Get("/api/trpc/userProfile.get")]
Task<CivitUserProfileResponse> GetUserProfile(
[Query] CivitUserProfileRequest input,
[Authorize] string bearerToken,
CancellationToken cancellationToken = default
);
[QueryUriFormat(UriFormat.UriEscaped)]
[Get("/api/trpc/buzz.getUserAccount")]
Task<CivitTrpcResponse<CivitUserAccountResponse>> GetUserAccount(
[Query] string input,
[Authorize] string bearerToken,
CancellationToken cancellationToken = default
);
Task<CivitTrpcResponse<CivitUserAccountResponse>> GetUserAccountDefault(
string bearerToken,
CancellationToken cancellationToken = default
)
{
return GetUserAccount(
"{\"json\":null,\"meta\":{\"values\":[\"undefined\"]}}",
bearerToken,
cancellationToken
);
}
[QueryUriFormat(UriFormat.UriEscaped)]
[Get("/api/trpc/user.getById")]
Task<CivitTrpcResponse<CivitGetUserByIdResponse>> GetUserById(
[Query] CivitGetUserByIdRequest input,
[Authorize] string bearerToken,
CancellationToken cancellationToken = default
);
[Post("/api/trpc/user.toggleFavoriteModel")]
Task<HttpResponseMessage> ToggleFavoriteModel(
[Body] CivitUserToggleFavoriteModelRequest request,
[Authorize] string bearerToken,
CancellationToken cancellationToken = default
);
}

73
StabilityMatrix.Core/Api/ILykosAuthApi.cs

@ -0,0 +1,73 @@
using System.Net;
using Refit;
using StabilityMatrix.Core.Models.Api.Lykos;
namespace StabilityMatrix.Core.Api;
[Headers("User-Agent: StabilityMatrix")]
public interface ILykosAuthApi
{
[Headers("Authorization: Bearer")]
[Get("/api/Users/{email}")]
Task<GetUserResponse> GetUser(string email, CancellationToken cancellationToken = default);
[Headers("Authorization: Bearer")]
[Get("/api/Users/me")]
Task<GetUserResponse> GetUserSelf(CancellationToken cancellationToken = default);
[Post("/api/Accounts")]
Task<LykosAccountTokens> PostAccount(
[Body] PostAccountRequest request,
CancellationToken cancellationToken = default
);
[Post("/api/Login")]
Task<LykosAccountTokens> PostLogin(
[Body] PostLoginRequest request,
CancellationToken cancellationToken = default
);
[Post("/api/Login/Refresh")]
Task<LykosAccountTokens> PostLoginRefresh(
[Body] PostLoginRefreshRequest request,
CancellationToken cancellationToken = default
);
[Headers("Authorization: Bearer")]
[Get("/api/oauth/patreon/redirect")]
Task<HttpResponseMessage> GetPatreonOAuthRedirect(
string redirectUrl,
CancellationToken cancellationToken = default
);
public async Task<string> GetPatreonOAuthUrl(
string redirectUrl,
CancellationToken cancellationToken = default
)
{
var result = await GetPatreonOAuthRedirect(redirectUrl, cancellationToken)
.ConfigureAwait(false);
if (result.StatusCode != HttpStatusCode.Redirect)
{
result.EnsureSuccessStatusCode();
throw new InvalidOperationException(
$"Expected a redirect 302 response, got {result.StatusCode}"
);
}
return result.Headers.Location?.ToString()
?? throw new InvalidOperationException("Expected a redirect URL, but got none");
}
[Headers("Authorization: Bearer")]
[Delete("/api/oauth/patreon")]
Task DeletePatreonOAuth(CancellationToken cancellationToken = default);
[Headers("Authorization: Bearer")]
[Get("/api/files/download")]
Task<GetFilesDownloadResponse> GetFilesDownload(
string path,
CancellationToken cancellationToken = default
);
}

7
StabilityMatrix.Core/Api/ITokenProvider.cs

@ -0,0 +1,7 @@
namespace StabilityMatrix.Core.Api;
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync();
Task<(string AccessToken, string RefreshToken)> RefreshTokensAsync();
}

52
StabilityMatrix.Core/Api/LykosAuthTokenProvider.cs

@ -0,0 +1,52 @@
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models.Api.Lykos;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Core.Api;
[Singleton]
public class LykosAuthTokenProvider : ITokenProvider
{
private readonly ISecretsManager secretsManager;
private readonly Lazy<ILykosAuthApi> lazyLykosAuthApi;
public LykosAuthTokenProvider(
Lazy<ILykosAuthApi> lazyLykosAuthApi,
ISecretsManager secretsManager
)
{
// Lazy as instantiating requires the current class to be instantiated.
this.lazyLykosAuthApi = lazyLykosAuthApi;
this.secretsManager = secretsManager;
}
/// <inheritdoc />
public async Task<string> GetAccessTokenAsync()
{
var secrets = await secretsManager.SafeLoadAsync().ConfigureAwait(false);
return secrets.LykosAccount?.AccessToken ?? "";
}
/// <inheritdoc />
public async Task<(string AccessToken, string RefreshToken)> RefreshTokensAsync()
{
var secrets = await secretsManager.SafeLoadAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(secrets.LykosAccount?.RefreshToken))
{
throw new InvalidOperationException("No refresh token found");
}
var lykosAuthApi = lazyLykosAuthApi.Value;
var newTokens = await lykosAuthApi
.PostLoginRefresh(new PostLoginRefreshRequest(secrets.LykosAccount.RefreshToken))
.ConfigureAwait(false);
secrets = secrets with { LykosAccount = newTokens };
await secretsManager.SaveAsync(secrets).ConfigureAwait(false);
return (newTokens.AccessToken, newTokens.RefreshToken);
}
}

69
StabilityMatrix.Core/Api/TokenAuthHeaderHandler.cs

@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Headers;
using NLog;
using Polly;
using Polly.Retry;
using StabilityMatrix.Core.Helper;
namespace StabilityMatrix.Core.Api;
public class TokenAuthHeaderHandler : DelegatingHandler
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly AsyncRetryPolicy<HttpResponseMessage> policy;
private readonly ITokenProvider tokenProvider;
public TokenAuthHeaderHandler(ITokenProvider tokenProvider)
{
this.tokenProvider = tokenProvider;
policy = Policy
.HandleResult<HttpResponseMessage>(
r =>
r.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
&& r.RequestMessage?.Headers.Authorization
is { Scheme: "Bearer", Parameter: not null }
)
.RetryAsync(
async (result, _) =>
{
var oldToken = ObjectHash.GetStringSignature(
await tokenProvider.GetAccessTokenAsync().ConfigureAwait(false)
);
Logger.Info(
"Refreshing access token for status ({StatusCode})",
result.Result.StatusCode
);
var (newToken, _) = await tokenProvider
.RefreshTokensAsync()
.ConfigureAwait(false);
Logger.Info(
"Access token refreshed: {OldToken} -> {NewToken}",
ObjectHash.GetStringSignature(oldToken),
ObjectHash.GetStringSignature(newToken)
);
}
);
// InnerHandler must be left as null when using DI, but must be assigned a value when
// using RestService.For<IMyApi>
// InnerHandler = new HttpClientHandler();
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken
)
{
return policy.ExecuteAsync(async () =>
{
var accessToken = await tokenProvider.GetAccessTokenAsync().ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
});
}
}

11
StabilityMatrix.Core/Attributes/SingletonAttribute.cs

@ -3,16 +3,25 @@
namespace StabilityMatrix.Core.Attributes;
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
[AttributeUsage(AttributeTargets.Class)]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public class SingletonAttribute : Attribute
{
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
public Type? InterfaceType { get; init; }
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
public Type? ImplType { get; init; }
public SingletonAttribute() { }
public SingletonAttribute(Type interfaceType)
{
InterfaceType = interfaceType;
}
public SingletonAttribute(Type interfaceType, Type implType)
{
InterfaceType = implType;
ImplType = implType;
}
}

12
StabilityMatrix.Core/Exceptions/AppException.cs

@ -5,5 +5,17 @@ namespace StabilityMatrix.Core.Exceptions;
/// </summary>
public class AppException : ApplicationException
{
public override string Message { get; }
public string? Details { get; init; }
public AppException(string message)
{
Message = message;
}
public AppException(string message, string details)
{
Message = message;
Details = details;
}
}

24
StabilityMatrix.Core/Extensions/SemVersionExtensions.cs

@ -0,0 +1,24 @@
using Semver;
namespace StabilityMatrix.Core.Extensions;
public static class SemVersionExtensions
{
public static string ToDisplayString(this SemVersion version)
{
var versionString = $"{version.Major}.{version.Minor}.{version.Patch}";
// Add the build metadata if we have pre-release information
if (version.PrereleaseIdentifiers.Count > 0)
{
versionString += $"-{version.Prerelease}";
if (!string.IsNullOrWhiteSpace(version.Metadata))
{
// First 7 characters of the commit hash
versionString += $"+{version.Metadata[..7]}";
}
}
return versionString;
}
}

25
StabilityMatrix.Core/Extensions/UriExtensions.cs

@ -0,0 +1,25 @@
using System.Web;
namespace StabilityMatrix.Core.Extensions;
public static class UriExtensions
{
public static Uri WithQuery(this Uri uri, string key, string value)
{
var builder = new UriBuilder(uri);
var query = HttpUtility.ParseQueryString(builder.Query);
query[key] = value;
builder.Query = query.ToString() ?? string.Empty;
return builder.Uri;
}
public static Uri Append(this Uri uri, params string[] paths)
{
return new Uri(
paths.Aggregate(
uri.AbsoluteUri,
(current, path) => $"{current.TrimEnd('/')}/{path.TrimStart('/')}"
)
);
}
}

5
StabilityMatrix.Core/Helper/CodeTimer.cs

@ -11,6 +11,7 @@ public class CodeTimer : IDisposable
private readonly string name;
private readonly Stopwatch stopwatch;
private bool isDisposed;
private CodeTimer? ParentTimer { get; }
private List<CodeTimer> SubTimers { get; } = new();
@ -97,6 +98,9 @@ public class CodeTimer : IDisposable
public void Dispose()
{
if (isDisposed)
return;
stopwatch.Stop();
// Remove ourselves from the stack
@ -119,6 +123,7 @@ public class CodeTimer : IDisposable
SubTimers.Clear();
}
isDisposed = true;
GC.SuppressFinalize(this);
}
}

14
StabilityMatrix.Core/Helper/Compat.cs

@ -51,7 +51,7 @@ public static class Compat
/// <summary>
/// Set AppDataHome to a custom path. Used for testing.
/// </summary>
internal static void SetAppDataHome(string path)
public static void SetAppDataHome(string path)
{
AppDataHome = path;
}
@ -136,17 +136,7 @@ public static class Compat
AppData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (
Environment.GetEnvironmentVariable("STABILITY_MATRIX_APPDATAHOME") is
{ } appDataOverride
)
{
AppDataHome = appDataOverride;
}
else
{
AppDataHome = AppData + AppName;
}
AppDataHome = AppData + AppName;
}
/// <summary>

60
StabilityMatrix.Core/Helper/FileTransfers.cs

@ -190,35 +190,45 @@ public static class FileTransfers
var sourceFile = sourceDir.JoinFile(file.Name);
var destinationFile = destinationDir.JoinFile(file.Name);
if (destinationFile.Exists)
await MoveFileAsync(sourceFile, destinationFile, overwrite, overwriteIfHashMatches)
.ConfigureAwait(false);
}
}
public static async Task MoveFileAsync(
FilePath sourceFile,
FilePath destinationFile,
bool overwrite = false,
bool overwriteIfHashMatches = false
)
{
if (destinationFile.Exists)
{
if (overwriteIfHashMatches)
{
if (overwriteIfHashMatches)
{
// Check if files hashes are the same
var sourceHash = await FileHash
.GetBlake3Async(sourceFile)
.ConfigureAwait(false);
var destinationHash = await FileHash
.GetBlake3Async(destinationFile)
.ConfigureAwait(false);
// For same hash, just delete original file
if (sourceHash == destinationHash)
{
Logger.Info(
$"Deleted source file {file.Name} as it already exists in {destinationDir}."
+ $" Matching Blake3 hash: {sourceHash}"
);
sourceFile.Delete();
continue;
}
}
else if (!overwrite)
// Check if files hashes are the same
var sourceHash = await FileHash.GetBlake3Async(sourceFile).ConfigureAwait(false);
var destinationHash = await FileHash
.GetBlake3Async(destinationFile)
.ConfigureAwait(false);
// For same hash, just delete original file
if (sourceHash == destinationHash)
{
throw new FileTransferExistsException(sourceFile, destinationFile);
Logger.Info(
$"Deleted source file {sourceFile.Name} as it already exists in {Path.GetDirectoryName(destinationFile)}."
+ $" Matching Blake3 hash: {sourceHash}"
);
sourceFile.Delete();
return;
}
}
// Move the file
await sourceFile.MoveToAsync(destinationFile).ConfigureAwait(false);
else if (!overwrite)
{
throw new FileTransferExistsException(sourceFile, destinationFile);
}
}
// Move the file
await sourceFile.MoveToAsync(destinationFile).ConfigureAwait(false);
}
}

25
StabilityMatrix.Core/Helper/LazyInstance.cs

@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
namespace StabilityMatrix.Core.Helper;
/// <summary>
/// Lazy instance of a DI service.
/// </summary>
public class LazyInstance<T> : Lazy<T>
where T : notnull
{
public LazyInstance(IServiceProvider serviceProvider)
: base(serviceProvider.GetRequiredService<T>) { }
}
public static class LazyInstanceServiceExtensions
{
/// <summary>
/// Register <see cref="LazyInstance{T}"/> to be used when resolving <see cref="Lazy{T}"/> instances.
/// </summary>
public static IServiceCollection AddLazyInstance(this IServiceCollection services)
{
services.AddTransient(typeof(Lazy<>), typeof(LazyInstance<>));
return services;
}
}

26
StabilityMatrix.Core/Helper/ObjectHash.cs

@ -1,9 +1,10 @@
using System.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StabilityMatrix.Core.Helper;
public class ObjectHash
public static class ObjectHash
{
/// <summary>
/// Return a GUID based on the MD5 hash of the JSON representation of the object.
@ -16,4 +17,25 @@ public class ObjectHash
var hash = md5.ComputeHash(bytes);
return new Guid(hash);
}
/// <summary>
/// Return a short Sha256 signature of a string
/// </summary>
public static string GetStringSignature(string? str)
{
if (str is null)
{
return "null";
}
if (string.IsNullOrEmpty(str))
{
return "";
}
var bytes = Encoding.UTF8.GetBytes(str);
var hash = Convert.ToBase64String(SHA256.HashData(bytes));
return $"[..{str.Length}, {hash[..7]}]";
}
}

15
StabilityMatrix.Core/Models/Api/CivitAccountStatusUpdateEventArgs.cs

@ -0,0 +1,15 @@
using StabilityMatrix.Core.Models.Api.CivitTRPC;
namespace StabilityMatrix.Core.Models.Api;
public class CivitAccountStatusUpdateEventArgs : EventArgs
{
public static CivitAccountStatusUpdateEventArgs Disconnected { get; } = new();
public bool IsConnected { get; init; }
public CivitUserProfileResponse? UserProfile { get; init; }
public string? UsernameWithParentheses =>
string.IsNullOrEmpty(UserProfile?.Username) ? null : $"({UserProfile.Username})";
}

3
StabilityMatrix.Core/Models/Api/CivitTRPC/CivitApiTokens.cs

@ -0,0 +1,3 @@
namespace StabilityMatrix.Core.Models.Api.CivitTRPC;
public record CivitApiTokens(string ApiToken, string Username);

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save