Browse Source

Merge branch 'dev' into merge-main-to-dev-0aaa095

pull/324/head
Ionite 12 months ago committed by GitHub
parent
commit
064710e5ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .config/dotnet-tools.json
  2. 2
      .github/workflows/build.yml
  3. 12
      .github/workflows/release.yml
  4. 2
      .github/workflows/test-ui.yml
  5. 2
      .github/workflows/version-bump.yml
  6. 71
      CHANGELOG.md
  7. 2
      StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj
  8. 49
      StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs
  9. 5
      StabilityMatrix.Avalonia/App.axaml
  10. 86
      StabilityMatrix.Avalonia/App.axaml.cs
  11. 6
      StabilityMatrix.Avalonia/Assets.cs
  12. BIN
      StabilityMatrix.Avalonia/Assets/brands-civitai.png
  13. BIN
      StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp
  14. 34
      StabilityMatrix.Avalonia/Assets/sitecustomize.py
  15. 3
      StabilityMatrix.Avalonia/Controls/AdvancedImageBox.axaml.cs
  16. 37
      StabilityMatrix.Avalonia/Controls/BetterAdvancedImage.cs
  17. 9
      StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs
  18. 126
      StabilityMatrix.Avalonia/Controls/Extensions/ShowDisabledTooltipExtension.cs
  19. 13
      StabilityMatrix.Avalonia/Controls/HyperlinkIconButton.cs
  20. 2
      StabilityMatrix.Avalonia/Controls/LineDashFrame.cs
  21. 131
      StabilityMatrix.Avalonia/Controls/ProgressRing.cs
  22. 124
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml
  23. 199
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs
  24. 59
      StabilityMatrix.Avalonia/Controls/StarsRating.axaml
  25. 168
      StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs
  26. 30
      StabilityMatrix.Avalonia/Converters/BooleanChoiceMultiConverter.cs
  27. 28
      StabilityMatrix.Avalonia/Converters/CustomStringFormatConverter.cs
  28. 32
      StabilityMatrix.Avalonia/Converters/EnumStringConverter.cs
  29. 24
      StabilityMatrix.Avalonia/Converters/EnumToBooleanConverter.cs
  30. 36
      StabilityMatrix.Avalonia/Converters/IndexPlusOneConverter.cs
  31. 50
      StabilityMatrix.Avalonia/Converters/KiloFormatter.cs
  32. 25
      StabilityMatrix.Avalonia/Converters/KiloFormatterStringConverter.cs
  33. 41
      StabilityMatrix.Avalonia/Converters/MemoryBytesFormatter.cs
  34. 8
      StabilityMatrix.Avalonia/Converters/StringFormatConverters.cs
  35. 102
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  36. 84
      StabilityMatrix.Avalonia/DialogHelper.cs
  37. 19
      StabilityMatrix.Avalonia/Helpers/IOCommands.cs
  38. 2
      StabilityMatrix.Avalonia/Helpers/TextEditorConfigs.cs
  39. 102
      StabilityMatrix.Avalonia/Helpers/UriHandler.cs
  40. 288
      StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
  41. 96
      StabilityMatrix.Avalonia/Languages/Resources.resx
  42. 46
      StabilityMatrix.Avalonia/Models/AppArgs.cs
  43. 10
      StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs
  44. 70
      StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs
  45. 15
      StabilityMatrix.Avalonia/Models/ViewModelState.cs
  46. 157
      StabilityMatrix.Avalonia/Program.cs
  47. 254
      StabilityMatrix.Avalonia/Services/AccountsService.cs
  48. 29
      StabilityMatrix.Avalonia/Services/IAccountsService.cs
  49. 18
      StabilityMatrix.Avalonia/Services/INavigationService.cs
  50. 81
      StabilityMatrix.Avalonia/Services/NavigationService.cs
  51. 57
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  52. 44
      StabilityMatrix.Avalonia/Styles/ControlThemes/HyperlinkIconButtonStyles.axaml
  53. 25
      StabilityMatrix.Avalonia/Styles/ControlThemes/ListBoxStyles.axaml
  54. 46
      StabilityMatrix.Avalonia/Styles/ProgressRing.axaml
  55. 2
      StabilityMatrix.Avalonia/Styles/ThemeColors.axaml
  56. 2
      StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs
  57. 77
      StabilityMatrix.Avalonia/ViewModels/Base/TaskDialogViewModelBase.cs
  58. 61
      StabilityMatrix.Avalonia/ViewModels/Base/ViewModelBase.cs
  59. 5
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs
  60. 27
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs
  61. 76
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs
  62. 19
      StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs
  63. 6
      StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs
  64. 4
      StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs
  65. 141
      StabilityMatrix.Avalonia/ViewModels/Dialogs/LykosLoginViewModel.cs
  66. 84
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OAuthConnectViewModel.cs
  67. 4
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs
  68. 264
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  69. 1
      StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs
  70. 12
      StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
  71. 128
      StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
  72. 4
      StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs
  73. 52
      StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs
  74. 286
      StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs
  75. 228
      StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs
  76. 834
      StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs
  77. 236
      StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs
  78. 908
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  79. 513
      StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml
  80. 23
      StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml.cs
  81. 174
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
  82. 67
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs
  83. 7
      StabilityMatrix.Avalonia/Views/Dialogs/InstallerDialog.axaml
  84. 130
      StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml
  85. 13
      StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml.cs
  86. 40
      StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml
  87. 13
      StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml.cs
  88. 2
      StabilityMatrix.Avalonia/Views/Dialogs/PackageModificationDialog.axaml.cs
  89. 20
      StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs
  90. 273
      StabilityMatrix.Avalonia/Views/OutputsPage.axaml
  91. 8
      StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs
  92. 16
      StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml
  93. 179
      StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml
  94. 13
      StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml.cs
  95. 145
      StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml
  96. 10
      StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs
  97. 611
      StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml
  98. 13
      StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml.cs
  99. 154
      StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml
  100. 41
      StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml.cs
  101. Some files were not shown because too many files have changed in this diff Show More

2
.config/dotnet-tools.json

@ -15,7 +15,7 @@
]
},
"csharpier": {
"version": "0.25.0",
"version": "0.26.3",
"commands": [
"dotnet-csharpier"
]

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

12
.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

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

71
CHANGELOG.md

@ -5,6 +5,77 @@ 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-pre.2
### Added
- Added System Information section to Settings
### Changed
- Moved Inference Settings to subpage
### Fixed
- Fixed crash when loading an empty settings file
- Improve Settings save and load performance with .NET 8 Source Generating Serialization
- Fixed ApplicationException during database shutdown
## v2.7.0-pre.1
### Fixed
- Fixed control character decoding that caused some progress bars to show as `\u2588`
- Fixed Python `rich` package's progress bars not showing in console
- Optimized ProgressRing animation bindings to reduce CPU usage
- Improved safety checks in custom control rendering to reduce potential graphical artifacts
- Improved console rendering safety with cursor line increment clamping, as potential fix for [#111](https://github.com/LykosAI/StabilityMatrix/issues/111)
## v2.7.0-dev.4
### Fixed
- Fixed [#290](https://github.com/LykosAI/StabilityMatrix/issues/290) - Model browser crash due to text trimming certain unicode characters
## v2.7.0-dev.3
### Added
- New package: [RuinedFooocus](https://github.com/runew0lf/RuinedFooocus)
#### Model Browser
- Right clicking anywhere on the model card will open the same menu as the three-dots button
- New model downloads will save trigger words in metadata, if available
- Model author username and avatar display, with clickable link to their profile
#### Checkpoints Page
- Added "Copy Trigger Words" option to the three-dots menu on the Checkpoints page (when data is available)
- Added trigger words on checkpoint card and tooltip
### Changed
#### Model Browser
- Improved number formatting with K/M suffixes for download and favorite counts
- Animated zoom effect on hovering over model images
#### Checkpoints Page
- Rearranged top row layout to use CommandBar
### Fixed
- Improved startup time and window load time after exiting dialogs
## 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.7
### Fixed
- Fixed prerequisite install not unpacking due to improperly formatted 7z argument (Caused the "python310._pth FileNotFoundException")

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>

49
StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs

@ -1,11 +1,13 @@
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;
@ -16,7 +18,8 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
/// <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
@ -28,6 +31,11 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
/// </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)
};
}

5
StabilityMatrix.Avalonia/App.axaml

@ -22,6 +22,8 @@
<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" />
@ -39,6 +41,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 +65,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}" />

86
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)
foreach (var attribute in typePair.Attributes)
{
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(typePair.Attribute.InterfaceType, typePair.Type);
services.AddSingleton(attribute.InterfaceType, typePair.Type);
}
}
}
@ -495,11 +541,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

34
StabilityMatrix.Avalonia/Assets/sitecustomize.py

@ -46,7 +46,41 @@ def audit(event: str, *args):
# Reconfigure stdout to UTF-8
# noinspection PyUnresolvedReferences
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
# Install the audit hook
sys.addaudithook(audit)
# Patch Rich terminal detection
def _patch_rich_console():
try:
from rich import console
class _Console(console.Console):
@property
def is_terminal(self) -> bool:
return True
console.Console = _Console
except ImportError:
pass
except Exception as e:
print("[sitecustomize error]:", e)
_patch_rich_console()
# Patch tqdm to use stdout instead of stderr
def _patch_tqdm():
try:
import sys
from tqdm import std
sys.stderr = sys.stdout
except ImportError:
pass
except Exception as e:
print("[sitecustomize error]:", e)
_patch_tqdm()

3
StabilityMatrix.Avalonia/Controls/AdvancedImageBox.axaml.cs

@ -1354,6 +1354,7 @@ public class AdvancedImageBox : TemplatedControl
InvalidateVisual();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void RenderBackgroundGrid(DrawingContext context)
{
var size = GridCellSize;
@ -1387,8 +1388,6 @@ public class AdvancedImageBox : TemplatedControl
public override void Render(DrawingContext context)
{
base.Render(context);
var gridCellSize = GridCellSize;
if (ShowGrid & gridCellSize > 0 && (!IsHorizontalBarVisible || !IsVerticalBarVisible))

37
StabilityMatrix.Avalonia/Controls/BetterAdvancedImage.cs

@ -15,11 +15,15 @@ public class BetterAdvancedImage : AdvancedImage
#region Reflection Shenanigans to access private parent fields
[NotNull]
private static readonly FieldInfo? IsCornerRadiusUsedField = typeof(AdvancedImage).GetField(
"_isCornerRadiusUsed",BindingFlags.Instance | BindingFlags.NonPublic);
"_isCornerRadiusUsed",
BindingFlags.Instance | BindingFlags.NonPublic
);
[NotNull]
private static readonly FieldInfo? CornerRadiusClipField = typeof(AdvancedImage).GetField(
"_cornerRadiusClip",BindingFlags.Instance | BindingFlags.NonPublic);
"_cornerRadiusClip",
BindingFlags.Instance | BindingFlags.NonPublic
);
private bool IsCornerRadiusUsed
{
@ -48,13 +52,11 @@ public class BetterAdvancedImage : AdvancedImage
protected override Type StyleKeyOverride { get; } = typeof(AdvancedImage);
public BetterAdvancedImage(Uri? baseUri) : base(baseUri)
{
}
public BetterAdvancedImage(Uri? baseUri)
: base(baseUri) { }
public BetterAdvancedImage(IServiceProvider serviceProvider) : base(serviceProvider)
{
}
public BetterAdvancedImage(IServiceProvider serviceProvider)
: base(serviceProvider) { }
/// <exception cref="ArgumentOutOfRangeException"></exception>
/// <inheritdoc />
@ -77,7 +79,8 @@ public class BetterAdvancedImage : AdvancedImage
HorizontalAlignment.Center => (int)(viewPort.Width - scaledSize.Width) / 2,
HorizontalAlignment.Right => (int)(viewPort.Width - scaledSize.Width),
// Stretch is default, use center
HorizontalAlignment.Stretch => (int) (viewPort.Width - scaledSize.Width) / 2,
HorizontalAlignment.Stretch
=> (int)(viewPort.Width - scaledSize.Width) / 2,
_ => throw new ArgumentException(nameof(HorizontalContentAlignment))
};
var destY = VerticalContentAlignment switch
@ -103,7 +106,8 @@ public class BetterAdvancedImage : AdvancedImage
HorizontalAlignment.Center => (int)(sourceSize - destRectUnscaledSize).Width / 2,
HorizontalAlignment.Right => (int)(sourceSize - destRectUnscaledSize).Width,
// Stretch is default, use center
HorizontalAlignment.Stretch => (int) (sourceSize - destRectUnscaledSize).Width / 2,
HorizontalAlignment.Stretch
=> (int)(sourceSize - destRectUnscaledSize).Width / 2,
_ => throw new ArgumentException(nameof(HorizontalContentAlignment))
};
var sourceY = VerticalContentAlignment switch
@ -120,10 +124,17 @@ public class BetterAdvancedImage : AdvancedImage
.WithX(sourceX)
.WithY(sourceY);
DrawingContext.PushedState? pushedState =
IsCornerRadiusUsed ? context.PushClip(CornerRadiusClip) : null;
if (IsCornerRadiusUsed)
{
using (context.PushClip(CornerRadiusClip))
{
context.DrawImage(source, sourceRect, destRect);
pushedState?.Dispose();
}
}
else
{
context.DrawImage(source, sourceRect, destRect);
}
}
else
{

9
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
@ -262,9 +268,12 @@ public class BetterContentDialog : ContentDialog
}
protected void OnDialogButtonClick(object? sender, ContentDialogResult e)
{
Dispatcher.UIThread.Post(() =>
{
Result = e;
HideCore();
});
}
protected override void OnDataContextChanged(EventArgs e)

126
StabilityMatrix.Avalonia/Controls/Extensions/ShowDisabledTooltipExtension.cs

@ -0,0 +1,126 @@
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using JetBrains.Annotations;
namespace StabilityMatrix.Avalonia.Controls.Extensions;
/// <summary>
/// Show tooltip on Controls with IsEffectivelyEnabled = false
/// https://github.com/AvaloniaUI/Avalonia/issues/3847#issuecomment-1618790059
/// </summary>
[PublicAPI]
public static class ShowDisabledTooltipExtension
{
static ShowDisabledTooltipExtension()
{
ShowOnDisabledProperty.Changed.AddClassHandler<Control>(HandleShowOnDisabledChanged);
}
public static bool GetShowOnDisabled(AvaloniaObject obj)
{
return obj.GetValue(ShowOnDisabledProperty);
}
public static void SetShowOnDisabled(AvaloniaObject obj, bool value)
{
obj.SetValue(ShowOnDisabledProperty, value);
}
public static readonly AttachedProperty<bool> ShowOnDisabledProperty =
AvaloniaProperty.RegisterAttached<object, Control, bool>("ShowOnDisabled");
private static void HandleShowOnDisabledChanged(
Control control,
AvaloniaPropertyChangedEventArgs e
)
{
if (e.GetNewValue<bool>())
{
control.DetachedFromVisualTree += AttachedControl_DetachedFromVisualOrExtension;
control.AttachedToVisualTree += AttachedControl_AttachedToVisualTree;
if (control.IsInitialized)
{
// enabled after visual attached
AttachedControl_AttachedToVisualTree(control, null!);
}
}
else
{
AttachedControl_DetachedFromVisualOrExtension(control, null!);
}
}
private static void AttachedControl_AttachedToVisualTree(
object? sender,
VisualTreeAttachmentEventArgs e
)
{
if (sender is not Control control || TopLevel.GetTopLevel(control) is not { } tl)
{
return;
}
// NOTE pointermove needed to be tunneled for me but you may not need to...
tl.AddHandler(
InputElement.PointerMovedEvent,
TopLevel_PointerMoved,
RoutingStrategies.Tunnel
);
}
private static void AttachedControl_DetachedFromVisualOrExtension(
object? s,
VisualTreeAttachmentEventArgs e
)
{
if (s is not Control control)
{
return;
}
control.DetachedFromVisualTree -= AttachedControl_DetachedFromVisualOrExtension;
control.AttachedToVisualTree -= AttachedControl_AttachedToVisualTree;
if (TopLevel.GetTopLevel(control) is not { } tl)
{
return;
}
tl.RemoveHandler(InputElement.PointerMovedEvent, TopLevel_PointerMoved);
}
private static void TopLevel_PointerMoved(object? sender, PointerEventArgs e)
{
if (sender is not Control tl)
{
return;
}
var attachedControls = tl.GetVisualDescendants()
.Where(GetShowOnDisabled)
.Cast<Control>()
.ToList();
// find disabled children under pointer w/ this extension enabled
var disabledChildUnderPointer = attachedControls.FirstOrDefault(
x =>
x.Bounds.Contains(e.GetPosition(x.Parent as Visual))
&& x is { IsEffectivelyVisible: true, IsEffectivelyEnabled: false }
);
if (disabledChildUnderPointer != null)
{
// manually show tooltip
ToolTip.SetIsOpen(disabledChildUnderPointer, true);
}
var disabledTooltipsToHide = attachedControls.Where(
x => ToolTip.GetIsOpen(x) && x != disabledChildUnderPointer && !x.IsEffectivelyEnabled
);
foreach (var control in disabledTooltipsToHide)
{
ToolTip.SetIsOpen(control, false);
}
}
}

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

2
StabilityMatrix.Avalonia/Controls/LineDashFrame.cs

@ -82,8 +82,6 @@ public class LineDashFrame : Frame
/// <inheritdoc />
public override void Render(DrawingContext context)
{
base.Render(context);
var width = Bounds.Width;
var height = Bounds.Height;

131
StabilityMatrix.Avalonia/Controls/ProgressRing.cs

@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Shapes;
namespace StabilityMatrix.Avalonia.Controls;
@ -13,70 +15,102 @@ namespace StabilityMatrix.Avalonia.Controls;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class ProgressRing : RangeBase
{
private Arc? fillArc;
public static readonly StyledProperty<bool> IsIndeterminateProperty =
ProgressBar.IsIndeterminateProperty.AddOwner<ProgressRing>();
public static readonly StyledProperty<bool> PreserveAspectProperty =
AvaloniaProperty.Register<ProgressRing, bool>(nameof(PreserveAspect), true);
public static readonly StyledProperty<double> ValueAngleProperty =
AvaloniaProperty.Register<ProgressRing, double>(nameof(ValueAngle));
public static readonly StyledProperty<double> StartAngleProperty =
AvaloniaProperty.Register<ProgressRing, double>(nameof(StartAngle));
public static readonly StyledProperty<double> EndAngleProperty =
AvaloniaProperty.Register<ProgressRing, double>(nameof(EndAngle), 360);
static ProgressRing()
{
MinimumProperty.Changed.AddClassHandler<ProgressRing>(OnMinimumPropertyChanged);
MaximumProperty.Changed.AddClassHandler<ProgressRing>(OnMaximumPropertyChanged);
ValueProperty.Changed.AddClassHandler<ProgressRing>(OnValuePropertyChanged);
MaximumProperty.Changed.AddClassHandler<ProgressRing>(OnStartAnglePropertyChanged);
MaximumProperty.Changed.AddClassHandler<ProgressRing>(OnEndAnglePropertyChanged);
}
public ProgressRing()
{
UpdatePseudoClasses(IsIndeterminate, PreserveAspect);
}
public bool IsIndeterminate
{
get => GetValue(IsIndeterminateProperty);
set => SetValue(IsIndeterminateProperty, value);
}
public static readonly StyledProperty<bool> PreserveAspectProperty = AvaloniaProperty.Register<
ProgressRing,
bool
>(nameof(PreserveAspect), true);
public bool PreserveAspect
{
get => GetValue(PreserveAspectProperty);
set => SetValue(PreserveAspectProperty, value);
}
public double ValueAngle
public static readonly StyledProperty<double> StrokeThicknessProperty =
Shape.StrokeThicknessProperty.AddOwner<ProgressRing>();
public double StrokeThickness
{
get => GetValue(ValueAngleProperty);
private set => SetValue(ValueAngleProperty, value);
get => GetValue(StrokeThicknessProperty);
set => SetValue(StrokeThicknessProperty, value);
}
public static readonly StyledProperty<double> StartAngleProperty = AvaloniaProperty.Register<
ProgressRing,
double
>(nameof(StartAngle));
public double StartAngle
{
get => GetValue(StartAngleProperty);
set => SetValue(StartAngleProperty, value);
}
public static readonly StyledProperty<double> SweepAngleProperty = AvaloniaProperty.Register<
ProgressRing,
double
>(nameof(SweepAngle));
public double SweepAngle
{
get => GetValue(SweepAngleProperty);
set => SetValue(SweepAngleProperty, value);
}
public static readonly StyledProperty<double> EndAngleProperty = AvaloniaProperty.Register<
ProgressRing,
double
>(nameof(EndAngle), 360);
public double EndAngle
{
get => GetValue(EndAngleProperty);
set => SetValue(EndAngleProperty, value);
}
static ProgressRing()
{
AffectsRender<ProgressRing>(SweepAngleProperty, StartAngleProperty, EndAngleProperty);
ValueProperty.Changed.AddClassHandler<ProgressRing>(OnValuePropertyChanged);
SweepAngleProperty.Changed.AddClassHandler<ProgressRing>(OnSweepAnglePropertyChanged);
}
public ProgressRing()
{
UpdatePseudoClasses(IsIndeterminate, PreserveAspect);
}
/// <inheritdoc />
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
fillArc = e.NameScope.Find<Arc>("PART_Fill");
if (fillArc is not null)
{
fillArc.StartAngle = StartAngle;
fillArc.SweepAngle = SweepAngle;
}
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
var e = change as AvaloniaPropertyChangedEventArgs<bool>;
if (e is null) return;
if (e is null)
return;
if (e.Property == IsIndeterminateProperty)
{
@ -88,9 +122,7 @@ public class ProgressRing : RangeBase
}
}
private void UpdatePseudoClasses(
bool? isIndeterminate,
bool? preserveAspect)
private void UpdatePseudoClasses(bool? isIndeterminate, bool? preserveAspect)
{
if (isIndeterminate.HasValue)
{
@ -103,28 +135,25 @@ public class ProgressRing : RangeBase
}
}
private static void OnMinimumPropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
private static void OnValuePropertyChanged(
ProgressRing sender,
AvaloniaPropertyChangedEventArgs e
)
{
sender.Minimum = (double) e.NewValue!;
sender.SweepAngle =
((double)e.NewValue! - sender.Minimum)
* (sender.EndAngle - sender.StartAngle)
/ (sender.Maximum - sender.Minimum);
}
private static void OnMaximumPropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
private static void OnSweepAnglePropertyChanged(
ProgressRing sender,
AvaloniaPropertyChangedEventArgs e
)
{
sender.Maximum = (double) e.NewValue!;
}
private static void OnValuePropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
if (sender.fillArc is { } arc)
{
sender.ValueAngle = ((double) e.NewValue! - sender.Minimum) * (sender.EndAngle - sender.StartAngle) / (sender.Maximum - sender.Minimum);
arc.SweepAngle = Math.Round(e.GetNewValue<double>());
}
private static void OnStartAnglePropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
{
sender.StartAngle = (double) e.NewValue!;
}
private static void OnEndAnglePropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
{
sender.EndAngle = (double) e.NewValue!;
}
}

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

28
StabilityMatrix.Avalonia/Converters/CustomStringFormatConverter.cs

@ -0,0 +1,28 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
public class CustomStringFormatConverter<T>([StringSyntax("CompositeFormat")] string format)
: IValueConverter
where T : IFormatProvider, new()
{
/// <inheritdoc />
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is null ? null : string.Format(new T(), format, value);
}
/// <inheritdoc />
public object? ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
)
{
return value is null ? null : throw new NotImplementedException();
}
}

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

36
StabilityMatrix.Avalonia/Converters/IndexPlusOneConverter.cs

@ -0,0 +1,36 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
/// <summary>
/// Converts an index to index + 1
/// </summary>
public class IndexPlusOneConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is int i)
{
return i + 1;
}
return value;
}
public object? ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
)
{
if (value is int i)
{
return i - 1;
}
return value;
}
}

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

41
StabilityMatrix.Avalonia/Converters/MemoryBytesFormatter.cs

@ -0,0 +1,41 @@
using System;
using Size = StabilityMatrix.Core.Helper.Size;
namespace StabilityMatrix.Avalonia.Converters;
public class MemoryBytesFormatter : ICustomFormatter, IFormatProvider
{
/// <inheritdoc />
public object? GetFormat(Type? formatType)
{
return formatType == typeof(ICustomFormatter) ? this : null;
}
/// <inheritdoc />
public string Format(string? format, object? arg, IFormatProvider? formatProvider)
{
if (format == null || !format.Trim().StartsWith('M'))
{
if (arg is IFormattable formatArg)
{
return formatArg.ToString(format, formatProvider);
}
return arg?.ToString() ?? string.Empty;
}
var value = Convert.ToUInt64(arg);
var result = format.Trim().EndsWith("10", StringComparison.OrdinalIgnoreCase)
? Size.FormatBase10Bytes(value)
: Size.FormatBytes(value);
// Strip i if not Mi
if (!format.Trim().Contains('I', StringComparison.OrdinalIgnoreCase))
{
result = result.Replace("i", string.Empty, StringComparison.OrdinalIgnoreCase);
}
return result;
}
}

8
StabilityMatrix.Avalonia/Converters/StringFormatConverters.cs

@ -1,4 +1,5 @@
using Avalonia.Data.Converters;
using System;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
@ -7,4 +8,9 @@ public static class StringFormatConverters
private static StringFormatValueConverter? _decimalConverter;
public static StringFormatValueConverter Decimal =>
_decimalConverter ??= new StringFormatValueConverter("{0:D}", null);
private static readonly Lazy<IValueConverter> MemoryBytesConverterLazy =
new(() => new CustomStringFormatConverter<MemoryBytesFormatter>("{0:M}"));
public static IValueConverter MemoryBytes => MemoryBytesConverterLazy.Value;
}

102
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>()
@ -204,7 +206,8 @@ public static class DesignData
Format = CivitModelFormat.SafeTensor,
Fp = CivitModelFpType.fp16,
Size = CivitModelSize.pruned,
}
},
TrainedWords = ["aurora", "lightning"]
}
},
new()
@ -304,6 +307,44 @@ 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" }
],
Creator = new CivitCreator
{
Image = "https://gravatar.com/avatar/fe74084ae8a081dc2283f5bde4736756ad?f=y&d=retro",
Username = "creator-1"
}
};
}),
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"
}
}
}
],
Creator = new CivitCreator
{
Image = "https://gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro",
Username = "creator-2"
}
};
})
};
@ -344,7 +385,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 +480,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 +622,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 +828,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,
},

19
StabilityMatrix.Avalonia/Helpers/IOCommands.cs

@ -0,0 +1,19 @@
using CommunityToolkit.Mvvm.Input;
using StabilityMatrix.Core.Processes;
namespace StabilityMatrix.Avalonia.Helpers;
public static class IOCommands
{
public static RelayCommand<string?> OpenUrlCommand { get; } =
new(
url =>
{
if (string.IsNullOrWhiteSpace(url))
return;
ProcessRunner.OpenUrl(url);
},
url => !string.IsNullOrWhiteSpace(url)
);
}

2
StabilityMatrix.Avalonia/Helpers/TextEditorConfigs.cs

@ -84,6 +84,8 @@ public static class TextEditorConfigs
textMate.SetGrammar(scope);
textMate.SetTheme(registryOptions.LoadTheme(ThemeName.DarkPlus));
editor.Options.ShowBoxForControlCharacters = false;
}
private static IRawTheme GetThemeFromStream(Stream stream)

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

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

@ -167,6 +167,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Copy Trigger Words.
/// </summary>
public static string Action_CopyTriggerWords {
get {
return ResourceManager.GetString("Action_CopyTriggerWords", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>
@ -176,6 +185,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 +257,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 +527,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 +590,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 +635,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>
@ -608,6 +662,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Auto Completion.
/// </summary>
public static string Label_AutoCompletion {
get {
return ResourceManager.GetString("Label_AutoCompletion", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Automatically scroll to end of console output.
/// </summary>
@ -617,6 +680,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 +770,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>
@ -725,6 +806,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Replace underscores with spaces when inserting completions.
/// </summary>
public static string Label_CompletionReplaceUnderscoresWithSpaces {
get {
return ResourceManager.GetString("Label_CompletionReplaceUnderscoresWithSpaces", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Confirm Delete.
/// </summary>
@ -734,6 +824,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 +842,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 +968,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 +1004,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>
@ -986,6 +1112,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to General.
/// </summary>
public static string Label_General {
get {
return ResourceManager.GetString("Label_General", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Height.
/// </summary>
@ -1004,6 +1139,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Image Viewer.
/// </summary>
public static string Label_ImageViewer {
get {
return ResourceManager.GetString("Label_ImageViewer", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import with Metadata.
/// </summary>
@ -1040,6 +1184,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Inference.
/// </summary>
public static string Label_Inference {
get {
return ResourceManager.GetString("Label_Inference", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Inner exception.
/// </summary>
@ -1310,6 +1463,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Output Image Files.
/// </summary>
public static string Label_OutputImageFiles {
get {
return ResourceManager.GetString("Label_OutputImageFiles", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Output Browser.
/// </summary>
@ -1364,6 +1526,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>
@ -1418,6 +1589,42 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Prompt.
/// </summary>
public static string Label_Prompt {
get {
return ResourceManager.GetString("Label_Prompt", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Prompt Tags.
/// </summary>
public static string Label_PromptTags {
get {
return ResourceManager.GetString("Label_PromptTags", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Tags file to use for suggesting completions (Supports the a1111-sd-webui-tagcomplete .csv format).
/// </summary>
public static string Label_PromptTagsDescription {
get {
return ResourceManager.GetString("Label_PromptTagsDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import Prompt tags.
/// </summary>
public static string Label_PromptTagsImport {
get {
return ResourceManager.GetString("Label_PromptTagsImport", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Python Packages.
/// </summary>
@ -1661,6 +1868,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to System Information.
/// </summary>
public static string Label_SystemInformation {
get {
return ResourceManager.GetString("Label_SystemInformation", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Text to Image.
/// </summary>
@ -1688,6 +1904,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Trigger words:.
/// </summary>
public static string Label_TriggerWords {
get {
return ResourceManager.GetString("Label_TriggerWords", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to An unexpected error occurred.
/// </summary>
@ -1715,6 +1940,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 +1976,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 +2039,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>
@ -1868,6 +2138,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Additional folders such as IPAdapters and TextualInversions (embeddings) can be enabled here.
/// </summary>
public static string TeachingTip_MoreCheckpointCategories {
get {
return ResourceManager.GetString("TeachingTip_MoreCheckpointCategories", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The app will relaunch after updating.
/// </summary>
@ -1940,6 +2219,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>

96
StabilityMatrix.Avalonia/Languages/Resources.resx

@ -750,4 +750,100 @@
<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>
<data name="Action_CopyTriggerWords" xml:space="preserve">
<value>Copy Trigger Words</value>
</data>
<data name="Label_TriggerWords" xml:space="preserve">
<value>Trigger words:</value>
</data>
<data name="TeachingTip_MoreCheckpointCategories" xml:space="preserve">
<value>Additional folders such as IPAdapters and TextualInversions (embeddings) can be enabled here</value>
</data>
<data name="Label_General" xml:space="preserve">
<value>General</value><comment>A general settings category</comment>
</data>
<data name="Label_Inference" xml:space="preserve">
<value>Inference</value><comment>The Inference feature page</comment>
</data>
<data name="Label_Prompt" xml:space="preserve">
<value>Prompt</value><comment>A settings category for Inference generation prompts</comment>
</data>
<data name="Label_OutputImageFiles" xml:space="preserve">
<value>Output Image Files</value>
</data>
<data name="Label_ImageViewer" xml:space="preserve">
<value>Image Viewer</value>
</data>
<data name="Label_AutoCompletion" xml:space="preserve">
<value>Auto Completion</value>
</data>
<data name="Label_CompletionReplaceUnderscoresWithSpaces" xml:space="preserve">
<value>Replace underscores with spaces when inserting completions</value>
</data>
<data name="Label_PromptTags" xml:space="preserve">
<value>Prompt Tags</value><comment>Tags for image generation prompts</comment>
</data>
<data name="Label_PromptTagsImport" xml:space="preserve">
<value>Import Prompt tags</value>
</data>
<data name="Label_PromptTagsDescription" xml:space="preserve">
<value>Tags file to use for suggesting completions (Supports the a1111-sd-webui-tagcomplete .csv format)</value>
</data>
<data name="Label_SystemInformation" xml:space="preserve">
<value>System Information</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,
}

157
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,33 +12,38 @@ 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;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
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 +52,59 @@ 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);
GlobalConfig.HomeDir = 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,25 +172,43 @@ 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)
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());
var isCopied = false;
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)
@ -161,32 +225,57 @@ public class Program
);
}
// Start the new app
Process.Start(targetExe);
// 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);
}
catch (Exception)
private static void HandleUpdateCleanup()
{
Thread.Sleep(delay);
// Delete update folder if it exists in current directory
if (UpdateHelper.UpdateFolder is { Exists: true } updateDir)
{
try
{
updateDir.Delete(true);
}
catch (Exception e)
{
Logger.Error(e, "Failed to delete update folder");
}
}
}
// Delete update folder if it exists in current directory
var updateDir = UpdateHelper.UpdateFolder;
if (updateDir.Exists)
/// <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
{
updateDir.Delete(true);
var process = Process.GetProcessById(pid);
process
.WaitForExitAsync(new CancellationTokenSource(timeout).Token)
.GetAwaiter()
.GetResult();
}
catch (Exception e)
catch (OperationCanceledException)
{
var logger = LogManager.GetCurrentClassLogger();
logger.Error(e, "Failed to delete update file");
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;
}
}

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>

81
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) }
);
}
if (
App.Services.GetService(typeof(MainWindowViewModel))
is MainWindowViewModel mainViewModel
/// <inheritdoc />
public void NavigateTo(
Type viewModelType,
NavigationTransitionInfo? transitionInfo = null,
object? param = null
)
{
mainViewModel.SelectedCategory = mainViewModel.Pages.FirstOrDefault(
x => x.GetType() == typeof(TViewModel)
);
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(ISettingsManager)) is ISettingsManager settingsManager)
{
// 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-pre.999</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>

46
StabilityMatrix.Avalonia/Styles/ProgressRing.axaml

@ -7,10 +7,13 @@
<Design.PreviewWith>
<StackPanel>
<Border Padding="20">
<controls:ProgressRing StartAngle="90" EndAngle="270" Value="50" Foreground="Red" BorderThickness="5" Width="100" Height="100" />
<controls:ProgressRing Value="50" />
</Border>
<Border Padding="20">
<controls:ProgressRing IsIndeterminate="True" BorderThickness="10" Width="100" Height="100" />
<controls:ProgressRing StartAngle="90" EndAngle="450" Value="50" Foreground="Red" StrokeThickness="3" Width="100" Height="100" />
</Border>
<Border Padding="20">
<controls:ProgressRing IsIndeterminate="True" StrokeThickness="10" Width="100" Height="100" />
</Border>
</StackPanel>
</Design.PreviewWith>
@ -22,27 +25,40 @@
<Style Selector="controls|ProgressRing">
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" />
<Setter Property="BorderThickness" Value="9" />
<Setter Property="StrokeThickness" Value="5" />
<Setter Property="MinHeight" Value="16" />
<Setter Property="MinWidth" Value="16" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition
Property="SweepAngle"
Duration="0:0:0.267">
<DoubleTransition.Easing>
<LinearEasing/>
</DoubleTransition.Easing>
</DoubleTransition>
</Transitions>
</Setter>
<Setter Property="Template">
<ControlTemplate>
<Panel x:Name="FluentRingRoot">
<Ellipse x:Name="Track"
<Panel x:Name="PART_RingRoot">
<Ellipse x:Name="PART_Track"
Stroke="{TemplateBinding Background}"
StrokeThickness="{Binding BorderThickness.Left, RelativeSource={RelativeSource Mode=TemplatedParent}}" />
<Arc x:Name="Fill"
StrokeThickness="{TemplateBinding StrokeThickness}" />
<Arc x:Name="PART_Fill"
Stroke="{TemplateBinding Foreground}"
StrokeThickness="{Binding BorderThickness.Left, RelativeSource={RelativeSource Mode=TemplatedParent}}"
StrokeThickness="{TemplateBinding StrokeThickness}"
StrokeLineCap="Round" />
</Panel>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="controls|ProgressRing:not(:indeterminate) /template/ Arc#Fill">
<Setter Property="StartAngle" Value="{Binding StartAngle, RelativeSource={RelativeSource Mode=TemplatedParent}}"/>
<Setter Property="SweepAngle" Value="{Binding ValueAngle, RelativeSource={RelativeSource Mode=TemplatedParent}}"/>
<Style Selector="controls|ProgressRing:not(:indeterminate) /template/ Arc#PART_Fill">
<!--<Setter Property="StartAngle" Value="{Binding StartAngle, RelativeSource={RelativeSource Mode=TemplatedParent}}"/>-->
<!--<Setter Property="SweepAngle" Value="{Binding SweepAngle, RelativeSource={RelativeSource Mode=TemplatedParent}}"/>-->
</Style>
<Style Selector="controls|ProgressRing:preserveaspect">
@ -50,14 +66,14 @@
<Setter Property="MinHeight" Value="32" />
</Style>
<Style Selector="controls|ProgressRing:preserveaspect /template/ Panel#FluentRingRoot">
<Style Selector="controls|ProgressRing:preserveaspect /template/ Panel#PART_RingRoot">
<Setter Property="Width" Value="{TemplateBinding Bounds, Converter={StaticResource FitSquarelyWithinAspectRatioConverter}}"/>
<Setter Property="Height" Value="{Binding Width, RelativeSource={RelativeSource Mode=Self}}"/>
</Style>
<Style Selector="controls|ProgressRing[IsEnabled=True]:indeterminate /template/ Arc#Fill">
<Style Selector="controls|ProgressRing[IsEnabled=True]:indeterminate /template/ Arc#PART_Fill">
<Style.Animations>
<Animation Duration="0:0:5" Easing="LinearEasing" IterationCount="INFINITE" FillMode="Both">
<Animation Duration="0:0:4.567" Easing="LinearEasing" IterationCount="Infinite" FillMode="Both">
<KeyFrame Cue="0%">
<Setter Property="StartAngle" Value="-720"/>
<Setter Property="SweepAngle" Value="0"/>
@ -98,7 +114,7 @@
</Style.Animations>
</Style>
<Style Selector="controls|ProgressRing[IsEnabled=True] /template/ Ellipse#Track">
<Style Selector="controls|ProgressRing[IsEnabled=True]:not(:indeterminate) /template/ Ellipse#PART_Track">
<Style.Animations>
<Animation Duration="0:0:1" IterationCount="INFINITE">
<KeyFrame Cue="0%">

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) { }
}

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

@ -1,13 +1,18 @@
using System;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Threading;
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
@ -20,26 +25,56 @@ 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();
public virtual Task OnLoadedAsync()
{
return Task.CompletedTask;
Dispatcher.UIThread.InvokeAsync(OnInitialLoadedAsync).SafeFireAndForget();
}
}
public virtual void OnUnloaded()
{
/// <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() => Task.CompletedTask;
public virtual Task OnUnloadedAsync()
{
return Task.CompletedTask;
}
/// <summary>
/// Called the first time the view's LoadedEvent is fired.
/// Sets the <see cref="ViewModelState.InitialLoaded"/> flag.
/// </summary>
protected virtual Task OnInitialLoadedAsync() => Task.CompletedTask;
/// <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() => Task.CompletedTask;
}

5
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs

@ -558,6 +558,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);

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

@ -55,8 +55,14 @@ public partial class CheckpointFile : ViewModelBase
[ObservableProperty]
private CivitModelType modelType;
[ObservableProperty]
private CheckpointFolder parentFolder;
public string FileName => Path.GetFileName(FilePath);
public bool CanShowTriggerWords =>
ConnectedModel != null && !string.IsNullOrWhiteSpace(ConnectedModel.TrainedWordsString);
public ObservableCollection<string> Badges { get; set; } = new();
public static readonly string[] SupportedCheckpointExtensions =
@ -136,6 +142,8 @@ public partial class CheckpointFile : ViewModelBase
RemoveFromParentList();
}
public void OnMoved() => RemoveFromParentList();
[RelayCommand]
private async Task RenameAsync()
{
@ -210,6 +218,19 @@ public partial class CheckpointFile : ViewModelBase
ProcessRunner.OpenUrl($"https://civitai.com/models/{ConnectedModel.ModelId}");
}
[RelayCommand]
private Task CopyTriggerWords()
{
if (ConnectedModel == null)
return Task.CompletedTask;
var words = ConnectedModel.TrainedWordsString;
if (string.IsNullOrWhiteSpace(words))
return Task.CompletedTask;
return App.Clipboard.SetTextAsync(words);
}
/// <summary>
/// Indexes directory and yields all checkpoint files.
/// First we match all files with supported extensions.
@ -218,6 +239,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 +288,8 @@ public partial class CheckpointFile : ViewModelBase
checkpointFile.PreviewImagePath = Assets.NoImage.ToString();
}
checkpointFile.ParentFolder = parentFolder;
yield return checkpointFile;
}
}
@ -327,13 +351,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));

76
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;
@ -89,8 +90,8 @@ public partial class CheckpointFolder : ViewModelBase
public string TitleWithFilesCount =>
CheckpointFiles.Any() || SubFolders.Any(f => f.CheckpointFiles.Any())
? $"{Title} ({CheckpointFiles.Count + SubFolders.Sum(folder => folder.CheckpointFiles.Count)})"
: Title;
? $"{FolderType.GetDescription() ?? FolderType.GetStringValue()} ({CheckpointFiles.Count + SubFolders.Sum(folder => folder.CheckpointFiles.Count)})"
: FolderType.GetDescription() ?? FolderType.GetStringValue();
public ProgressViewModel Progress { get; } = new();
@ -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>

19
StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs

@ -20,6 +20,7 @@ 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;
@ -55,6 +56,9 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
[ObservableProperty]
private string searchFilter = string.Empty;
[ObservableProperty]
private bool isCategoryTipOpen;
partial void OnIsImportAsConnectedChanged(bool value)
{
if (
@ -114,6 +118,16 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
if (Design.IsDesignMode)
return;
if (
!settingsManager.Settings.SeenTeachingTips.Contains(TeachingTip.CheckpointCategoriesTip)
)
{
IsCategoryTipOpen = true;
settingsManager.Transaction(
s => s.SeenTeachingTips.Add(TeachingTip.CheckpointCategoriesTip)
);
}
IsLoading = CheckpointFolders.Count == 0;
IsIndexing = CheckpointFolders.Count > 0;
// GetStuff();
@ -124,6 +138,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)
{

6
StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs

@ -297,6 +297,12 @@ public partial class ConsoleViewModel : ObservableObject, IDisposable, IAsyncDis
{
// Get the current line, we'll insert spaces from start to end
var currentLine = Document.GetLineByOffset(writeCursor);
// Must be smaller than total lines
currentLine =
currentLine.LineNumber < Document.LineCount
? currentLine
: Document.GetLineByNumber(Document.LineCount - 1);
// Make some spaces to insert
var spaces = new string(' ', currentLine.Length);

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;

264
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,145 @@ 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();
try
{
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;
}
return "## Unable to load release notes";
}
catch (HttpRequestException e)
{
return $"## Unable to fetch release notes ({e.StatusCode})\n\n[{changelogUrl}]({changelogUrl})";
}
catch (TaskCanceledException) { }
return $"## Unable to fetch release notes\n\n[{changelogUrl}]({changelogUrl})";
}
/// <summary>
/// Formats changelog markdown including up to the current version
/// </summary>
@ -122,6 +266,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,115 +312,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();
try
{
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;
}
return "## Unable to load release notes";
}
catch (HttpRequestException e)
{
return $"## Unable to fetch release notes ({e.StatusCode})\n\n[{changelogUrl}]({changelogUrl})";
}
catch (TaskCanceledException) { }
return $"## Unable to fetch release notes\n\n[{changelogUrl}]({changelogUrl})";
}
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();
}
}

1
StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs

@ -7,6 +7,7 @@ using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.HardwareInfo;
namespace StabilityMatrix.Avalonia.ViewModels;

12
StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs

@ -78,7 +78,7 @@ public partial class MainWindowViewModel : ViewModelBase
SelectedCategory ??= Pages.FirstOrDefault();
}
public override async Task OnLoadedAsync()
protected override async Task OnInitialLoadedAsync()
{
await base.OnLoadedAsync();
@ -102,16 +102,20 @@ public partial class MainWindowViewModel : ViewModelBase
// Index checkpoints if we dont have
Task.Run(() => settingsManager.IndexCheckpoints()).SafeFireAndForget();
if (!App.IsHeadlessMode)
// Disable preload for now, might be causing https://github.com/LykosAI/StabilityMatrix/issues/249
/*if (!App.IsHeadlessMode)
{
PreloadPages();
}
}*/
Program.StartupTimer.Stop();
var startupTime = CodeTimer.FormatTime(Program.StartupTimer.Elapsed);
Logger.Info($"App started ({startupTime})");
if (Program.Args.DebugOneClickInstall || !settingsManager.Settings.InstalledPackages.Any())
if (
Program.Args.DebugOneClickInstall
|| settingsManager.Settings.InstalledPackages.Count == 0
)
{
var viewModel = dialogFactory.Get<OneClickInstallViewModel>();
var dialog = new BetterContentDialog

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

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

@ -1,9 +1,231 @@
using StabilityMatrix.Avalonia.ViewModels.Base;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.Controls.Notifications;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls;
using NLog;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Models.TagCompletion;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Settings;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
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(InferenceSettingsPage))]
[Singleton]
public partial class InferenceSettingsViewModel : ViewModelBase { }
[Singleton, ManagedService]
public partial class InferenceSettingsViewModel : PageViewModelBase
{
private readonly INotificationService notificationService;
private readonly ISettingsManager settingsManager;
private readonly ICompletionProvider completionProvider;
/// <inheritdoc />
public override string Title => "Inference";
/// <inheritdoc />
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
[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(InferenceSettingsViewModel), 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;
public InferenceSettingsViewModel(INotificationService notificationService, IPrerequisiteHelper prerequisiteHelper, IPyRunner pyRunner, ServiceManager<ViewModelBase> dialogFactory, ICompletionProvider completionProvider, ITrackedDownloadService trackedDownloadService, IModelIndexService modelIndexService, INavigationService<SettingsViewModel> settingsNavigationService, IAccountsService accountsService, ISettingsManager settingsManager)
{
this.settingsManager = settingsManager;
this.notificationService = notificationService;
this.completionProvider = completionProvider;
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
);
ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn);
}
/// <summary>
/// Validator for <see cref="OutputImageFileNameFormat"/>
/// </summary>
public static ValidationResult ValidateOutputImageFileNameFormat(
string? format,
ValidationContext context
)
{
return FileNameFormatProvider.GetSample().Validate(format ?? string.Empty);
}
/// <inheritdoc />
public override void OnLoaded()
{
base.OnLoaded();
UpdateAvailableTagCompletionCsvs();
}
#region Commands
[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 = ["*.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
private void UpdateAvailableTagCompletionCsvs()
{
if (!settingsManager.IsLibraryDirSet)
return;
if (settingsManager.TagsDirectory is not { Exists: true } tagsDir)
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;
}
}
}

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

@ -0,0 +1,834 @@
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.Helper.HardwareInfo;
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;
// Integrations section
[ObservableProperty]
private bool isDiscordRichPresenceEnabled;
// Debug section
[ObservableProperty]
private string? debugPaths;
[ObservableProperty]
private string? debugCompatInfo;
[ObservableProperty]
private string? debugGpuInfo;
#region System Info
private static Lazy<IReadOnlyList<GpuInfo>> GpuInfosLazy { get; } =
new(() => HardwareHelper.IterGpuInfo().ToImmutableArray());
public static IReadOnlyList<GpuInfo> GpuInfos => GpuInfosLazy.Value;
[ObservableProperty]
private MemoryInfo memoryInfo;
private readonly DispatcherTimer hardwareInfoUpdateTimer =
new() { Interval = TimeSpan.FromSeconds(2.627) };
public Task<CpuInfo> CpuInfoAsync => HardwareHelper.GetCpuInfoAsync();
#endregion
// 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
);
DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler(
notificationService,
LogLevel.Warn
);
hardwareInfoUpdateTimer.Tick += OnHardwareInfoUpdateTimerTick;
}
/// <inheritdoc />
public override void OnLoaded()
{
base.OnLoaded();
hardwareInfoUpdateTimer.Start();
OnHardwareInfoUpdateTimerTick(null, null!);
}
/// <inheritdoc />
public override void OnUnloaded()
{
base.OnUnloaded();
hardwareInfoUpdateTimer.Stop();
}
/// <inheritdoc />
public override async Task OnLoadedAsync()
{
await base.OnLoadedAsync();
await notificationService.TryAsync(completionProvider.Setup());
// Start accounts update
accountsService.RefreshAsync().SafeFireAndForget();
}
private void OnHardwareInfoUpdateTimerTick(object? sender, EventArgs e)
{
MemoryInfo = HardwareHelper.GetMemoryInfo();
}
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 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 };
}

908
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;
[ObservableProperty]
private string? debugCompatInfo;
public IReadOnlyList<PageViewModelBase> SubPages { get; }
[ObservableProperty]
private string? debugGpuInfo;
// Info section
private const int VersionTapCountThreshold = 7;
[ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))]
private int versionTapCount;
private ObservableCollection<PageViewModelBase> currentPagePath = [];
[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());
private PageViewModelBase? currentPage;
UpdateAvailableTagCompletionCsvs();
}
public static ValidationResult ValidateOutputImageFileNameFormat(
string? format,
ValidationContext context
)
public SettingsViewModel(ServiceManager<ViewModelBase> vmFactory)
{
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
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
};
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
);
}
#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
};
CurrentPagePath.AddRange(SubPages);
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
CurrentPage = SubPages[0];
}
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
partial void OnCurrentPageChanged(PageViewModelBase? value)
{
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)
if (value is null)
{
// 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()
if (value is MainSettingsViewModel)
{
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();
CurrentPagePath.Clear();
CurrentPagePath.Add(value);
}
// 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}"));
CurrentPagePath.Clear();
CurrentPagePath.AddRange(new[] { SubPages[0], value });
}
[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
}

513
StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml

@ -1,50 +1,85 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl
x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage"
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: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:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
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: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"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:helpers="clr-namespace:StabilityMatrix.Avalonia.Helpers"
d:DataContext="{x:Static designData:DesignData.CheckpointBrowserViewModel}"
d:DesignHeight="700"
d:DesignWidth="800"
x:CompileBindings="True"
x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage">
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>
ClipToBounds="True"
CornerRadius="8">
<Border.ContextFlyout>
<MenuFlyout>
<MenuItem Header="{x:Static lang:Resources.Action_OpenOnCivitAi}"
Command="{Binding OpenModelCommand}">
@ -53,198 +88,320 @@
</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>
</Border.ContextFlyout>
<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"/>
<StackPanel
Grid.Row="0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button
Command="{Binding ToggleFavoriteCommand}"
Margin="0,8,8,0"
HorizontalAlignment="Right"
Margin="0,16,8,0"
VerticalAlignment="Top"
Classes="transparent-info"
Command="{Binding ToggleFavoriteCommand}"
FontSize="20"
Classes="info"
IsVisible="{Binding !IsFavorite}"
VerticalAlignment="Top">
IsVisible="{Binding !IsFavorite}">
<Grid>
<ui:SymbolIcon Symbol="StarAdd" />
</Grid>
</Button>
<Button
Command="{Binding ToggleFavoriteCommand}"
Margin="0,8,8,0"
HorizontalAlignment="Right"
Margin="0,16,8,0"
FontSize="20"
VerticalAlignment="Top"
Classes="success"
IsVisible="{Binding IsFavorite}"
VerticalAlignment="Top">
Command="{Binding ToggleFavoriteCommand}"
FontSize="20"
IsVisible="{Binding IsFavorite}">
<Grid>
<ui:SymbolIcon Symbol="StarFilled" />
</Grid>
</Button>
</StackPanel>
<!-- Username pill card -->
<Border
BoxShadow="inset 1.2 0 80 1.8 #66000000"
CornerRadius="16"
Margin="4"
Grid.Row="0"
HorizontalAlignment="Left"
VerticalAlignment="Bottom">
<Border.Resources>
<DropShadowEffect
x:Key="TextDropShadowEffect"
BlurRadius="12"
Color="#FF000000"
Opacity="0.9"/>
<DropShadowEffect
x:Key="ImageDropShadowEffect"
BlurRadius="12"
Color="#FF000000"
Opacity="0.2"/>
</Border.Resources>
<Button
Command="{x:Static helpers:IOCommands.OpenUrlCommand}"
CommandParameter="{Binding CivitModel.Creator.ProfileUrl}"
CornerRadius="16"
Classes="transparent"
Padding="10,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<controls:BetterAdvancedImage
Width="22"
Height="22"
Effect="{StaticResource ImageDropShadowEffect}"
CornerRadius="11"
RenderOptions.BitmapInterpolationMode="HighQuality"
IsVisible="{Binding CivitModel.Creator.Image, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Source="{Binding CivitModel.Creator.Image}"/>
<TextBlock
VerticalAlignment="Center"
Effect="{StaticResource TextDropShadowEffect}"
Text="{Binding CivitModel.Creator.Username}"/>
</StackPanel>
</Button>
</Border>
<StackPanel Orientation="Horizontal">
<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">
<!--
TextTrimming causing issues with unicode chars until
https://github.com/AvaloniaUI/Avalonia/pull/13385 is released
-->
<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}"
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}" />
</Grid>
<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>
</controls:Card>
<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>
</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>
@ -252,48 +409,51 @@
<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">
<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">
<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">
<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">
<ScrollViewer
Grid.Row="1"
Margin="8,0,8,0"
ScrollChanged="ScrollViewer_OnScrollChanged">
<ItemsRepeater ItemTemplate="{StaticResource CivitModelTemplate}"
HorizontalAlignment="Center"
ItemsSource="{Binding ModelCards}">
<ItemsRepeater.Layout>
<UniformGridLayout Orientation="Horizontal" />
@ -304,15 +464,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,29 +481,30 @@
</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">
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">
ToolTip.Tip="{x:Static lang:Resources.Label_PreviousPage}">
<avalonia:Icon Value="fa-solid fa-caret-left" />
</Button>
<Button Command="{Binding NextPage}"
<Button
Margin="0,0,8,0"
Command="{Binding NextPage}"
IsEnabled="{Binding CanGoToNextPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_NextPage}"
Margin="0,0,8,0">
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}">
IsEnabled="{Binding CanGoToLastPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_LastPage}">
<avalonia:Icon Value="fa-solid fa-forward-fast" />
</Button>
</StackPanel>
@ -352,19 +513,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();
}
}
}

174
StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml

@ -9,10 +9,11 @@
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:checkpointManager="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointManager"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:avalonia="https://github.com/projektanker/icons.avalonia"
d:DataContext="{x:Static mocks:DesignData.CheckpointsPageViewModel}"
x:CompileBindings="True"
x:DataType="vm:CheckpointsPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650"
x:Class="StabilityMatrix.Avalonia.Views.CheckpointsPage">
<controls:UserControlBase.Resources>
@ -44,10 +45,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">
@ -61,12 +67,17 @@
<ui:MenuFlyoutItem Command="{Binding OpenOnCivitAiCommand}"
Text="{x:Static lang:Resources.Action_OpenOnCivitAi}" IconSource="Link"
IsVisible="{Binding IsConnectedModel}" />
<ui:MenuFlyoutItem Command="{Binding CopyTriggerWordsCommand}"
IsVisible="{Binding CanShowTriggerWords}"
Text="{x:Static lang:Resources.Action_CopyTriggerWords}"
IconSource="Copy" />
</ui:FAMenuFlyout>
</controls:Card.ContextFlyout>
<Grid>
<!-- Main contents, hidden when IsLoading is true -->
<StackPanel MinHeight="70">
<Grid ColumnDefinitions="*, Auto" RowDefinitions="Auto,*,Auto" IsVisible="{Binding !IsLoading}">
<Grid ColumnDefinitions="*, Auto" RowDefinitions="Auto,*,Auto,Auto"
IsVisible="{Binding !IsLoading}">
<StackPanel
Grid.ColumnSpan="2"
HorizontalAlignment="Left"
@ -101,6 +112,17 @@
Source="{Binding PreviewImagePath}"
Stretch="Uniform"
IsVisible="{Binding IsConnectedModel}" />
<TextBlock
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
HorizontalAlignment="Left"
Margin="0,0,0,4"
TextWrapping="WrapWithOverflow"
IsVisible="{Binding CanShowTriggerWords}">
<Run Text="{x:Static lang:Resources.Label_TriggerWords}" />
<Run
Text="{Binding ConnectedModel.TrainedWordsString, FallbackValue=''}" />
</TextBlock>
</StackPanel>
</ToolTip.Tip>
<TextBlock
@ -174,7 +196,7 @@
Grid.Column="0"
Grid.Row="2"
IsEnabled="True"
Margin="4,4"
Margin="0,4"
Text="{Binding FileName}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding FileName}"
@ -191,6 +213,20 @@
<UniformGridLayout />
</ItemsRepeater.Layout>
</ItemsRepeater>
<TextBlock Grid.Row="3" Grid.Column="0"
Grid.ColumnSpan="2"
FontSize="11"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Margin="0,4,0,0"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
ToolTip.Tip="{Binding ConnectedModel.TrainedWordsString, FallbackValue=''}"
IsVisible="{Binding CanShowTriggerWords}">
<Run Text="{x:Static lang:Resources.Label_TriggerWords}" />
<Run Text="{Binding ConnectedModel.TrainedWordsString, FallbackValue=''}" />
</TextBlock>
</Grid>
</StackPanel>
@ -215,6 +251,7 @@
<!-- Checkpoint Folder Expander -->
<DataTemplate DataType="{x:Type checkpointManager:CheckpointFolder}" x:Key="CheckpointFolderGridDataTemplate">
<Expander
DragDrop.AllowDrop="True"
CornerRadius="8"
IsExpanded="{Binding IsExpanded}"
Margin="4"
@ -235,7 +272,8 @@
<ui:MenuFlyoutSeparator />
<ui:MenuFlyoutSubItem Text="{x:Static lang:Resources.Action_New}" IconSource="Add">
<ui:MenuFlyoutSubItem.Items>
<ui:MenuFlyoutItem Text="{x:Static lang:Resources.Label_Folder}" IconSource="OpenFolder"
<ui:MenuFlyoutItem Text="{x:Static lang:Resources.Label_Folder}"
IconSource="OpenFolder"
Command="{Binding CreateSubFolderCommand}" />
</ui:MenuFlyoutSubItem.Items>
</ui:MenuFlyoutSubItem>
@ -268,6 +306,7 @@
Background="Transparent"
DragDrop.AllowDrop="True">
<ItemsRepeater
Name="FilesRepeater"
ItemTemplate="{StaticResource CheckpointFileDataTemplate}"
ItemsSource="{Binding DisplayedCheckpointFiles}">
<ItemsRepeater.Layout>
@ -368,51 +407,35 @@
</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
OffContent="{x:Static lang:Resources.Label_ImportAsConnected}"
OnContent="{x:Static lang:Resources.Label_ImportAsConnected}"
IsChecked="{Binding IsImportAsConnected}"
Margin="24,4,24,-4"
ToolTip.Tip="{x:Static lang:Resources.Label_ImportAsConnectedExplanation}" />
<ToggleSwitch
OffContent="{x:Static lang:Resources.Label_ShowModelImages}"
OnContent="{x:Static lang:Resources.Label_ShowModelImages}"
IsChecked="{Binding ShowConnectedModelImages}"
Margin="24,-4,24,4" />
</StackPanel>
<StackPanel
Grid.Column="1"
Grid.Row="0"
Spacing="8"
HorizontalAlignment="Right"
Margin="16,0"
Orientation="Horizontal">
<TextBox Margin="16, 16"
<StackPanel Orientation="Horizontal">
<TextBox Margin="16, 16, 8, 16"
Watermark="{x:Static lang:Resources.Action_Search}"
Height="16"
MinWidth="150"
Text="{Binding SearchFilter, Mode=TwoWay}"/>
<controls:ProgressRing
Width="16"
Height="16"
BorderThickness="4"
IsIndeterminate="True"
IsVisible="{Binding IsIndexing}"/>
<TextBlock Text="{x:Static lang:Resources.Label_Indexing}" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding IsIndexing}"/>
<Button
Command="{Binding OpenModelsFolderCommand}">
<TextBlock Text="{x:Static lang:Resources.Label_ModelsFolder}"/>
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>
<DropDownButton
x:Name="CategoriesDropdown"
Content="{x:Static lang:Resources.Label_Categories}"
Margin="8"
Margin="8,0"
VerticalAlignment="Center"
HorizontalAlignment="Right">
<DropDownButton.Flyout>
@ -429,12 +452,75 @@
</DropDownButton.Flyout>
</DropDownButton>
</StackPanel>
<ui:CommandBar
Grid.Row="0"
Grid.Column="1"
x:Name="CommandBar"
Margin="8,0,0,0"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
HorizontalAlignment="Right"
HorizontalContentAlignment="Right"
DefaultLabelPosition="Right">
<ui:CommandBar.PrimaryCommands>
<ui:CommandBarButton
IconSource="OpenFolder"
VerticalAlignment="Center"
Label="{x:Static lang:Resources.Label_ModelsFolder}"
Command="{Binding OpenModelsFolderCommand}" />
<ui:CommandBarButton
IconSource="Refresh"
VerticalAlignment="Center"
Command="{Binding OnLoaded}"
Label="{x:Static lang:Resources.Action_Refresh}" />
<ui:CommandBarToggleButton
IconSource="Image"
Label="{x:Static lang:Resources.Label_ShowModelImages}"
IsChecked="{Binding ShowConnectedModelImages, Mode=TwoWay}">
</ui:CommandBarToggleButton>
</ui:CommandBar.PrimaryCommands>
<ui:CommandBar.SecondaryCommands>
<ui:CommandBarToggleButton
IconSource="Cloud"
Label="{x:Static lang:Resources.Label_ImportAsConnected}"
IsChecked="{Binding IsImportAsConnected, Mode=TwoWay}">
</ui:CommandBarToggleButton>
</ui:CommandBar.SecondaryCommands>
</ui:CommandBar>
<ui:TeachingTip Grid.Row="0" Grid.Column="0" Name="TeachingTip1"
Target="{Binding #CategoriesDropdown}"
Title="{x:Static lang:Resources.TeachingTip_MoreCheckpointCategories}"
PreferredPlacement="Bottom"
IsOpen="{Binding IsCategoryTipOpen}" />
<StackPanel
IsVisible="False"
Grid.Column="1"
Grid.Row="0"
Spacing="8"
HorizontalAlignment="Right"
Margin="16,0"
Orientation="Horizontal">
<controls:ProgressRing
Width="16"
Height="16"
BorderThickness="4"
IsIndeterminate="True"
IsVisible="{Binding IsIndexing}" />
<TextBlock Text="{x:Static lang:Resources.Label_Indexing}" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding IsIndexing}" />
</StackPanel>
<!-- Main view with model cards -->
<ScrollViewer
Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="1"
x:Name="MainScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<Grid>

67
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,13 +62,37 @@ 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)
{
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;
}
}
}
private static void OnDragExit(object? sender, DragEventArgs e)
@ -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();
}
}

2
StabilityMatrix.Avalonia/Views/Dialogs/PackageModificationDialog.axaml.cs

@ -34,6 +34,8 @@ public partial class PackageModificationDialog : UserControlBase
textMate.SetGrammar(scope);
textMate.SetTheme(options.LoadTheme(ThemeName.DarkPlus));
editor.Options.ShowBoxForControlCharacters = false;
}
EventManager.Instance.ScrollToBottomRequested += (_, _) =>

20
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,10 +27,12 @@ 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;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Update;
using StabilityMatrix.Core.Processes;
@ -44,7 +47,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 +61,7 @@ public partial class MainWindow : AppWindowBase
public MainWindow(
INotificationService notificationService,
INavigationService navigationService
INavigationService<MainWindowViewModel> navigationService
)
{
this.notificationService = notificationService;
@ -74,6 +77,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 +163,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(() =>
@ -170,7 +184,7 @@ public partial class MainWindow : AppWindowBase
var tip = this.FindControl<TeachingTip>("UpdateAvailableTeachingTip")!;
tip.Target = target;
tip.Subtitle = $"{Compat.AppVersion} -> {updateInfo.Version}";
tip.Subtitle = $"{Compat.AppVersion.ToDisplayString()} -> {updateInfo.Version}";
tip.IsOpen = true;
}
});

273
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 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"
Text="{x:Static lang:Resources.Label_OutputFolder}"
Margin="4" />
<ComboBox Grid.Column="0"
Grid.Row="1" ItemsSource="{Binding Categories}"
Margin="4"
Text="{x:Static lang:Resources.Label_OutputFolder}" />
<ComboBox
Grid.Row="1"
Grid.Column="0"
MinWidth="150"
Margin="4,0"
SelectedItem="{Binding SelectedCategory}"
VerticalAlignment="Stretch"
MinWidth="150">
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"
<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}"
Text="{x:Static lang:Resources.Label_OutputType}" />
<ComboBox
Grid.Row="1"
Grid.Column="1"
MinWidth="150"
Margin="4,0"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center" />
VerticalAlignment="Center"
VerticalContentAlignment="Center"
IsVisible="{Binding CanShowOutputTypes}"
ItemsSource="{Binding OutputTypes}"
SelectedItem="{Binding SelectedOutputType}" />
<TextBlock Grid.Row="0"
<TextBlock
Grid.Row="0"
Grid.Column="2"
Margin="4"
Text="{x:Static lang:Resources.Action_Search}" />
<TextBox Grid.Row="1"
<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"
MinWidth="150"
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>
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="1"
Grid.Column="4"
<TextBlock
Grid.Row="0"
Grid.Column="3"
Margin="4"
IsVisible="{Binding !!NumItemsSelected}"
FontSize="16"
Margin="8, 0"
Text="{Binding NumImagesSelected}" />
<ui:CommandBar
Grid.Row="1"
Grid.Column="3"
Margin="8,0,0,0"
VerticalAlignment="Center"
TextAlignment="Center"
HorizontalAlignment="Right"
Text="{Binding NumImagesSelected, FallbackValue=1234 images selected}" />
VerticalContentAlignment="Center"
DefaultLabelPosition="Right">
<Button Grid.Row="1"
Grid.Column="5"
VerticalAlignment="Bottom"
Padding="12, 0"
Height="40"
Classes="danger"
<ui:CommandBar.PrimaryCommands>
<ui:CommandBarButton
IsEnabled="{Binding !!NumItemsSelected}"
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"
IconSource="Delete" />
<ui:CommandBarSeparator />
<ui:CommandBarButton
Command="{Binding SelectAll}"
IsVisible="{Binding !NumItemsSelected}" />
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" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate DataType="{x:Type outputsPage:OutputImageViewModel}">
<selectableImageCard:SelectableImageButton
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}"
ImageHeight="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).ImageSize.Height}"
ImageWidth="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).ImageSize.Width}"
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}"
<ui:MenuFlyoutSubItem
IconSource="Share"
IsVisible="{Binding ImageFile.GenerationParameters, Converter={x:Static ObjectConverters.IsNotNull}}">
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();
}
}

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

@ -1,16 +1,147 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui"
<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: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: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: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"
d:DataContext="{x:Static mocks:DesignData.InferenceSettingsViewModel}"
d:DesignHeight="650"
d:DesignWidth="900"
x:DataType="vmSettings:InferenceSettingsViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StabilityMatrix.Avalonia.Views.Settings.InferenceSettingsPage">
Welcome to Avalonia!
mc:Ignorable="d">
<controls:UserControlBase.Styles>
<Style Selector="sg|SpacedGrid &gt; ui|SettingsExpander">
<Setter Property="Margin" Value="8,0" />
</Style>
</controls:UserControlBase.Styles>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,16" Spacing="8">
<!-- Prompt -->
<sg:SpacedGrid RowDefinitions="Auto,*" RowSpacing="4">
<TextBlock
Margin="0,0,0,4"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Prompt}" />
<!-- Auto Completion -->
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
Header="{x:Static lang:Resources.Label_AutoCompletion}">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-solid fa-wand-magic-sparkles" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsPromptCompletionEnabled}" />
</ui:SettingsExpander.Footer>
<!-- Tag csv selection -->
<ui:SettingsExpanderItem
Content="{x:Static lang:Resources.Label_PromptTags}"
Description="{x:Static lang:Resources.Label_PromptTagsDescription}"
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="{x:Static lang:Resources.Label_PromptTagsImport}"
IconSource="Add"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding ImportTagCsvCommand}" Content="Import" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Remove underscores -->
<ui:SettingsExpanderItem
Content="{x:Static lang:Resources.Label_CompletionReplaceUnderscoresWithSpaces}"
IconSource="Underline"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<CheckBox Margin="8" IsChecked="{Binding IsCompletionRemoveUnderscoresEnabled}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</sg:SpacedGrid>
<!-- General -->
<sg:SpacedGrid RowDefinitions="Auto,*,*" RowSpacing="4">
<TextBlock
Margin="0,0,0,4"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_General}" />
<!-- Image Viewer -->
<ui:SettingsExpander
Grid.Row="1"
Header="{x:Static lang:Resources.Label_ImageViewer}"
IconSource="Image"
IsExpanded="True">
<!-- 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="2"
Header="{x:Static lang:Resources.Label_OutputImageFiles}"
IsExpanded="True">
<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="2"
IsOpen="{Binding #OutputImageFileNameFormatTextBox.IsFocused}"
PreferredPlacement="Top"
Target="{Binding #OutputImageFileNameFormatTextBox, Mode=OneWay}">
<DataGrid AutoGenerateColumns="True" ItemsSource="{Binding OutputImageFileNameFormatVars}" />
<!--<mdxaml:MarkdownScrollViewer
Markdown="{Binding OutputImageFileNameFormatGuideMarkdown}"/>-->
</ui:TeachingTip>
</sg:SpacedGrid>
</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);
}
}

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

@ -0,0 +1,611 @@
<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:hardwareInfo="clr-namespace:StabilityMatrix.Core.Helper.HardwareInfo;assembly=StabilityMatrix.Core"
xmlns:helper="clr-namespace:StabilityMatrix.Core.Helper;assembly=StabilityMatrix.Core"
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:IndexPlusOneConverter x:Key="IndexPlusOneConverter" />
<converters:EnumStringConverter x:Key="EnumStringConverter" />
<converters:EnumToBooleanConverter x:Key="EnumBoolConverter" />
</controls:UserControlBase.Resources>
<controls:UserControlBase.Styles>
<Style Selector="sg|SpacedGrid &gt; ui|SettingsExpander">
<Setter Property="Margin" Value="8,0" />
</Style>
</controls:UserControlBase.Styles>
<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>
<!-- General -->
<sg:SpacedGrid RowDefinitions="Auto,*" RowSpacing="4">
<TextBlock
Margin="0,0,0,4"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_General}" />
<!-- Link to Inference Sub-Settings -->
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
ActionIconSource="ChevronRight"
Command="{Binding NavigateToSubPageCommand}"
CommandParameter="{x:Type vmSettings:InferenceSettingsViewModel}"
Header="{x:Static lang:Resources.Label_Inference}"
IsClickEnabled="True">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource
FontSize="10"
IsFilled="True"
Symbol="AppGeneric" />
</ui:SettingsExpander.IconSource>
</ui:SettingsExpander>
</sg:SpacedGrid>
<!-- 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,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"
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"
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"
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>
<ui:SettingsExpander Grid.Row="4" Header="{x:Static lang:Resources.Label_SystemInformation}">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource
FontSize="10"
IsFilled="True"
Symbol="Info" />
</ui:SettingsExpander.IconSource>
<!-- Cpu -->
<ui:SettingsExpanderItem>
<ui:SettingsExpanderItem.IconSource>
<controls:FASymbolIconSource
FontSize="10"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Symbol="fa-solid fa-microchip" />
</ui:SettingsExpanderItem.IconSource>
<sg:SpacedGrid
DataContext="{Binding CpuInfoAsync^}"
ColumnDefinitions="Auto,Auto"
ColumnSpacing="16"
RowDefinitions="Auto,Auto">
<TextBlock Grid.Column="0" Text="CPU" />
<SelectableTextBlock
Grid.Row="0"
Grid.Column="1"
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding ProcessorCaption}"
TextWrapping="WrapWithOverflow" />
</sg:SpacedGrid>
</ui:SettingsExpanderItem>
<!-- Memory -->
<ui:SettingsExpanderItem>
<ui:SettingsExpanderItem.IconSource>
<controls:FASymbolIconSource
FontSize="10"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Symbol="fa-solid fa-memory" />
</ui:SettingsExpanderItem.IconSource>
<sg:SpacedGrid
DataContext="{Binding MemoryInfo}"
ColumnDefinitions="Auto,Auto"
ColumnSpacing="16"
RowDefinitions="Auto,Auto">
<TextBlock Grid.Column="0" Text="Total Memory" />
<SelectableTextBlock
Grid.Row="0"
Grid.Column="1"
Foreground="{DynamicResource TextControlPlaceholderForeground}"
TextWrapping="WrapWithOverflow">
<SelectableTextBlock.Text>
<MultiBinding StringFormat="{}{0} ({1} usable)">
<Binding Path="TotalInstalledBytes" Converter="{x:Static converters:StringFormatConverters.MemoryBytes}"/>
<Binding Path="TotalPhysicalBytes" Converter="{x:Static converters:StringFormatConverters.MemoryBytes}"/>
</MultiBinding>
</SelectableTextBlock.Text>
</SelectableTextBlock>
<TextBlock
Grid.Row="1"
Grid.Column="0"
Text="Available Memory" />
<SelectableTextBlock
Grid.Row="1"
Grid.Column="1"
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding AvailablePhysicalBytes, Converter={x:Static converters:StringFormatConverters.MemoryBytes}}"
TextWrapping="WrapWithOverflow" />
</sg:SpacedGrid>
</ui:SettingsExpanderItem>
<!-- GPUs -->
<ui:SettingsExpanderItem>
<ui:SettingsExpanderItem.IconSource>
<controls:FASymbolIconSource
FontSize="10"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Symbol="fa-solid fa-tachograph-digital" />
</ui:SettingsExpanderItem.IconSource>
<ItemsControl ItemsSource="{Binding GpuInfos}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="hardwareInfo:GpuInfo">
<sg:SpacedGrid ColumnDefinitions="Auto,Auto,Auto" RowDefinitions="Auto,Auto">
<TextBlock Text="{Binding Index, StringFormat={}{0}, Converter={StaticResource IndexPlusOneConverter}}" Theme="{DynamicResource BodyStrongTextBlockStyle}" />
<SelectableTextBlock
Grid.Row="0"
Grid.Column="1"
Text="{Binding Name}" />
<SelectableTextBlock
Grid.Row="1"
Grid.Column="1"
Grid.ColumnSpan="2"
Foreground="{DynamicResource TextControlPlaceholderForeground}"
IsVisible="{Binding !!MemoryBytes}"
Text="{Binding MemoryBytes, Converter={x:Static converters:StringFormatConverters.MemoryBytes}}"
TextWrapping="WrapWithOverflow" />
</sg:SpacedGrid>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="8" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ui:SettingsExpanderItem>
</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();
}
}
}

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

Loading…
Cancel
Save