JT
1 year ago
339 changed files with 17986 additions and 1773 deletions
@ -0,0 +1,42 @@ |
|||||||
|
node("Diligence") { |
||||||
|
def repoName = "StabilityMatrix" |
||||||
|
def author = "ionite34" |
||||||
|
def version = "" |
||||||
|
|
||||||
|
stage('Clean') { |
||||||
|
deleteDir() |
||||||
|
} |
||||||
|
|
||||||
|
stage('Checkout') { |
||||||
|
git branch: env.BRANCH_NAME, credentialsId: 'Ionite', url: "https://github.com/${author}/${repoName}.git" |
||||||
|
} |
||||||
|
|
||||||
|
// stage('Test') { |
||||||
|
// sh "dotnet test StabilityMatrix.Tests" |
||||||
|
// } |
||||||
|
|
||||||
|
if (env.BRANCH_NAME == 'main') { |
||||||
|
|
||||||
|
stage('Set Version') { |
||||||
|
script { |
||||||
|
if (env.TAG_NAME) { |
||||||
|
version = env.TAG_NAME.replaceFirst(/^v/, '') |
||||||
|
} else { |
||||||
|
version = VersionNumber projectStartDate: '2023-06-21', versionNumberString: '${BUILDS_ALL_TIME}', worstResultForIncrement: 'SUCCESS' |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
stage('Publish') { |
||||||
|
sh "/home/jenkins/.dotnet/tools/pupnet --runtime linux-x64 --kind appimage --app-version ${version} --clean -y" |
||||||
|
} |
||||||
|
|
||||||
|
stage ('Archive Artifacts') { |
||||||
|
archiveArtifacts artifacts: 'out/*.appimage', followSymlinks: false |
||||||
|
} |
||||||
|
} else { |
||||||
|
stage('Publish') { |
||||||
|
sh "/home/jenkins/.dotnet/tools/pupnet --runtime linux-x64 --kind appimage --clean -y" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,292 @@ |
|||||||
|
################################################################################ |
||||||
|
# PUPNET DEPLOY: 1.4.0 |
||||||
|
################################################################################ |
||||||
|
|
||||||
|
######################################## |
||||||
|
# APP PREAMBLE |
||||||
|
######################################## |
||||||
|
|
||||||
|
# Mandatory application base name. This MUST BE the base name of the main executable file. It should NOT |
||||||
|
# include any directory part or extension, i.e. do not append '.exe' or '.dll'. It should not contain |
||||||
|
# spaces or invalid filename characters. |
||||||
|
AppBaseName = StabilityMatrix.Avalonia |
||||||
|
|
||||||
|
# Mandatory application friendly name. |
||||||
|
AppFriendlyName = Stability Matrix |
||||||
|
|
||||||
|
# Mandatory application ID in reverse DNS form. This should stay constant for lifetime of the software. |
||||||
|
AppId = zone.lykos.stabilitymatrix |
||||||
|
|
||||||
|
# Mandatory application version and package release of form: 'VERSION[RELEASE]'. Use optional square |
||||||
|
# brackets to denote package release, i.e. '1.2.3[1]'. Release refers to a change to the deployment |
||||||
|
# package, rather the application. If release part is absent (i.e. '1.2.3'), the release value defaults |
||||||
|
# to '1'. Note that the version-release value given here may be overridden from the command line. |
||||||
|
AppVersionRelease = 2.0.0[1] |
||||||
|
|
||||||
|
# Mandatory single line application short summary description. |
||||||
|
AppShortSummary = Package and checkpoint manager for Stable Diffusion. |
||||||
|
|
||||||
|
# Optional multi-line (surround with triple """ quotes) application description which may provide |
||||||
|
# longer text than AppShortSummary. Text separated by an empty line will be treated as paragraphs |
||||||
|
# (complex formatting should be avoided). The content is used by package builders where supported, |
||||||
|
# including RPM and DEB, and may optionally be used to populate the '<description>' element in the |
||||||
|
# AppStream metadata through the use of a macro variable. |
||||||
|
AppDescription = |
||||||
|
|
||||||
|
# Mandatory application license ID. This should be one of the recognised SPDX license |
||||||
|
# identifiers, such as: 'MIT', 'GPL-3.0-or-later' or 'Apache-2.0'. For a proprietary or |
||||||
|
# custom license, use 'LicenseRef-Proprietary' or 'LicenseRef-LICENSE'. |
||||||
|
AppLicenseId = LicenseRef-Proprietary |
||||||
|
|
||||||
|
# Optional path to application copyright/license text file. If provided, it will be packaged with the |
||||||
|
# application and used with package builders where supported. |
||||||
|
AppLicenseFile = LICENSE |
||||||
|
|
||||||
|
# Optional path to application changelog file. IMPORTANT. If given, this file should contain version |
||||||
|
# information in a predefined format. Namely, it should contain one or more version headings of form: |
||||||
|
# '+ VERSION;DATE', under which are to be listed change items of form: '- Change description'. Formatted |
||||||
|
# information will be parsed and used to populate AppStream metadata. Additionally, it will be packaged |
||||||
|
# with the application and used with package builders where supported. NOTE. Superfluous text in the file |
||||||
|
# is ignored, so the file may also contain README information. |
||||||
|
# For information: https://github.com/kuiperzone/PupNet-Deploy. |
||||||
|
AppChangeFile = |
||||||
|
|
||||||
|
######################################## |
||||||
|
# PUBLISHER |
||||||
|
######################################## |
||||||
|
|
||||||
|
# Mandatory publisher, group or creator. |
||||||
|
PublisherName = Lykos |
||||||
|
|
||||||
|
# Optional copyright statement. |
||||||
|
PublisherCopyright = Copyright (C) Lykos 2023 |
||||||
|
|
||||||
|
# Optional publisher or application web-link name. Note that Windows Setup packages |
||||||
|
# require both PublisherLinkName and PublisherLinkUrl in order to include the link as |
||||||
|
# an item in program menu entries. Do not modify name, as may leave old entries in updated installations. |
||||||
|
PublisherLinkName = Home Page |
||||||
|
|
||||||
|
# Optional publisher or application web-link URL. |
||||||
|
PublisherLinkUrl = https://lykos.ai |
||||||
|
|
||||||
|
# Publisher or maintainer email contact. Although optional, some package builders (i.e. DEB) require it |
||||||
|
# and may warn or fail unless provided. |
||||||
|
PublisherEmail = stability-matrix@lykos.ai |
||||||
|
|
||||||
|
######################################## |
||||||
|
# DESKTOP INTEGRATION |
||||||
|
######################################## |
||||||
|
|
||||||
|
# Boolean (true or false) which indicates whether the application is hidden on the desktop. It is used to |
||||||
|
# populate the 'NoDisplay' field of the .desktop file. The default is false. Setting to true will also |
||||||
|
# cause the main application start menu entry to be omitted for Windows Setup. |
||||||
|
DesktopNoDisplay = false |
||||||
|
|
||||||
|
# Boolean (true or false) which indicates whether the application runs in the terminal, rather than |
||||||
|
# providing a GUI. It is used to populate the 'Terminal' field of the .desktop file. |
||||||
|
DesktopTerminal = false |
||||||
|
|
||||||
|
# Optional path to a Linux desktop file. If empty (default), one will be generated automatically from |
||||||
|
# the information in this file. Supplying a custom file, however, allows for mime-types and |
||||||
|
# internationalisation. If supplied, the file MUST contain the line: 'Exec=${INSTALL_EXEC}' |
||||||
|
# in order to use the correct install location. Other macros may be used to help automate the content. |
||||||
|
# Note. PupNet Deploy can generate you a desktop file. Use --help and 'pupnet --help macro' for reference. |
||||||
|
# See: https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html |
||||||
|
DesktopFile = |
||||||
|
|
||||||
|
# Optional command name to start the application from the terminal. If, for example, AppBaseName is |
||||||
|
# 'Zone.Kuiper.HelloWorld', the value here may be set to a simpler and/or lower-case variant such as |
||||||
|
# 'helloworld'. It must not contain spaces or invalid filename characters. Do not add any extension such |
||||||
|
# as '.exe'. If empty, the application will not be in the path and cannot be started from the command line. |
||||||
|
# For Windows Setup packages, see also SetupCommandPrompt. StartCommand is not |
||||||
|
# supported for all packages kinds (i.e. Flatpak). Default is empty (none). |
||||||
|
StartCommand = stabilitymatrix |
||||||
|
|
||||||
|
# Optional category for the application. The value should be one of the recognised Freedesktop top-level |
||||||
|
# categories, such as: Audio, Development, Game, Office, Utility etc. Only a single value should be |
||||||
|
# provided here which will be used, where supported, to populate metadata. The default is empty. |
||||||
|
# See: https://specifications.freedesktop.org/menu-spec/latest/apa.html |
||||||
|
PrimeCategory = Utility |
||||||
|
|
||||||
|
# Path to AppStream metadata file. It is optional, but recommended as it is used by software centers. |
||||||
|
# Note. The contents of the files may use macro variables. Use 'pupnet --help macro' for reference. |
||||||
|
# See: https://docs.appimage.org/packaging-guide/optional/appstream.html |
||||||
|
MetaFile = |
||||||
|
|
||||||
|
# Optional icon file paths. The value may include multiple filenames separated with semicolon or given |
||||||
|
# in multi-line form. Valid types are SVG, PNG and ICO (ICO ignored on Linux). Note that the inclusion |
||||||
|
# of a scalable SVG is preferable on Linux, whereas PNGs must be one of the standard sizes and MUST |
||||||
|
# include the size in the filename in the form: name.32x32.png' or 'name.32.png'. |
||||||
|
IconFiles = """ |
||||||
|
StabilityMatrix.Avalonia/Assets/Icon.512x512.png |
||||||
|
StabilityMatrix.Avalonia/Assets/Icon.ico |
||||||
|
""" |
||||||
|
|
||||||
|
######################################## |
||||||
|
# DOTNET PUBLISH |
||||||
|
######################################## |
||||||
|
|
||||||
|
# Optional path relative to this file in which to find the dotnet project (.csproj) or solution (.sln) |
||||||
|
# file, or the directory containing it. If empty (default), a single project or solution file is |
||||||
|
# expected under the same directory as this file. IMPORTANT. If set to 'NONE', dotnet publish |
||||||
|
# is disabled (not called). Instead, only DotnetPostPublish is called. |
||||||
|
DotnetProjectPath = StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj |
||||||
|
|
||||||
|
# Optional arguments supplied to 'dotnet publish'. Do NOT include '-r' (runtime), or '-c' (configuration) |
||||||
|
# here as they will be added according to command line arguments. Typically you want as a minimum: |
||||||
|
# '-p:Version=${APP_VERSION} --self-contained true'. Additional useful arguments include: |
||||||
|
# '-p:DebugType=None -p:DebugSymbols=false -p:PublishSingleFile=true -p:PublishReadyToRun=true |
||||||
|
# -p:PublishTrimmed=true -p:TrimMode=link'. Note. This value may use macro variables. Use 'pupnet --help macro' |
||||||
|
# for reference. See: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-publish |
||||||
|
DotnetPublishArgs = -p:Version=${APP_VERSION} --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -p:PublishTrimmed=true |
||||||
|
|
||||||
|
# Post-publish (or standalone build) command on Linux (ignored on Windows). It is called after dotnet |
||||||
|
# publish, but before the final output is built. This could, for example, be a script which copies |
||||||
|
# additional files into the build directory given by ${BUILD_APP_BIN}. The working directory will be |
||||||
|
# the location of this file. This value is optional, but becomes mandatory if DotnetProjectPath equals |
||||||
|
# 'NONE'. Note. This value may use macro variables. Additionally, scripts may use these as environment |
||||||
|
# variables. Use 'pupnet --help macro' for reference. |
||||||
|
DotnetPostPublish = |
||||||
|
|
||||||
|
# Post-publish (or standalone build) command on Windows (ignored on Linux). This should perform |
||||||
|
# the equivalent operation, as required, as DotnetPostPublish, but using DOS commands and batch |
||||||
|
# scripts. Multiple commands may be specified, separated by semicolon or given in multi-line form. |
||||||
|
# Note. This value may use macro variables. Additionally, scripts may use these as environment |
||||||
|
# variables. Use 'pupnet --help macro' for reference. |
||||||
|
DotnetPostPublishOnWindows = |
||||||
|
|
||||||
|
######################################## |
||||||
|
# PACKAGE OUTPUT |
||||||
|
######################################## |
||||||
|
|
||||||
|
# Optional package name (excludes version etc.). If empty, defaults to AppBaseName. However, it is |
||||||
|
# used not only to specify the base output filename, but to identify the application in DEB and RPM |
||||||
|
# packages. You may wish, therefore, to ensure that the value represents a unique name. Naming |
||||||
|
# requirements are strict and must contain only alpha-numeric and '-', '+' and '.' characters. |
||||||
|
PackageName = StabilityMatrix |
||||||
|
|
||||||
|
# Output directory, or subdirectory relative to this file. It will be created if it does not exist and |
||||||
|
# will contain the final deploy output files. If empty, it defaults to the location of this file. |
||||||
|
OutputDirectory = Release/linux-x64 |
||||||
|
|
||||||
|
######################################## |
||||||
|
# APPIMAGE OPTIONS |
||||||
|
######################################## |
||||||
|
|
||||||
|
# Additional arguments for use with appimagetool. Useful for signing. Default is empty. |
||||||
|
AppImageArgs = |
||||||
|
|
||||||
|
# Boolean (true or false) which sets whether to include the application version in the AppImage filename, |
||||||
|
# i.e. 'HelloWorld-1.2.3-x86_64.AppImage'. Default is false. It is ignored if the output filename is |
||||||
|
# specified at command line. |
||||||
|
AppImageVersionOutput = false |
||||||
|
|
||||||
|
######################################## |
||||||
|
# FLATPAK OPTIONS |
||||||
|
######################################## |
||||||
|
|
||||||
|
# The runtime platform. Invariably for .NET (inc. Avalonia), this should be 'org.freedesktop.Platform'. |
||||||
|
# Refer: https://docs.flatpak.org/en/latest/available-runtimes.html |
||||||
|
FlatpakPlatformRuntime = org.freedesktop.Platform |
||||||
|
|
||||||
|
# The platform SDK. Invariably for .NET (inc. Avalonia applications) this should be 'org.freedesktop.Sdk'. |
||||||
|
# The SDK must be installed on the build system. |
||||||
|
FlatpakPlatformSdk = org.freedesktop.Sdk |
||||||
|
|
||||||
|
# The platform runtime version. The latest available version may change periodically. |
||||||
|
# Refer to Flatpak documentation. |
||||||
|
FlatpakPlatformVersion = 22.08 |
||||||
|
|
||||||
|
# Flatpak manifest 'finish-args' sandbox permissions. Optional, but if empty, the application will have |
||||||
|
# extremely limited access to the host environment. This option may be used to grant required |
||||||
|
# application permissions. Values here should be prefixed with '--' and separated by semicolon or given |
||||||
|
# in multi-line form. Refer: https://docs.flatpak.org/en/latest/sandbox-permissions.html |
||||||
|
FlatpakFinishArgs = """ |
||||||
|
--socket=wayland |
||||||
|
--socket=x11 |
||||||
|
--filesystem=host |
||||||
|
--share=network |
||||||
|
""" |
||||||
|
|
||||||
|
# Additional arguments for use with flatpak-builder. Useful for signing. Default is empty. |
||||||
|
# See flatpak-builder --help. |
||||||
|
FlatpakBuilderArgs = |
||||||
|
|
||||||
|
######################################## |
||||||
|
# RPM OPTIONS |
||||||
|
######################################## |
||||||
|
|
||||||
|
# Boolean (true or false) which specifies whether to build the RPM package with 'AutoReq' equal to yes or no. |
||||||
|
# For dotnet application, the value should typically be false, but see RpmRequires below. |
||||||
|
# Refer: https://rpm-software-management.github.io/rpm/manual/spec.html |
||||||
|
RpmAutoReq = false |
||||||
|
|
||||||
|
# Boolean (true or false) which specifies whether to build the RPM package with 'AutoProv' equal to yes or no. |
||||||
|
# Refer: https://rpm-software-management.github.io/rpm/manual/spec.html |
||||||
|
RpmAutoProv = true |
||||||
|
|
||||||
|
# Optional list of RPM dependencies. The list may include multiple values separated with semicolon or given |
||||||
|
# in multi-line form. If empty, a self-contained dotnet package will successfully run on many (but not all) |
||||||
|
# Linux distros. In some cases, it will be necessary to explicitly specify additional dependencies. |
||||||
|
# Default values are recommended for use with dotnet and RPM packages at the time of writing. |
||||||
|
# For updated information, see: https://learn.microsoft.com/en-us/dotnet/core/install/linux-rhel#dependencies |
||||||
|
RpmRequires = """ |
||||||
|
krb5-libs |
||||||
|
libicu |
||||||
|
openssl-libs |
||||||
|
zlib |
||||||
|
""" |
||||||
|
|
||||||
|
######################################## |
||||||
|
# DEBIAN OPTIONS |
||||||
|
######################################## |
||||||
|
|
||||||
|
# Optional list of Debian dependencies. The list may include multiple values separated with semicolon or given |
||||||
|
# in multi-line form. If empty, a self-contained dotnet package will successfully run on many (but not all) |
||||||
|
# Linux distros. In some cases, it will be necessary to explicitly specify additional dependencies. |
||||||
|
# Default values are recommended for use with dotnet and Debian packages at the time of writing. |
||||||
|
# For updated information, see: https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu#dependencies |
||||||
|
DebianRecommends = """ |
||||||
|
libc6 |
||||||
|
libgcc1 |
||||||
|
libgcc-s1 |
||||||
|
libgssapi-krb5-2 |
||||||
|
libicu |
||||||
|
libssl |
||||||
|
libstdc++6 |
||||||
|
libunwind |
||||||
|
zlib1g |
||||||
|
""" |
||||||
|
|
||||||
|
######################################## |
||||||
|
# WINDOWS SETUP OPTIONS |
||||||
|
######################################## |
||||||
|
|
||||||
|
# Boolean (true or false) which specifies whether the application is to be installed in administrative |
||||||
|
# mode, or per-user. Default is false. See: https://jrsoftware.org/ishelp/topic_admininstallmode.htm |
||||||
|
SetupAdminInstall = false |
||||||
|
|
||||||
|
# Optional command prompt title. The Windows installer will NOT add your application to the path. However, |
||||||
|
# if your package contains a command-line utility, setting this value will ensure that a 'Command Prompt' |
||||||
|
# program menu entry is added (with this title) which, when launched, will open a dedicated command |
||||||
|
# window with your application directory in its path. Default is empty. See also StartCommand. |
||||||
|
SetupCommandPrompt = Command Prompt |
||||||
|
|
||||||
|
# Mandatory value which specifies minimum version of Windows that your software runs on. Windows 8 = 6.2, |
||||||
|
# Windows 10/11 = 10. Default: 10. See: https://jrsoftware.org/ishelp/topic_setup_minversion.htm |
||||||
|
SetupMinWindowsVersion = 10 |
||||||
|
|
||||||
|
# Optional name and parameters of the Sign Tool to be used to digitally sign: the installer, |
||||||
|
# uninstaller, and contained exe and dll files. If empty, files will not be signed. |
||||||
|
# See: https://jrsoftware.org/ishelp/topic_setup_signtool.htm |
||||||
|
SetupSignTool = |
||||||
|
|
||||||
|
# Optional suffix for the installer output filename. The default is empty, but you may wish set it to: |
||||||
|
# 'Setup' or similar. This, for example, will output a file of name: HelloWorldSetup-x86_64.exe |
||||||
|
# Ignored if the output filename is specified at command line. |
||||||
|
SetupSuffixOutput = |
||||||
|
|
||||||
|
# Boolean (true or false) which sets whether to include the application version in the setup filename, |
||||||
|
# i.e. 'HelloWorld-1.2.3-x86_64.exe'. Default is false. Ignored if the output filename is specified |
||||||
|
# at command line. |
||||||
|
SetupVersionOutput = false |
@ -0,0 +1,31 @@ |
|||||||
|
<Application xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
x:Class="StabilityMatrix.Avalonia.App" |
||||||
|
xmlns:local="using:StabilityMatrix.Avalonia" |
||||||
|
xmlns:styling="clr-namespace:FluentAvalonia.Styling;assembly=FluentAvalonia" |
||||||
|
|
||||||
|
RequestedThemeVariant="Default"> |
||||||
|
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. --> |
||||||
|
|
||||||
|
<Application.DataTemplates> |
||||||
|
<local:ViewLocator/> |
||||||
|
</Application.DataTemplates> |
||||||
|
|
||||||
|
<Application.Resources> |
||||||
|
<ResourceDictionary> |
||||||
|
<ResourceDictionary.MergedDictionaries> |
||||||
|
<ResourceInclude Source="Styles/ThemeColors.axaml"/> |
||||||
|
<ResourceInclude Source="Styles/Card.axaml"/> |
||||||
|
</ResourceDictionary.MergedDictionaries> |
||||||
|
<x:Double x:Key="ContentDialogMaxWidth">700</x:Double> |
||||||
|
</ResourceDictionary> |
||||||
|
</Application.Resources> |
||||||
|
|
||||||
|
<Application.Styles> |
||||||
|
<styling:FluentAvaloniaTheme PreferUserAccentColor="True" UseSystemFontOnWindows="True" /> |
||||||
|
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml"/> |
||||||
|
<StyleInclude Source="avares://AsyncImageLoader.Avalonia/AdvancedImage.axaml" /> |
||||||
|
<StyleInclude Source="Styles/ProgressRing.axaml"/> |
||||||
|
<StyleInclude Source="Styles/ButtonStyles.axaml"/> |
||||||
|
</Application.Styles> |
||||||
|
</Application> |
@ -0,0 +1,521 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Net; |
||||||
|
using System.Net.Http; |
||||||
|
using System.Text.Json; |
||||||
|
using System.Text.Json.Serialization; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.ApplicationLifetimes; |
||||||
|
using Avalonia.Markup.Xaml; |
||||||
|
using Avalonia.Platform.Storage; |
||||||
|
using Avalonia.Styling; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using Microsoft.Extensions.Configuration; |
||||||
|
using Microsoft.Extensions.DependencyInjection; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using NLog; |
||||||
|
using NLog.Config; |
||||||
|
using NLog.Extensions.Logging; |
||||||
|
using NLog.Targets; |
||||||
|
using Octokit; |
||||||
|
using Polly; |
||||||
|
using Polly.Contrib.WaitAndRetry; |
||||||
|
using Polly.Extensions.Http; |
||||||
|
using Polly.Timeout; |
||||||
|
using Refit; |
||||||
|
using Sentry; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.DesignData; |
||||||
|
using StabilityMatrix.Avalonia.Helpers; |
||||||
|
using StabilityMatrix.Avalonia.Models; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Api; |
||||||
|
using StabilityMatrix.Core.Converters.Json; |
||||||
|
using StabilityMatrix.Core.Database; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Helper.Cache; |
||||||
|
using StabilityMatrix.Core.Helper.Factory; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Models.Configs; |
||||||
|
using StabilityMatrix.Core.Models.Packages; |
||||||
|
using StabilityMatrix.Core.Models.Settings; |
||||||
|
using StabilityMatrix.Core.Python; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using StabilityMatrix.Core.Updater; |
||||||
|
using Application = Avalonia.Application; |
||||||
|
using LogLevel = Microsoft.Extensions.Logging.LogLevel; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia; |
||||||
|
|
||||||
|
public sealed class App : Application |
||||||
|
{ |
||||||
|
[NotNull] public static IServiceProvider? Services { get; private set; } |
||||||
|
[NotNull] public static Visual? VisualRoot { get; private set; } |
||||||
|
[NotNull] public static IStorageProvider? StorageProvider { get; private set; } |
||||||
|
[NotNull] public static IConfiguration? Config { get; private set; } |
||||||
|
|
||||||
|
// ReSharper disable once MemberCanBePrivate.Global |
||||||
|
public IClassicDesktopStyleApplicationLifetime? DesktopLifetime => |
||||||
|
ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; |
||||||
|
|
||||||
|
public override void Initialize() |
||||||
|
{ |
||||||
|
AvaloniaXamlLoader.Load(this); |
||||||
|
|
||||||
|
// Set design theme |
||||||
|
if (Design.IsDesignMode) |
||||||
|
{ |
||||||
|
RequestedThemeVariant = ThemeVariant.Dark; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted() |
||||||
|
{ |
||||||
|
base.OnFrameworkInitializationCompleted(); |
||||||
|
|
||||||
|
if (Design.IsDesignMode) |
||||||
|
{ |
||||||
|
DesignData.DesignData.Initialize(); |
||||||
|
Services = DesignData.DesignData.Services; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
ConfigureServiceProvider(); |
||||||
|
} |
||||||
|
|
||||||
|
if (DesktopLifetime is not null) |
||||||
|
{ |
||||||
|
DesktopLifetime.ShutdownMode = ShutdownMode.OnExplicitShutdown; |
||||||
|
|
||||||
|
// First time setup if needed |
||||||
|
var settingsManager = Services.GetRequiredService<ISettingsManager>(); |
||||||
|
if (!settingsManager.IsEulaAccepted()) |
||||||
|
{ |
||||||
|
var setupWindow = Services.GetRequiredService<FirstLaunchSetupWindow>(); |
||||||
|
var setupViewModel = Services.GetRequiredService<FirstLaunchSetupViewModel>(); |
||||||
|
setupWindow.DataContext = setupViewModel; |
||||||
|
setupWindow.ShowAsDialog = true; |
||||||
|
setupWindow.ShowActivated = true; |
||||||
|
setupWindow.ShowAsyncCts = new CancellationTokenSource(); |
||||||
|
|
||||||
|
DesktopLifetime.MainWindow = setupWindow; |
||||||
|
|
||||||
|
setupWindow.ShowAsyncCts.Token.Register(() => |
||||||
|
{ |
||||||
|
if (setupWindow.Result == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
settingsManager.SetEulaAccepted(); |
||||||
|
ShowMainWindow(); |
||||||
|
DesktopLifetime.MainWindow.Show(); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
Shutdown(); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
ShowMainWindow(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void ShowMainWindow() |
||||||
|
{ |
||||||
|
if (DesktopLifetime is null) return; |
||||||
|
|
||||||
|
var mainViewModel = Services.GetRequiredService<MainWindowViewModel>(); |
||||||
|
var notificationService = Services.GetRequiredService<INotificationService>(); |
||||||
|
|
||||||
|
var mainWindow = Services.GetRequiredService<MainWindow>(); |
||||||
|
mainWindow.DataContext = mainViewModel; |
||||||
|
mainWindow.NotificationService = notificationService; |
||||||
|
|
||||||
|
var settingsManager = Services.GetRequiredService<ISettingsManager>(); |
||||||
|
var windowSettings = settingsManager.Settings.WindowSettings; |
||||||
|
if (windowSettings != null) |
||||||
|
{ |
||||||
|
mainWindow.Position = new PixelPoint(windowSettings.X, windowSettings.Y); |
||||||
|
mainWindow.Width = windowSettings.Width; |
||||||
|
mainWindow.Height = windowSettings.Height; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
mainWindow.WindowStartupLocation = WindowStartupLocation.CenterScreen; |
||||||
|
} |
||||||
|
|
||||||
|
mainWindow.Closing += (_, _) => |
||||||
|
{ |
||||||
|
settingsManager.Transaction(s => |
||||||
|
{ |
||||||
|
s.WindowSettings = new WindowSettings( |
||||||
|
mainWindow.Width, mainWindow.Height, |
||||||
|
mainWindow.Position.X, mainWindow.Position.Y); |
||||||
|
}, ignoreMissingLibraryDir: true); |
||||||
|
}; |
||||||
|
mainWindow.Closed += (_, _) => Shutdown(); |
||||||
|
|
||||||
|
VisualRoot = mainWindow; |
||||||
|
StorageProvider = mainWindow.StorageProvider; |
||||||
|
|
||||||
|
DesktopLifetime.MainWindow = mainWindow; |
||||||
|
DesktopLifetime.Exit += OnExit; |
||||||
|
} |
||||||
|
|
||||||
|
private static void ConfigureServiceProvider() |
||||||
|
{ |
||||||
|
var services = ConfigureServices(); |
||||||
|
Services = services.BuildServiceProvider(); |
||||||
|
|
||||||
|
var settingsManager = Services.GetRequiredService<ISettingsManager>(); |
||||||
|
settingsManager.TryFindLibrary(); |
||||||
|
Services.GetRequiredService<ProgressManagerViewModel>().StartEventListener(); |
||||||
|
} |
||||||
|
|
||||||
|
internal static void ConfigurePageViewModels(IServiceCollection services) |
||||||
|
{ |
||||||
|
services.AddSingleton<PackageManagerViewModel>() |
||||||
|
.AddSingleton<SettingsViewModel>() |
||||||
|
.AddSingleton<CheckpointBrowserViewModel>() |
||||||
|
.AddSingleton<CheckpointBrowserCardViewModel>() |
||||||
|
.AddSingleton<CheckpointsPageViewModel>() |
||||||
|
.AddSingleton<LaunchPageViewModel>() |
||||||
|
.AddSingleton<ProgressManagerViewModel>(); |
||||||
|
|
||||||
|
services.AddSingleton<MainWindowViewModel>(provider => |
||||||
|
new MainWindowViewModel(provider.GetRequiredService<ISettingsManager>(), |
||||||
|
provider.GetRequiredService<ServiceManager<ViewModelBase>>()) |
||||||
|
{ |
||||||
|
Pages = |
||||||
|
{ |
||||||
|
provider.GetRequiredService<LaunchPageViewModel>(), |
||||||
|
provider.GetRequiredService<PackageManagerViewModel>(), |
||||||
|
provider.GetRequiredService<CheckpointsPageViewModel>(), |
||||||
|
provider.GetRequiredService<CheckpointBrowserViewModel>(), |
||||||
|
}, |
||||||
|
FooterPages = |
||||||
|
{ |
||||||
|
provider.GetRequiredService<SettingsViewModel>() |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Register disposable view models for shutdown cleanup |
||||||
|
services.AddSingleton<IDisposable>(p |
||||||
|
=> p.GetRequiredService<LaunchPageViewModel>()); |
||||||
|
} |
||||||
|
|
||||||
|
internal static void ConfigureDialogViewModels(IServiceCollection services) |
||||||
|
{ |
||||||
|
// Dialog view models (transient) |
||||||
|
services.AddTransient<InstallerViewModel>(); |
||||||
|
services.AddTransient<OneClickInstallViewModel>(); |
||||||
|
services.AddTransient<SelectModelVersionViewModel>(); |
||||||
|
services.AddTransient<SelectDataDirectoryViewModel>(); |
||||||
|
services.AddTransient<LaunchOptionsViewModel>(); |
||||||
|
services.AddTransient<ExceptionViewModel>(); |
||||||
|
services.AddSingleton<FirstLaunchSetupViewModel>(); |
||||||
|
services.AddSingleton<UpdateViewModel>(); |
||||||
|
|
||||||
|
// Other transients (usually sub view models) |
||||||
|
services.AddTransient<CheckpointFolder>(); |
||||||
|
services.AddTransient<CheckpointFile>(); |
||||||
|
|
||||||
|
// Global progress |
||||||
|
services.AddSingleton<ProgressManagerViewModel>(); |
||||||
|
|
||||||
|
// Controls |
||||||
|
services.AddTransient<RefreshBadgeViewModel>(); |
||||||
|
|
||||||
|
// Dialog factory |
||||||
|
services.AddSingleton<ServiceManager<ViewModelBase>>(provider => |
||||||
|
new ServiceManager<ViewModelBase>() |
||||||
|
.Register(provider.GetRequiredService<InstallerViewModel>) |
||||||
|
.Register(provider.GetRequiredService<OneClickInstallViewModel>) |
||||||
|
.Register(provider.GetRequiredService<SelectModelVersionViewModel>) |
||||||
|
.Register(provider.GetRequiredService<SelectDataDirectoryViewModel>) |
||||||
|
.Register(provider.GetRequiredService<LaunchOptionsViewModel>) |
||||||
|
.Register(provider.GetRequiredService<UpdateViewModel>) |
||||||
|
.Register(provider.GetRequiredService<CheckpointFolder>) |
||||||
|
.Register(provider.GetRequiredService<CheckpointFile>) |
||||||
|
.Register(provider.GetRequiredService<RefreshBadgeViewModel>) |
||||||
|
.Register(provider.GetRequiredService<ExceptionViewModel>) |
||||||
|
.Register(provider.GetRequiredService<ProgressManagerViewModel>) |
||||||
|
.Register(provider.GetRequiredService<FirstLaunchSetupViewModel>)); |
||||||
|
} |
||||||
|
|
||||||
|
internal static void ConfigureViews(IServiceCollection services) |
||||||
|
{ |
||||||
|
// Pages |
||||||
|
services.AddSingleton<CheckpointsPage>(); |
||||||
|
services.AddSingleton<LaunchPageView>(); |
||||||
|
services.AddSingleton<PackageManagerPage>(); |
||||||
|
services.AddSingleton<SettingsPage>(); |
||||||
|
services.AddSingleton<CheckpointBrowserPage>(); |
||||||
|
services.AddSingleton<ProgressManagerPage>(); |
||||||
|
|
||||||
|
// Dialogs |
||||||
|
services.AddTransient<SelectDataDirectoryDialog>(); |
||||||
|
services.AddTransient<LaunchOptionsDialog>(); |
||||||
|
services.AddTransient<UpdateDialog>(); |
||||||
|
services.AddTransient<ExceptionDialog>(); |
||||||
|
|
||||||
|
// Controls |
||||||
|
services.AddTransient<RefreshBadge>(); |
||||||
|
|
||||||
|
// Windows |
||||||
|
services.AddSingleton<MainWindow>(); |
||||||
|
services.AddSingleton<FirstLaunchSetupWindow>(); |
||||||
|
} |
||||||
|
|
||||||
|
internal static void ConfigurePackages(IServiceCollection services) |
||||||
|
{ |
||||||
|
services.AddSingleton<BasePackage, A3WebUI>(); |
||||||
|
services.AddSingleton<BasePackage, VladAutomatic>(); |
||||||
|
services.AddSingleton<BasePackage, ComfyUI>(); |
||||||
|
} |
||||||
|
|
||||||
|
private static IServiceCollection ConfigureServices() |
||||||
|
{ |
||||||
|
var services = new ServiceCollection(); |
||||||
|
|
||||||
|
services.AddMemoryCache(); |
||||||
|
|
||||||
|
ConfigurePageViewModels(services); |
||||||
|
ConfigureDialogViewModels(services); |
||||||
|
ConfigurePackages(services); |
||||||
|
|
||||||
|
// Other services |
||||||
|
services.AddSingleton<ISettingsManager, SettingsManager>(); |
||||||
|
services.AddSingleton<ISharedFolders, SharedFolders>(); |
||||||
|
services.AddSingleton<SharedState>(); |
||||||
|
services.AddSingleton<ModelFinder>(); |
||||||
|
services.AddSingleton<IPackageFactory, PackageFactory>(); |
||||||
|
services.AddSingleton<IDownloadService, DownloadService>(); |
||||||
|
services.AddSingleton<IGithubApiCache, GithubApiCache>(); |
||||||
|
services.AddSingleton<INotificationService, NotificationService>(); |
||||||
|
services.AddSingleton<IPyRunner, PyRunner>(); |
||||||
|
services.AddSingleton<IUpdateHelper, UpdateHelper>(); |
||||||
|
|
||||||
|
Config = new ConfigurationBuilder() |
||||||
|
.SetBasePath(Directory.GetCurrentDirectory()) |
||||||
|
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) |
||||||
|
.Build(); |
||||||
|
|
||||||
|
services.Configure<DebugOptions>(Config.GetSection(nameof(DebugOptions))); |
||||||
|
|
||||||
|
if (Compat.IsWindows) |
||||||
|
{ |
||||||
|
services.AddSingleton<IPrerequisiteHelper, WindowsPrerequisiteHelper>(); |
||||||
|
} |
||||||
|
else if (Compat.IsLinux || Compat.IsMacOS) |
||||||
|
{ |
||||||
|
services.AddSingleton<IPrerequisiteHelper, UnixPrerequisiteHelper>(); |
||||||
|
} |
||||||
|
|
||||||
|
ConfigureViews(services); |
||||||
|
|
||||||
|
if (Design.IsDesignMode) |
||||||
|
{ |
||||||
|
services.AddSingleton<ILiteDbContext, MockLiteDbContext>(); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
services.AddSingleton<ILiteDbContext, LiteDbContext>(); |
||||||
|
services.AddSingleton<IDisposable>(p => p.GetRequiredService<ILiteDbContext>()); |
||||||
|
} |
||||||
|
|
||||||
|
services.AddTransient<IGitHubClient, GitHubClient>(_ => |
||||||
|
{ |
||||||
|
var client = new GitHubClient(new ProductHeaderValue("StabilityMatrix")); |
||||||
|
// var githubApiKey = Config["GithubApiKey"]; |
||||||
|
// if (string.IsNullOrWhiteSpace(githubApiKey)) |
||||||
|
// return client; |
||||||
|
// |
||||||
|
// client.Credentials = new Credentials(githubApiKey); |
||||||
|
return client; |
||||||
|
}); |
||||||
|
|
||||||
|
// Configure Refit and Polly |
||||||
|
var jsonSerializerOptions = new JsonSerializerOptions |
||||||
|
{ |
||||||
|
PropertyNameCaseInsensitive = true, |
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase |
||||||
|
}; |
||||||
|
jsonSerializerOptions.Converters.Add(new ObjectToInferredTypesConverter()); |
||||||
|
jsonSerializerOptions.Converters.Add(new DefaultUnknownEnumConverter<CivitFileType>()); |
||||||
|
jsonSerializerOptions.Converters.Add( |
||||||
|
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); |
||||||
|
jsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; |
||||||
|
|
||||||
|
var defaultRefitSettings = new RefitSettings |
||||||
|
{ |
||||||
|
ContentSerializer = |
||||||
|
new SystemTextJsonContentSerializer(jsonSerializerOptions) |
||||||
|
}; |
||||||
|
|
||||||
|
// HTTP Policies |
||||||
|
var retryStatusCodes = new[] |
||||||
|
{ |
||||||
|
HttpStatusCode.RequestTimeout, // 408 |
||||||
|
HttpStatusCode.InternalServerError, // 500 |
||||||
|
HttpStatusCode.BadGateway, // 502 |
||||||
|
HttpStatusCode.ServiceUnavailable, // 503 |
||||||
|
HttpStatusCode.GatewayTimeout // 504 |
||||||
|
}; |
||||||
|
var delay = Backoff |
||||||
|
.DecorrelatedJitterBackoffV2(medianFirstRetryDelay: TimeSpan.FromMilliseconds(80), |
||||||
|
retryCount: 5); |
||||||
|
var retryPolicy = HttpPolicyExtensions |
||||||
|
.HandleTransientHttpError() |
||||||
|
.Or<TimeoutRejectedException>() |
||||||
|
.OrResult(r => retryStatusCodes.Contains(r.StatusCode)) |
||||||
|
.WaitAndRetryAsync(delay); |
||||||
|
|
||||||
|
// Shorter timeout for local requests |
||||||
|
var localTimeout = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(3)); |
||||||
|
var localDelay = Backoff |
||||||
|
.DecorrelatedJitterBackoffV2(medianFirstRetryDelay: TimeSpan.FromMilliseconds(50), |
||||||
|
retryCount: 3); |
||||||
|
var localRetryPolicy = HttpPolicyExtensions |
||||||
|
.HandleTransientHttpError() |
||||||
|
.Or<TimeoutRejectedException>() |
||||||
|
.OrResult(r => retryStatusCodes.Contains(r.StatusCode)) |
||||||
|
.WaitAndRetryAsync(localDelay, onRetryAsync: (_, _) => |
||||||
|
{ |
||||||
|
Debug.WriteLine("Retrying local request..."); |
||||||
|
return Task.CompletedTask; |
||||||
|
}); |
||||||
|
|
||||||
|
// named client for update |
||||||
|
services.AddHttpClient("UpdateClient") |
||||||
|
.AddPolicyHandler(retryPolicy); |
||||||
|
|
||||||
|
// Add Refit clients |
||||||
|
services.AddRefitClient<ICivitApi>(defaultRefitSettings) |
||||||
|
.ConfigureHttpClient(c => |
||||||
|
{ |
||||||
|
c.BaseAddress = new Uri("https://civitai.com"); |
||||||
|
c.Timeout = TimeSpan.FromSeconds(15); |
||||||
|
}) |
||||||
|
.AddPolicyHandler(retryPolicy); |
||||||
|
|
||||||
|
// Add Refit client managers |
||||||
|
services.AddHttpClient("A3Client") |
||||||
|
.AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy)); |
||||||
|
|
||||||
|
// Add logging |
||||||
|
services.AddLogging(builder => |
||||||
|
{ |
||||||
|
builder.ClearProviders(); |
||||||
|
builder.AddFilter("Microsoft.Extensions.Http", LogLevel.Warning) |
||||||
|
.AddFilter("Microsoft.Extensions.Http.DefaultHttpClientFactory", LogLevel.Warning) |
||||||
|
.AddFilter("Microsoft", LogLevel.Warning) |
||||||
|
.AddFilter("System", LogLevel.Warning); |
||||||
|
builder.SetMinimumLevel(LogLevel.Debug); |
||||||
|
builder.AddNLog(ConfigureLogging()); |
||||||
|
}); |
||||||
|
|
||||||
|
return services; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Requests shutdown of the Current Application. |
||||||
|
/// </summary> |
||||||
|
/// <remarks>This returns asynchronously *without waiting* for Shutdown</remarks> |
||||||
|
/// <param name="exitCode">Exit code for the application.</param> |
||||||
|
/// <exception cref="NullReferenceException">If Application.Current is null</exception> |
||||||
|
public static void Shutdown(int exitCode = 0) |
||||||
|
{ |
||||||
|
if (Current is null) throw new NullReferenceException( |
||||||
|
"Current Application was null when Shutdown called"); |
||||||
|
if (Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) |
||||||
|
{ |
||||||
|
lifetime.Shutdown(exitCode); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs args) |
||||||
|
{ |
||||||
|
Debug.WriteLine("Start OnExit"); |
||||||
|
// Services.GetRequiredService<LaunchViewModel>().OnShutdown(); |
||||||
|
var settingsManager = Services.GetRequiredService<ISettingsManager>(); |
||||||
|
|
||||||
|
// If RemoveFolderLinksOnShutdown is set, delete all package junctions |
||||||
|
if (settingsManager is |
||||||
|
{ |
||||||
|
IsLibraryDirSet: true, |
||||||
|
Settings.RemoveFolderLinksOnShutdown: true |
||||||
|
}) |
||||||
|
{ |
||||||
|
var sharedFolders = Services.GetRequiredService<ISharedFolders>(); |
||||||
|
sharedFolders.RemoveLinksForAllPackages(); |
||||||
|
} |
||||||
|
|
||||||
|
Debug.WriteLine("Start OnExit: Disposing services"); |
||||||
|
// Dispose all services |
||||||
|
foreach (var disposable in Services.GetServices<IDisposable>()) |
||||||
|
{ |
||||||
|
Debug.WriteLine($"Disposing {disposable.GetType().Name}"); |
||||||
|
disposable.Dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
Debug.WriteLine("End OnExit"); |
||||||
|
} |
||||||
|
|
||||||
|
private static LoggingConfiguration ConfigureLogging() |
||||||
|
{ |
||||||
|
var logConfig = new LoggingConfiguration(); |
||||||
|
|
||||||
|
// File target |
||||||
|
logConfig.AddRule(NLog.LogLevel.Debug, NLog.LogLevel.Fatal, |
||||||
|
new FileTarget("logfile") |
||||||
|
{ |
||||||
|
Layout = "${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}", |
||||||
|
ArchiveOldFileOnStartup = true, |
||||||
|
FileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/app.log", |
||||||
|
ArchiveFileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/app.{#}.log", |
||||||
|
ArchiveNumbering = ArchiveNumberingMode.Rolling, |
||||||
|
MaxArchiveFiles = 2 |
||||||
|
}); |
||||||
|
|
||||||
|
// Debugger Target |
||||||
|
logConfig.AddRule(NLog.LogLevel.Trace, NLog.LogLevel.Fatal, |
||||||
|
new DebuggerTarget("debugger") |
||||||
|
{ |
||||||
|
Layout = "${message}" |
||||||
|
}); |
||||||
|
|
||||||
|
// Sentry |
||||||
|
if (SentrySdk.IsEnabled) |
||||||
|
{ |
||||||
|
logConfig.AddSentry(o => |
||||||
|
{ |
||||||
|
o.InitializeSdk = false; |
||||||
|
o.Layout = "${message}"; |
||||||
|
o.ShutdownTimeoutSeconds = 5; |
||||||
|
o.IncludeEventDataOnBreadcrumbs = true; |
||||||
|
o.BreadcrumbLayout = "${logger}: ${message}"; |
||||||
|
// Debug and higher are stored as breadcrumbs (default is Info) |
||||||
|
o.MinimumBreadcrumbLevel = NLog.LogLevel.Debug; |
||||||
|
// Error and higher is sent as event (default is Error) |
||||||
|
o.MinimumEventLevel = NLog.LogLevel.Error; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
LogManager.Configuration = logConfig; |
||||||
|
|
||||||
|
|
||||||
|
return logConfig; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,80 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Runtime.Versioning; |
||||||
|
using Avalonia.Platform; |
||||||
|
using StabilityMatrix.Avalonia.Models; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia; |
||||||
|
|
||||||
|
internal static class Assets |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Fixed image for models with no images. |
||||||
|
/// </summary> |
||||||
|
public static Uri NoImage { get; } = |
||||||
|
new("avares://StabilityMatrix.Avalonia/Assets/noimage.png"); |
||||||
|
|
||||||
|
public static AvaloniaResource LicensesJson => new( |
||||||
|
"avares://StabilityMatrix.Avalonia/Assets/licenses.json"); |
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")] |
||||||
|
[SupportedOSPlatform("linux")] |
||||||
|
public static AvaloniaResource SevenZipExecutable => Compat.Switch( |
||||||
|
(PlatformKind.Windows, |
||||||
|
new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/win-x64/7za.exe")), |
||||||
|
(PlatformKind.Linux | PlatformKind.X64, |
||||||
|
new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/linux-x64/7zzs", (UnixFileMode) 0x777))); |
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")] |
||||||
|
[SupportedOSPlatform("linux")] |
||||||
|
public static AvaloniaResource SevenZipLicense => Compat.Switch( |
||||||
|
(PlatformKind.Windows, new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/win-x64/7za - LICENSE.txt")), |
||||||
|
(PlatformKind.Linux | PlatformKind.X64, new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/linux-x64/7zzs - LICENSE.txt"))); |
||||||
|
|
||||||
|
public static AvaloniaResource PyScriptSiteCustomize => new( |
||||||
|
"avares://StabilityMatrix.Avalonia/Assets/sitecustomize.py"); |
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")] |
||||||
|
public static AvaloniaResource PyScriptGetPip => new( |
||||||
|
"avares://StabilityMatrix.Avalonia/Assets/win-x64/get-pip.pyc"); |
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")] |
||||||
|
public static IEnumerable<(AvaloniaResource resource, string relativePath)> PyModuleVenv => |
||||||
|
FindAssets("win-x64/venv/"); |
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")] |
||||||
|
[SupportedOSPlatform("linux")] |
||||||
|
[SupportedOSPlatform("macos")] |
||||||
|
public static RemoteResource PythonDownloadUrl => Compat.Switch( |
||||||
|
(PlatformKind.Windows | PlatformKind.X64, new RemoteResource( |
||||||
|
new Uri("https://www.python.org/ftp/python/3.10.11/python-3.10.11-embed-amd64.zip"), |
||||||
|
"608619f8619075629c9c69f361352a0da6ed7e62f83a0e19c63e0ea32eb7629d")), |
||||||
|
(PlatformKind.Linux | PlatformKind.X64, new RemoteResource( |
||||||
|
new Uri("https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.10.11+20230507-x86_64-unknown-linux-gnu-install_only.tar.gz"), |
||||||
|
"c5bcaac91bc80bfc29cf510669ecad12d506035ecb3ad85ef213416d54aecd79")), |
||||||
|
(PlatformKind.MacOS | PlatformKind.Arm, new RemoteResource( |
||||||
|
new Uri("https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.10.11+20230507-aarch64-apple-darwin-install_only.tar.gz"), |
||||||
|
"8348bc3c2311f94ec63751fb71bd0108174be1c4def002773cf519ee1506f96f"))); |
||||||
|
|
||||||
|
public static Uri DiscordServerUrl { get; } = |
||||||
|
new("https://discord.com/invite/TUrgfECxHz"); |
||||||
|
|
||||||
|
public static Uri PatreonUrl { get; } = |
||||||
|
new("https://patreon.com/StabilityMatrix"); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Yield AvaloniaResources given a relative directory path within the 'Assets' folder. |
||||||
|
/// </summary> |
||||||
|
public static IEnumerable<(AvaloniaResource resource, string relativePath)> FindAssets(string relativeAssetPath) |
||||||
|
{ |
||||||
|
var baseUri = new Uri("avares://StabilityMatrix.Avalonia/Assets/"); |
||||||
|
var targetUri = new Uri(baseUri, relativeAssetPath); |
||||||
|
var files = AssetLoader.GetAssets(targetUri, null); |
||||||
|
foreach (var file in files) |
||||||
|
{ |
||||||
|
yield return (new AvaloniaResource(file), targetUri.MakeRelativeUri(file).ToString()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 25 KiB |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 172 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -0,0 +1,88 @@ |
|||||||
|
7-Zip |
||||||
|
~~~~~ |
||||||
|
License for use and distribution |
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
||||||
|
|
||||||
|
7-Zip Copyright (C) 1999-2023 Igor Pavlov. |
||||||
|
|
||||||
|
The licenses for 7zz and 7zzs files are: |
||||||
|
|
||||||
|
- The "GNU LGPL" as main license for most of the code |
||||||
|
- The "GNU LGPL" with "unRAR license restriction" for some code |
||||||
|
- The "BSD 3-clause License" for some code |
||||||
|
|
||||||
|
Redistributions in binary form must reproduce related license information from this file. |
||||||
|
|
||||||
|
Note: |
||||||
|
You can use 7-Zip on any computer, including a computer in a commercial |
||||||
|
organization. You don't need to register or pay for 7-Zip. |
||||||
|
|
||||||
|
|
||||||
|
GNU LGPL information |
||||||
|
-------------------- |
||||||
|
|
||||||
|
This library is free software; you can redistribute it and/or |
||||||
|
modify it under the terms of the GNU Lesser General Public |
||||||
|
License as published by the Free Software Foundation; either |
||||||
|
version 2.1 of the License, or (at your option) any later version. |
||||||
|
|
||||||
|
This library is distributed in the hope that it will be useful, |
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
||||||
|
Lesser General Public License for more details. |
||||||
|
|
||||||
|
You can receive a copy of the GNU Lesser General Public License from |
||||||
|
http://www.gnu.org/ |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
BSD 3-clause License |
||||||
|
-------------------- |
||||||
|
|
||||||
|
The "BSD 3-clause License" is used for the code in 7z.dll that implements LZFSE data decompression. |
||||||
|
That code was derived from the code in the "LZFSE compression library" developed by Apple Inc, |
||||||
|
that also uses the "BSD 3-clause License": |
||||||
|
|
||||||
|
---- |
||||||
|
Copyright (c) 2015-2016, Apple Inc. All rights reserved. |
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: |
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. |
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer |
||||||
|
in the documentation and/or other materials provided with the distribution. |
||||||
|
|
||||||
|
3. Neither the name of the copyright holder(s) nor the names of any contributors may be used to endorse or promote products derived |
||||||
|
from this software without specific prior written permission. |
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE |
||||||
|
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES |
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) |
||||||
|
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
||||||
|
---- |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
unRAR license restriction |
||||||
|
------------------------- |
||||||
|
|
||||||
|
The decompression engine for RAR archives was developed using source |
||||||
|
code of unRAR program. |
||||||
|
All copyrights to original unRAR code are owned by Alexander Roshal. |
||||||
|
|
||||||
|
The license for original unRAR code has the following restriction: |
||||||
|
|
||||||
|
The unRAR sources cannot be used to re-create the RAR compression algorithm, |
||||||
|
which is proprietary. Distribution of modified unRAR sources in separate form |
||||||
|
or as a part of other software is permitted, provided that it is clearly |
||||||
|
stated in the documentation and source comments that the code may |
||||||
|
not be used to develop a RAR (WinRAR) compatible archiver. |
||||||
|
|
||||||
|
|
||||||
|
-- |
||||||
|
Igor Pavlov |
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,49 @@ |
|||||||
|
""" |
||||||
|
Startup site customization for Stability Matrix. |
||||||
|
|
||||||
|
Currently this installs an audit hook to notify the parent process when input() is called, |
||||||
|
so we can prompt the user to enter something. |
||||||
|
""" |
||||||
|
|
||||||
|
import sys |
||||||
|
|
||||||
|
# Application Program Command escape sequence |
||||||
|
# This wraps messages sent to the parent process. |
||||||
|
esc_apc = "\x9F" |
||||||
|
esc_prefix = "[SM;" |
||||||
|
esc_st = "\x9C" |
||||||
|
|
||||||
|
|
||||||
|
def send_apc(msg: str): |
||||||
|
"""Send an Application Program Command to the parent process.""" |
||||||
|
sys.stdout.flush() |
||||||
|
sys.stdout.write(esc_apc + esc_prefix + msg + esc_st) |
||||||
|
sys.stdout.flush() |
||||||
|
|
||||||
|
|
||||||
|
def send_apc_input(prompt: str): |
||||||
|
"""Apc message for input() prompt.""" |
||||||
|
send_apc('{"type":"input","data":"' + str(prompt) + '"}') |
||||||
|
|
||||||
|
|
||||||
|
def audit(event: str, *args): |
||||||
|
"""Main audit hook function.""" |
||||||
|
# https://docs.python.org/3/library/functions.html#input |
||||||
|
# input() raises audit event `builtins.input` with args (prompt: str) *before* reading from stdin. |
||||||
|
# `builtins.input/result` raised after reading from stdin. |
||||||
|
|
||||||
|
if event == "builtins.input": |
||||||
|
try: |
||||||
|
prompts = args[0] if args else () |
||||||
|
prompt = "".join(prompts) |
||||||
|
send_apc_input(prompt) |
||||||
|
except Exception: |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
# Reconfigure stdout to UTF-8 |
||||||
|
# noinspection PyUnresolvedReferences |
||||||
|
sys.stdout.reconfigure(encoding="utf-8") |
||||||
|
|
||||||
|
# Install the audit hook |
||||||
|
sys.addaudithook(audit) |
@ -0,0 +1,43 @@ |
|||||||
|
7-Zip Extra 18.01 |
||||||
|
----------------- |
||||||
|
|
||||||
|
7-Zip Extra is package of extra modules of 7-Zip. |
||||||
|
|
||||||
|
7-Zip Copyright (C) 1999-2018 Igor Pavlov. |
||||||
|
|
||||||
|
7-Zip is free software. Read License.txt for more information about license. |
||||||
|
|
||||||
|
Source code of binaries can be found at: |
||||||
|
http://www.7-zip.org/ |
||||||
|
|
||||||
|
|
||||||
|
7-Zip Extra |
||||||
|
~~~~~~~~~~~ |
||||||
|
License for use and distribution |
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
||||||
|
|
||||||
|
Copyright (C) 1999-2018 Igor Pavlov. |
||||||
|
|
||||||
|
7-Zip Extra files are under the GNU LGPL license. |
||||||
|
|
||||||
|
|
||||||
|
Notes: |
||||||
|
You can use 7-Zip Extra on any computer, including a computer in a commercial |
||||||
|
organization. You don't need to register or pay for 7-Zip. |
||||||
|
|
||||||
|
|
||||||
|
GNU LGPL information |
||||||
|
-------------------- |
||||||
|
|
||||||
|
This library is free software; you can redistribute it and/or |
||||||
|
modify it under the terms of the GNU Lesser General Public |
||||||
|
License as published by the Free Software Foundation; either |
||||||
|
version 2.1 of the License, or (at your option) any later version. |
||||||
|
|
||||||
|
This library is distributed in the hope that it will be useful, |
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
||||||
|
Lesser General Public License for more details. |
||||||
|
|
||||||
|
You can receive a copy of the GNU Lesser General Public License from |
||||||
|
http://www.gnu.org/ |
Binary file not shown.
Binary file not shown.
@ -0,0 +1,525 @@ |
|||||||
|
""" |
||||||
|
Virtual environment (venv) package for Python. Based on PEP 405. |
||||||
|
|
||||||
|
Copyright (C) 2011-2014 Vinay Sajip. |
||||||
|
Licensed to the PSF under a contributor agreement. |
||||||
|
""" |
||||||
|
import logging |
||||||
|
import os |
||||||
|
import shutil |
||||||
|
import subprocess |
||||||
|
import sys |
||||||
|
import sysconfig |
||||||
|
import types |
||||||
|
|
||||||
|
|
||||||
|
CORE_VENV_DEPS = ('pip', 'setuptools') |
||||||
|
logger = logging.getLogger(__name__) |
||||||
|
|
||||||
|
|
||||||
|
class EnvBuilder: |
||||||
|
""" |
||||||
|
This class exists to allow virtual environment creation to be |
||||||
|
customized. The constructor parameters determine the builder's |
||||||
|
behaviour when called upon to create a virtual environment. |
||||||
|
|
||||||
|
By default, the builder makes the system (global) site-packages dir |
||||||
|
*un*available to the created environment. |
||||||
|
|
||||||
|
If invoked using the Python -m option, the default is to use copying |
||||||
|
on Windows platforms but symlinks elsewhere. If instantiated some |
||||||
|
other way, the default is to *not* use symlinks. |
||||||
|
|
||||||
|
:param system_site_packages: If True, the system (global) site-packages |
||||||
|
dir is available to created environments. |
||||||
|
:param clear: If True, delete the contents of the environment directory if |
||||||
|
it already exists, before environment creation. |
||||||
|
:param symlinks: If True, attempt to symlink rather than copy files into |
||||||
|
virtual environment. |
||||||
|
:param upgrade: If True, upgrade an existing virtual environment. |
||||||
|
:param with_pip: If True, ensure pip is installed in the virtual |
||||||
|
environment |
||||||
|
:param prompt: Alternative terminal prefix for the environment. |
||||||
|
:param upgrade_deps: Update the base venv modules to the latest on PyPI |
||||||
|
""" |
||||||
|
|
||||||
|
def __init__(self, system_site_packages=False, clear=False, |
||||||
|
symlinks=False, upgrade=False, with_pip=False, prompt=None, |
||||||
|
upgrade_deps=False): |
||||||
|
self.system_site_packages = system_site_packages |
||||||
|
self.clear = clear |
||||||
|
self.symlinks = symlinks |
||||||
|
self.upgrade = upgrade |
||||||
|
self.with_pip = with_pip |
||||||
|
if prompt == '.': # see bpo-38901 |
||||||
|
prompt = os.path.basename(os.getcwd()) |
||||||
|
self.prompt = prompt |
||||||
|
self.upgrade_deps = upgrade_deps |
||||||
|
|
||||||
|
def create(self, env_dir): |
||||||
|
""" |
||||||
|
Create a virtual environment in a directory. |
||||||
|
|
||||||
|
:param env_dir: The target directory to create an environment in. |
||||||
|
|
||||||
|
""" |
||||||
|
env_dir = os.path.abspath(env_dir) |
||||||
|
context = self.ensure_directories(env_dir) |
||||||
|
# See issue 24875. We need system_site_packages to be False |
||||||
|
# until after pip is installed. |
||||||
|
true_system_site_packages = self.system_site_packages |
||||||
|
self.system_site_packages = False |
||||||
|
self.create_configuration(context) |
||||||
|
self.setup_python(context) |
||||||
|
if self.with_pip: |
||||||
|
self._setup_pip(context) |
||||||
|
if not self.upgrade: |
||||||
|
self.setup_scripts(context) |
||||||
|
self.post_setup(context) |
||||||
|
if true_system_site_packages: |
||||||
|
# We had set it to False before, now |
||||||
|
# restore it and rewrite the configuration |
||||||
|
self.system_site_packages = True |
||||||
|
self.create_configuration(context) |
||||||
|
if self.upgrade_deps: |
||||||
|
self.upgrade_dependencies(context) |
||||||
|
|
||||||
|
def clear_directory(self, path): |
||||||
|
for fn in os.listdir(path): |
||||||
|
fn = os.path.join(path, fn) |
||||||
|
if os.path.islink(fn) or os.path.isfile(fn): |
||||||
|
os.remove(fn) |
||||||
|
elif os.path.isdir(fn): |
||||||
|
shutil.rmtree(fn) |
||||||
|
|
||||||
|
def ensure_directories(self, env_dir): |
||||||
|
""" |
||||||
|
Create the directories for the environment. |
||||||
|
|
||||||
|
Returns a context object which holds paths in the environment, |
||||||
|
for use by subsequent logic. |
||||||
|
""" |
||||||
|
|
||||||
|
def create_if_needed(d): |
||||||
|
if not os.path.exists(d): |
||||||
|
os.makedirs(d) |
||||||
|
elif os.path.islink(d) or os.path.isfile(d): |
||||||
|
raise ValueError('Unable to create directory %r' % d) |
||||||
|
|
||||||
|
if os.path.exists(env_dir) and self.clear: |
||||||
|
self.clear_directory(env_dir) |
||||||
|
context = types.SimpleNamespace() |
||||||
|
context.env_dir = env_dir |
||||||
|
context.env_name = os.path.split(env_dir)[1] |
||||||
|
prompt = self.prompt if self.prompt is not None else context.env_name |
||||||
|
context.prompt = '(%s) ' % prompt |
||||||
|
create_if_needed(env_dir) |
||||||
|
executable = sys._base_executable |
||||||
|
if not executable: # see gh-96861 |
||||||
|
raise ValueError('Unable to determine path to the running ' |
||||||
|
'Python interpreter. Provide an explicit path or ' |
||||||
|
'check that your PATH environment variable is ' |
||||||
|
'correctly set.') |
||||||
|
dirname, exename = os.path.split(os.path.abspath(executable)) |
||||||
|
context.executable = executable |
||||||
|
context.python_dir = dirname |
||||||
|
context.python_exe = exename |
||||||
|
if sys.platform == 'win32': |
||||||
|
binname = 'Scripts' |
||||||
|
incpath = 'Include' |
||||||
|
libpath = os.path.join(env_dir, 'Lib', 'site-packages') |
||||||
|
else: |
||||||
|
binname = 'bin' |
||||||
|
incpath = 'include' |
||||||
|
libpath = os.path.join(env_dir, 'lib', |
||||||
|
'python%d.%d' % sys.version_info[:2], |
||||||
|
'site-packages') |
||||||
|
context.inc_path = path = os.path.join(env_dir, incpath) |
||||||
|
create_if_needed(path) |
||||||
|
create_if_needed(libpath) |
||||||
|
# Issue 21197: create lib64 as a symlink to lib on 64-bit non-OS X POSIX |
||||||
|
if ((sys.maxsize > 2**32) and (os.name == 'posix') and |
||||||
|
(sys.platform != 'darwin')): |
||||||
|
link_path = os.path.join(env_dir, 'lib64') |
||||||
|
if not os.path.exists(link_path): # Issue #21643 |
||||||
|
os.symlink('lib', link_path) |
||||||
|
context.bin_path = binpath = os.path.join(env_dir, binname) |
||||||
|
context.bin_name = binname |
||||||
|
context.env_exe = os.path.join(binpath, exename) |
||||||
|
create_if_needed(binpath) |
||||||
|
# Assign and update the command to use when launching the newly created |
||||||
|
# environment, in case it isn't simply the executable script (e.g. bpo-45337) |
||||||
|
context.env_exec_cmd = context.env_exe |
||||||
|
if sys.platform == 'win32': |
||||||
|
# bpo-45337: Fix up env_exec_cmd to account for file system redirections. |
||||||
|
# Some redirects only apply to CreateFile and not CreateProcess |
||||||
|
real_env_exe = os.path.realpath(context.env_exe) |
||||||
|
if os.path.normcase(real_env_exe) != os.path.normcase(context.env_exe): |
||||||
|
logger.warning('Actual environment location may have moved due to ' |
||||||
|
'redirects, links or junctions.\n' |
||||||
|
' Requested location: "%s"\n' |
||||||
|
' Actual location: "%s"', |
||||||
|
context.env_exe, real_env_exe) |
||||||
|
context.env_exec_cmd = real_env_exe |
||||||
|
return context |
||||||
|
|
||||||
|
def create_configuration(self, context): |
||||||
|
""" |
||||||
|
Create a configuration file indicating where the environment's Python |
||||||
|
was copied from, and whether the system site-packages should be made |
||||||
|
available in the environment. |
||||||
|
|
||||||
|
:param context: The information for the environment creation request |
||||||
|
being processed. |
||||||
|
""" |
||||||
|
context.cfg_path = path = os.path.join(context.env_dir, 'pyvenv.cfg') |
||||||
|
with open(path, 'w', encoding='utf-8') as f: |
||||||
|
f.write('home = %s\n' % context.python_dir) |
||||||
|
if self.system_site_packages: |
||||||
|
incl = 'true' |
||||||
|
else: |
||||||
|
incl = 'false' |
||||||
|
f.write('include-system-site-packages = %s\n' % incl) |
||||||
|
f.write('version = %d.%d.%d\n' % sys.version_info[:3]) |
||||||
|
if self.prompt is not None: |
||||||
|
f.write(f'prompt = {self.prompt!r}\n') |
||||||
|
|
||||||
|
if os.name != 'nt': |
||||||
|
def symlink_or_copy(self, src, dst, relative_symlinks_ok=False): |
||||||
|
""" |
||||||
|
Try symlinking a file, and if that fails, fall back to copying. |
||||||
|
""" |
||||||
|
force_copy = not self.symlinks |
||||||
|
if not force_copy: |
||||||
|
try: |
||||||
|
if not os.path.islink(dst): # can't link to itself! |
||||||
|
if relative_symlinks_ok: |
||||||
|
assert os.path.dirname(src) == os.path.dirname(dst) |
||||||
|
os.symlink(os.path.basename(src), dst) |
||||||
|
else: |
||||||
|
os.symlink(src, dst) |
||||||
|
except Exception: # may need to use a more specific exception |
||||||
|
logger.warning('Unable to symlink %r to %r', src, dst) |
||||||
|
force_copy = True |
||||||
|
if force_copy: |
||||||
|
shutil.copyfile(src, dst) |
||||||
|
else: |
||||||
|
def symlink_or_copy(self, src, dst, relative_symlinks_ok=False): |
||||||
|
""" |
||||||
|
Try symlinking a file, and if that fails, fall back to copying. |
||||||
|
""" |
||||||
|
bad_src = os.path.lexists(src) and not os.path.exists(src) |
||||||
|
if self.symlinks and not bad_src and not os.path.islink(dst): |
||||||
|
try: |
||||||
|
if relative_symlinks_ok: |
||||||
|
assert os.path.dirname(src) == os.path.dirname(dst) |
||||||
|
os.symlink(os.path.basename(src), dst) |
||||||
|
else: |
||||||
|
os.symlink(src, dst) |
||||||
|
return |
||||||
|
except Exception: # may need to use a more specific exception |
||||||
|
logger.warning('Unable to symlink %r to %r', src, dst) |
||||||
|
|
||||||
|
# On Windows, we rewrite symlinks to our base python.exe into |
||||||
|
# copies of venvlauncher.exe |
||||||
|
basename, ext = os.path.splitext(os.path.basename(src)) |
||||||
|
srcfn = os.path.join(os.path.dirname(__file__), |
||||||
|
"scripts", |
||||||
|
"nt", |
||||||
|
basename + ext) |
||||||
|
# Builds or venv's from builds need to remap source file |
||||||
|
# locations, as we do not put them into Lib/venv/scripts |
||||||
|
if sysconfig.is_python_build(True) or not os.path.isfile(srcfn): |
||||||
|
if basename.endswith('_d'): |
||||||
|
ext = '_d' + ext |
||||||
|
basename = basename[:-2] |
||||||
|
if basename == 'python': |
||||||
|
basename = 'venvlauncher' |
||||||
|
elif basename == 'pythonw': |
||||||
|
basename = 'venvwlauncher' |
||||||
|
src = os.path.join(os.path.dirname(src), basename + ext) |
||||||
|
else: |
||||||
|
src = srcfn |
||||||
|
if not os.path.exists(src): |
||||||
|
if not bad_src: |
||||||
|
logger.warning('Unable to copy %r', src) |
||||||
|
return |
||||||
|
|
||||||
|
shutil.copyfile(src, dst) |
||||||
|
|
||||||
|
def setup_python(self, context): |
||||||
|
""" |
||||||
|
Set up a Python executable in the environment. |
||||||
|
|
||||||
|
:param context: The information for the environment creation request |
||||||
|
being processed. |
||||||
|
""" |
||||||
|
binpath = context.bin_path |
||||||
|
path = context.env_exe |
||||||
|
copier = self.symlink_or_copy |
||||||
|
dirname = context.python_dir |
||||||
|
if os.name != 'nt': |
||||||
|
copier(context.executable, path) |
||||||
|
if not os.path.islink(path): |
||||||
|
os.chmod(path, 0o755) |
||||||
|
for suffix in ('python', 'python3', f'python3.{sys.version_info[1]}'): |
||||||
|
path = os.path.join(binpath, suffix) |
||||||
|
if not os.path.exists(path): |
||||||
|
# Issue 18807: make copies if |
||||||
|
# symlinks are not wanted |
||||||
|
copier(context.env_exe, path, relative_symlinks_ok=True) |
||||||
|
if not os.path.islink(path): |
||||||
|
os.chmod(path, 0o755) |
||||||
|
else: |
||||||
|
if self.symlinks: |
||||||
|
# For symlinking, we need a complete copy of the root directory |
||||||
|
# If symlinks fail, you'll get unnecessary copies of files, but |
||||||
|
# we assume that if you've opted into symlinks on Windows then |
||||||
|
# you know what you're doing. |
||||||
|
suffixes = [ |
||||||
|
f for f in os.listdir(dirname) if |
||||||
|
os.path.normcase(os.path.splitext(f)[1]) in ('.exe', '.dll') |
||||||
|
] |
||||||
|
if sysconfig.is_python_build(True): |
||||||
|
suffixes = [ |
||||||
|
f for f in suffixes if |
||||||
|
os.path.normcase(f).startswith(('python', 'vcruntime')) |
||||||
|
] |
||||||
|
else: |
||||||
|
suffixes = {'python.exe', 'python_d.exe', 'pythonw.exe', 'pythonw_d.exe'} |
||||||
|
base_exe = os.path.basename(context.env_exe) |
||||||
|
suffixes.add(base_exe) |
||||||
|
|
||||||
|
for suffix in suffixes: |
||||||
|
src = os.path.join(dirname, suffix) |
||||||
|
if os.path.lexists(src): |
||||||
|
copier(src, os.path.join(binpath, suffix)) |
||||||
|
|
||||||
|
if sysconfig.is_python_build(True): |
||||||
|
# copy init.tcl |
||||||
|
for root, dirs, files in os.walk(context.python_dir): |
||||||
|
if 'init.tcl' in files: |
||||||
|
tcldir = os.path.basename(root) |
||||||
|
tcldir = os.path.join(context.env_dir, 'Lib', tcldir) |
||||||
|
if not os.path.exists(tcldir): |
||||||
|
os.makedirs(tcldir) |
||||||
|
src = os.path.join(root, 'init.tcl') |
||||||
|
dst = os.path.join(tcldir, 'init.tcl') |
||||||
|
shutil.copyfile(src, dst) |
||||||
|
break |
||||||
|
|
||||||
|
def _call_new_python(self, context, *py_args, **kwargs): |
||||||
|
"""Executes the newly created Python using safe-ish options""" |
||||||
|
# gh-98251: We do not want to just use '-I' because that masks |
||||||
|
# legitimate user preferences (such as not writing bytecode). All we |
||||||
|
# really need is to ensure that the path variables do not overrule |
||||||
|
# normal venv handling. |
||||||
|
args = [context.env_exec_cmd, *py_args] |
||||||
|
kwargs['env'] = env = os.environ.copy() |
||||||
|
env['VIRTUAL_ENV'] = context.env_dir |
||||||
|
env.pop('PYTHONHOME', None) |
||||||
|
env.pop('PYTHONPATH', None) |
||||||
|
kwargs['cwd'] = context.env_dir |
||||||
|
kwargs['executable'] = context.env_exec_cmd |
||||||
|
subprocess.check_output(args, **kwargs) |
||||||
|
|
||||||
|
def _setup_pip(self, context): |
||||||
|
"""Installs or upgrades pip in a virtual environment""" |
||||||
|
self._call_new_python(context, '-m', 'ensurepip', '--upgrade', |
||||||
|
'--default-pip', stderr=subprocess.STDOUT) |
||||||
|
|
||||||
|
def setup_scripts(self, context): |
||||||
|
""" |
||||||
|
Set up scripts into the created environment from a directory. |
||||||
|
|
||||||
|
This method installs the default scripts into the environment |
||||||
|
being created. You can prevent the default installation by overriding |
||||||
|
this method if you really need to, or if you need to specify |
||||||
|
a different location for the scripts to install. By default, the |
||||||
|
'scripts' directory in the venv package is used as the source of |
||||||
|
scripts to install. |
||||||
|
""" |
||||||
|
path = os.path.abspath(os.path.dirname(__file__)) |
||||||
|
path = os.path.join(path, 'scripts') |
||||||
|
self.install_scripts(context, path) |
||||||
|
|
||||||
|
def post_setup(self, context): |
||||||
|
""" |
||||||
|
Hook for post-setup modification of the venv. Subclasses may install |
||||||
|
additional packages or scripts here, add activation shell scripts, etc. |
||||||
|
|
||||||
|
:param context: The information for the environment creation request |
||||||
|
being processed. |
||||||
|
""" |
||||||
|
pass |
||||||
|
|
||||||
|
def replace_variables(self, text, context): |
||||||
|
""" |
||||||
|
Replace variable placeholders in script text with context-specific |
||||||
|
variables. |
||||||
|
|
||||||
|
Return the text passed in , but with variables replaced. |
||||||
|
|
||||||
|
:param text: The text in which to replace placeholder variables. |
||||||
|
:param context: The information for the environment creation request |
||||||
|
being processed. |
||||||
|
""" |
||||||
|
text = text.replace('__VENV_DIR__', context.env_dir) |
||||||
|
text = text.replace('__VENV_NAME__', context.env_name) |
||||||
|
text = text.replace('__VENV_PROMPT__', context.prompt) |
||||||
|
text = text.replace('__VENV_BIN_NAME__', context.bin_name) |
||||||
|
text = text.replace('__VENV_PYTHON__', context.env_exe) |
||||||
|
return text |
||||||
|
|
||||||
|
def install_scripts(self, context, path): |
||||||
|
""" |
||||||
|
Install scripts into the created environment from a directory. |
||||||
|
|
||||||
|
:param context: The information for the environment creation request |
||||||
|
being processed. |
||||||
|
:param path: Absolute pathname of a directory containing script. |
||||||
|
Scripts in the 'common' subdirectory of this directory, |
||||||
|
and those in the directory named for the platform |
||||||
|
being run on, are installed in the created environment. |
||||||
|
Placeholder variables are replaced with environment- |
||||||
|
specific values. |
||||||
|
""" |
||||||
|
binpath = context.bin_path |
||||||
|
plen = len(path) |
||||||
|
for root, dirs, files in os.walk(path): |
||||||
|
if root == path: # at top-level, remove irrelevant dirs |
||||||
|
for d in dirs[:]: |
||||||
|
if d not in ('common', os.name): |
||||||
|
dirs.remove(d) |
||||||
|
continue # ignore files in top level |
||||||
|
for f in files: |
||||||
|
if (os.name == 'nt' and f.startswith('python') |
||||||
|
and f.endswith(('.exe', '.pdb'))): |
||||||
|
continue |
||||||
|
srcfile = os.path.join(root, f) |
||||||
|
suffix = root[plen:].split(os.sep)[2:] |
||||||
|
if not suffix: |
||||||
|
dstdir = binpath |
||||||
|
else: |
||||||
|
dstdir = os.path.join(binpath, *suffix) |
||||||
|
if not os.path.exists(dstdir): |
||||||
|
os.makedirs(dstdir) |
||||||
|
dstfile = os.path.join(dstdir, f) |
||||||
|
with open(srcfile, 'rb') as f: |
||||||
|
data = f.read() |
||||||
|
if not srcfile.endswith(('.exe', '.pdb')): |
||||||
|
try: |
||||||
|
data = data.decode('utf-8') |
||||||
|
data = self.replace_variables(data, context) |
||||||
|
data = data.encode('utf-8') |
||||||
|
except UnicodeError as e: |
||||||
|
data = None |
||||||
|
logger.warning('unable to copy script %r, ' |
||||||
|
'may be binary: %s', srcfile, e) |
||||||
|
if data is not None: |
||||||
|
with open(dstfile, 'wb') as f: |
||||||
|
f.write(data) |
||||||
|
shutil.copymode(srcfile, dstfile) |
||||||
|
|
||||||
|
def upgrade_dependencies(self, context): |
||||||
|
logger.debug( |
||||||
|
f'Upgrading {CORE_VENV_DEPS} packages in {context.bin_path}' |
||||||
|
) |
||||||
|
self._call_new_python(context, '-m', 'pip', 'install', '--upgrade', |
||||||
|
*CORE_VENV_DEPS) |
||||||
|
|
||||||
|
|
||||||
|
def create(env_dir, system_site_packages=False, clear=False, |
||||||
|
symlinks=False, with_pip=False, prompt=None, upgrade_deps=False): |
||||||
|
"""Create a virtual environment in a directory.""" |
||||||
|
builder = EnvBuilder(system_site_packages=system_site_packages, |
||||||
|
clear=clear, symlinks=symlinks, with_pip=with_pip, |
||||||
|
prompt=prompt, upgrade_deps=upgrade_deps) |
||||||
|
builder.create(env_dir) |
||||||
|
|
||||||
|
def main(args=None): |
||||||
|
compatible = True |
||||||
|
if sys.version_info < (3, 3): |
||||||
|
compatible = False |
||||||
|
elif not hasattr(sys, 'base_prefix'): |
||||||
|
compatible = False |
||||||
|
if not compatible: |
||||||
|
raise ValueError('This script is only for use with Python >= 3.3') |
||||||
|
else: |
||||||
|
import argparse |
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(prog=__name__, |
||||||
|
description='Creates virtual Python ' |
||||||
|
'environments in one or ' |
||||||
|
'more target ' |
||||||
|
'directories.', |
||||||
|
epilog='Once an environment has been ' |
||||||
|
'created, you may wish to ' |
||||||
|
'activate it, e.g. by ' |
||||||
|
'sourcing an activate script ' |
||||||
|
'in its bin directory.') |
||||||
|
parser.add_argument('dirs', metavar='ENV_DIR', nargs='+', |
||||||
|
help='A directory to create the environment in.') |
||||||
|
parser.add_argument('--system-site-packages', default=False, |
||||||
|
action='store_true', dest='system_site', |
||||||
|
help='Give the virtual environment access to the ' |
||||||
|
'system site-packages dir.') |
||||||
|
if os.name == 'nt': |
||||||
|
use_symlinks = False |
||||||
|
else: |
||||||
|
use_symlinks = True |
||||||
|
group = parser.add_mutually_exclusive_group() |
||||||
|
group.add_argument('--symlinks', default=use_symlinks, |
||||||
|
action='store_true', dest='symlinks', |
||||||
|
help='Try to use symlinks rather than copies, ' |
||||||
|
'when symlinks are not the default for ' |
||||||
|
'the platform.') |
||||||
|
group.add_argument('--copies', default=not use_symlinks, |
||||||
|
action='store_false', dest='symlinks', |
||||||
|
help='Try to use copies rather than symlinks, ' |
||||||
|
'even when symlinks are the default for ' |
||||||
|
'the platform.') |
||||||
|
parser.add_argument('--clear', default=False, action='store_true', |
||||||
|
dest='clear', help='Delete the contents of the ' |
||||||
|
'environment directory if it ' |
||||||
|
'already exists, before ' |
||||||
|
'environment creation.') |
||||||
|
parser.add_argument('--upgrade', default=False, action='store_true', |
||||||
|
dest='upgrade', help='Upgrade the environment ' |
||||||
|
'directory to use this version ' |
||||||
|
'of Python, assuming Python ' |
||||||
|
'has been upgraded in-place.') |
||||||
|
parser.add_argument('--without-pip', dest='with_pip', |
||||||
|
default=True, action='store_false', |
||||||
|
help='Skips installing or upgrading pip in the ' |
||||||
|
'virtual environment (pip is bootstrapped ' |
||||||
|
'by default)') |
||||||
|
parser.add_argument('--prompt', |
||||||
|
help='Provides an alternative prompt prefix for ' |
||||||
|
'this environment.') |
||||||
|
parser.add_argument('--upgrade-deps', default=False, action='store_true', |
||||||
|
dest='upgrade_deps', |
||||||
|
help='Upgrade core dependencies: {} to the latest ' |
||||||
|
'version in PyPI'.format( |
||||||
|
' '.join(CORE_VENV_DEPS))) |
||||||
|
options = parser.parse_args(args) |
||||||
|
if options.upgrade and options.clear: |
||||||
|
raise ValueError('you cannot supply --upgrade and --clear together.') |
||||||
|
builder = EnvBuilder(system_site_packages=options.system_site, |
||||||
|
clear=options.clear, |
||||||
|
symlinks=options.symlinks, |
||||||
|
upgrade=options.upgrade, |
||||||
|
with_pip=options.with_pip, |
||||||
|
prompt=options.prompt, |
||||||
|
upgrade_deps=options.upgrade_deps) |
||||||
|
for d in options.dirs: |
||||||
|
builder.create(d) |
||||||
|
|
||||||
|
if __name__ == '__main__': |
||||||
|
rc = 1 |
||||||
|
try: |
||||||
|
main() |
||||||
|
rc = 0 |
||||||
|
except Exception as e: |
||||||
|
print('Error: %s' % e, file=sys.stderr) |
||||||
|
sys.exit(rc) |
@ -0,0 +1,10 @@ |
|||||||
|
import sys |
||||||
|
from . import main |
||||||
|
|
||||||
|
rc = 1 |
||||||
|
try: |
||||||
|
main() |
||||||
|
rc = 0 |
||||||
|
except Exception as e: |
||||||
|
print('Error: %s' % e, file=sys.stderr) |
||||||
|
sys.exit(rc) |
@ -0,0 +1,247 @@ |
|||||||
|
<# |
||||||
|
.Synopsis |
||||||
|
Activate a Python virtual environment for the current PowerShell session. |
||||||
|
|
||||||
|
.Description |
||||||
|
Pushes the python executable for a virtual environment to the front of the |
||||||
|
$Env:PATH environment variable and sets the prompt to signify that you are |
||||||
|
in a Python virtual environment. Makes use of the command line switches as |
||||||
|
well as the `pyvenv.cfg` file values present in the virtual environment. |
||||||
|
|
||||||
|
.Parameter VenvDir |
||||||
|
Path to the directory that contains the virtual environment to activate. The |
||||||
|
default value for this is the parent of the directory that the Activate.ps1 |
||||||
|
script is located within. |
||||||
|
|
||||||
|
.Parameter Prompt |
||||||
|
The prompt prefix to display when this virtual environment is activated. By |
||||||
|
default, this prompt is the name of the virtual environment folder (VenvDir) |
||||||
|
surrounded by parentheses and followed by a single space (ie. '(.venv) '). |
||||||
|
|
||||||
|
.Example |
||||||
|
Activate.ps1 |
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script. |
||||||
|
|
||||||
|
.Example |
||||||
|
Activate.ps1 -Verbose |
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script, |
||||||
|
and shows extra information about the activation as it executes. |
||||||
|
|
||||||
|
.Example |
||||||
|
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv |
||||||
|
Activates the Python virtual environment located in the specified location. |
||||||
|
|
||||||
|
.Example |
||||||
|
Activate.ps1 -Prompt "MyPython" |
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script, |
||||||
|
and prefixes the current prompt with the specified string (surrounded in |
||||||
|
parentheses) while the virtual environment is active. |
||||||
|
|
||||||
|
.Notes |
||||||
|
On Windows, it may be required to enable this Activate.ps1 script by setting the |
||||||
|
execution policy for the user. You can do this by issuing the following PowerShell |
||||||
|
command: |
||||||
|
|
||||||
|
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser |
||||||
|
|
||||||
|
For more information on Execution Policies: |
||||||
|
https://go.microsoft.com/fwlink/?LinkID=135170 |
||||||
|
|
||||||
|
#> |
||||||
|
Param( |
||||||
|
[Parameter(Mandatory = $false)] |
||||||
|
[String] |
||||||
|
$VenvDir, |
||||||
|
[Parameter(Mandatory = $false)] |
||||||
|
[String] |
||||||
|
$Prompt |
||||||
|
) |
||||||
|
|
||||||
|
<# Function declarations --------------------------------------------------- #> |
||||||
|
|
||||||
|
<# |
||||||
|
.Synopsis |
||||||
|
Remove all shell session elements added by the Activate script, including the |
||||||
|
addition of the virtual environment's Python executable from the beginning of |
||||||
|
the PATH variable. |
||||||
|
|
||||||
|
.Parameter NonDestructive |
||||||
|
If present, do not remove this function from the global namespace for the |
||||||
|
session. |
||||||
|
|
||||||
|
#> |
||||||
|
function global:deactivate ([switch]$NonDestructive) { |
||||||
|
# Revert to original values |
||||||
|
|
||||||
|
# The prior prompt: |
||||||
|
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { |
||||||
|
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt |
||||||
|
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT |
||||||
|
} |
||||||
|
|
||||||
|
# The prior PYTHONHOME: |
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { |
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME |
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME |
||||||
|
} |
||||||
|
|
||||||
|
# The prior PATH: |
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { |
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH |
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PATH |
||||||
|
} |
||||||
|
|
||||||
|
# Just remove the VIRTUAL_ENV altogether: |
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV) { |
||||||
|
Remove-Item -Path env:VIRTUAL_ENV |
||||||
|
} |
||||||
|
|
||||||
|
# Just remove VIRTUAL_ENV_PROMPT altogether. |
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { |
||||||
|
Remove-Item -Path env:VIRTUAL_ENV_PROMPT |
||||||
|
} |
||||||
|
|
||||||
|
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: |
||||||
|
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { |
||||||
|
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force |
||||||
|
} |
||||||
|
|
||||||
|
# Leave deactivate function in the global namespace if requested: |
||||||
|
if (-not $NonDestructive) { |
||||||
|
Remove-Item -Path function:deactivate |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
<# |
||||||
|
.Description |
||||||
|
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the |
||||||
|
given folder, and returns them in a map. |
||||||
|
|
||||||
|
For each line in the pyvenv.cfg file, if that line can be parsed into exactly |
||||||
|
two strings separated by `=` (with any amount of whitespace surrounding the =) |
||||||
|
then it is considered a `key = value` line. The left hand string is the key, |
||||||
|
the right hand is the value. |
||||||
|
|
||||||
|
If the value starts with a `'` or a `"` then the first and last character is |
||||||
|
stripped from the value before being captured. |
||||||
|
|
||||||
|
.Parameter ConfigDir |
||||||
|
Path to the directory that contains the `pyvenv.cfg` file. |
||||||
|
#> |
||||||
|
function Get-PyVenvConfig( |
||||||
|
[String] |
||||||
|
$ConfigDir |
||||||
|
) { |
||||||
|
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" |
||||||
|
|
||||||
|
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). |
||||||
|
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue |
||||||
|
|
||||||
|
# An empty map will be returned if no config file is found. |
||||||
|
$pyvenvConfig = @{ } |
||||||
|
|
||||||
|
if ($pyvenvConfigPath) { |
||||||
|
|
||||||
|
Write-Verbose "File exists, parse `key = value` lines" |
||||||
|
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath |
||||||
|
|
||||||
|
$pyvenvConfigContent | ForEach-Object { |
||||||
|
$keyval = $PSItem -split "\s*=\s*", 2 |
||||||
|
if ($keyval[0] -and $keyval[1]) { |
||||||
|
$val = $keyval[1] |
||||||
|
|
||||||
|
# Remove extraneous quotations around a string value. |
||||||
|
if ("'""".Contains($val.Substring(0, 1))) { |
||||||
|
$val = $val.Substring(1, $val.Length - 2) |
||||||
|
} |
||||||
|
|
||||||
|
$pyvenvConfig[$keyval[0]] = $val |
||||||
|
Write-Verbose "Adding Key: '$($keyval[0])'='$val'" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return $pyvenvConfig |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
<# Begin Activate script --------------------------------------------------- #> |
||||||
|
|
||||||
|
# Determine the containing directory of this script |
||||||
|
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition |
||||||
|
$VenvExecDir = Get-Item -Path $VenvExecPath |
||||||
|
|
||||||
|
Write-Verbose "Activation script is located in path: '$VenvExecPath'" |
||||||
|
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" |
||||||
|
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" |
||||||
|
|
||||||
|
# Set values required in priority: CmdLine, ConfigFile, Default |
||||||
|
# First, get the location of the virtual environment, it might not be |
||||||
|
# VenvExecDir if specified on the command line. |
||||||
|
if ($VenvDir) { |
||||||
|
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" |
||||||
|
} |
||||||
|
else { |
||||||
|
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." |
||||||
|
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") |
||||||
|
Write-Verbose "VenvDir=$VenvDir" |
||||||
|
} |
||||||
|
|
||||||
|
# Next, read the `pyvenv.cfg` file to determine any required value such |
||||||
|
# as `prompt`. |
||||||
|
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir |
||||||
|
|
||||||
|
# Next, set the prompt from the command line, or the config file, or |
||||||
|
# just use the name of the virtual environment folder. |
||||||
|
if ($Prompt) { |
||||||
|
Write-Verbose "Prompt specified as argument, using '$Prompt'" |
||||||
|
} |
||||||
|
else { |
||||||
|
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" |
||||||
|
if ($pyvenvCfg -and $pyvenvCfg['prompt']) { |
||||||
|
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" |
||||||
|
$Prompt = $pyvenvCfg['prompt']; |
||||||
|
} |
||||||
|
else { |
||||||
|
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" |
||||||
|
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" |
||||||
|
$Prompt = Split-Path -Path $venvDir -Leaf |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Write-Verbose "Prompt = '$Prompt'" |
||||||
|
Write-Verbose "VenvDir='$VenvDir'" |
||||||
|
|
||||||
|
# Deactivate any currently active virtual environment, but leave the |
||||||
|
# deactivate function in place. |
||||||
|
deactivate -nondestructive |
||||||
|
|
||||||
|
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine |
||||||
|
# that there is an activated venv. |
||||||
|
$env:VIRTUAL_ENV = $VenvDir |
||||||
|
|
||||||
|
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { |
||||||
|
|
||||||
|
Write-Verbose "Setting prompt to '$Prompt'" |
||||||
|
|
||||||
|
# Set the prompt to include the env name |
||||||
|
# Make sure _OLD_VIRTUAL_PROMPT is global |
||||||
|
function global:_OLD_VIRTUAL_PROMPT { "" } |
||||||
|
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT |
||||||
|
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt |
||||||
|
|
||||||
|
function global:prompt { |
||||||
|
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " |
||||||
|
_OLD_VIRTUAL_PROMPT |
||||||
|
} |
||||||
|
$env:VIRTUAL_ENV_PROMPT = $Prompt |
||||||
|
} |
||||||
|
|
||||||
|
# Clear PYTHONHOME |
||||||
|
if (Test-Path -Path Env:PYTHONHOME) { |
||||||
|
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME |
||||||
|
Remove-Item -Path Env:PYTHONHOME |
||||||
|
} |
||||||
|
|
||||||
|
# Add the venv to the PATH |
||||||
|
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH |
||||||
|
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" |
@ -0,0 +1,69 @@ |
|||||||
|
# This file must be used with "source bin/activate" *from bash* |
||||||
|
# you cannot run it directly |
||||||
|
|
||||||
|
deactivate () { |
||||||
|
# reset old environment variables |
||||||
|
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then |
||||||
|
PATH="${_OLD_VIRTUAL_PATH:-}" |
||||||
|
export PATH |
||||||
|
unset _OLD_VIRTUAL_PATH |
||||||
|
fi |
||||||
|
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then |
||||||
|
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" |
||||||
|
export PYTHONHOME |
||||||
|
unset _OLD_VIRTUAL_PYTHONHOME |
||||||
|
fi |
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must |
||||||
|
# be called to get it to forget past commands. Without forgetting |
||||||
|
# past commands the $PATH changes we made may not be respected |
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then |
||||||
|
hash -r 2> /dev/null |
||||||
|
fi |
||||||
|
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then |
||||||
|
PS1="${_OLD_VIRTUAL_PS1:-}" |
||||||
|
export PS1 |
||||||
|
unset _OLD_VIRTUAL_PS1 |
||||||
|
fi |
||||||
|
|
||||||
|
unset VIRTUAL_ENV |
||||||
|
unset VIRTUAL_ENV_PROMPT |
||||||
|
if [ ! "${1:-}" = "nondestructive" ] ; then |
||||||
|
# Self destruct! |
||||||
|
unset -f deactivate |
||||||
|
fi |
||||||
|
} |
||||||
|
|
||||||
|
# unset irrelevant variables |
||||||
|
deactivate nondestructive |
||||||
|
|
||||||
|
VIRTUAL_ENV="__VENV_DIR__" |
||||||
|
export VIRTUAL_ENV |
||||||
|
|
||||||
|
_OLD_VIRTUAL_PATH="$PATH" |
||||||
|
PATH="$VIRTUAL_ENV/__VENV_BIN_NAME__:$PATH" |
||||||
|
export PATH |
||||||
|
|
||||||
|
# unset PYTHONHOME if set |
||||||
|
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) |
||||||
|
# could use `if (set -u; : $PYTHONHOME) ;` in bash |
||||||
|
if [ -n "${PYTHONHOME:-}" ] ; then |
||||||
|
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" |
||||||
|
unset PYTHONHOME |
||||||
|
fi |
||||||
|
|
||||||
|
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then |
||||||
|
_OLD_VIRTUAL_PS1="${PS1:-}" |
||||||
|
PS1="__VENV_PROMPT__${PS1:-}" |
||||||
|
export PS1 |
||||||
|
VIRTUAL_ENV_PROMPT="__VENV_PROMPT__" |
||||||
|
export VIRTUAL_ENV_PROMPT |
||||||
|
fi |
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must |
||||||
|
# be called to get it to forget past commands. Without forgetting |
||||||
|
# past commands the $PATH changes we made may not be respected |
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then |
||||||
|
hash -r 2> /dev/null |
||||||
|
fi |
@ -0,0 +1,34 @@ |
|||||||
|
@echo off |
||||||
|
|
||||||
|
rem This file is UTF-8 encoded, so we need to update the current code page while executing it |
||||||
|
for /f "tokens=2 delims=:." %%a in ('"%SystemRoot%\System32\chcp.com"') do ( |
||||||
|
set _OLD_CODEPAGE=%%a |
||||||
|
) |
||||||
|
if defined _OLD_CODEPAGE ( |
||||||
|
"%SystemRoot%\System32\chcp.com" 65001 > nul |
||||||
|
) |
||||||
|
|
||||||
|
set VIRTUAL_ENV=__VENV_DIR__ |
||||||
|
|
||||||
|
if not defined PROMPT set PROMPT=$P$G |
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PROMPT set PROMPT=%_OLD_VIRTUAL_PROMPT% |
||||||
|
if defined _OLD_VIRTUAL_PYTHONHOME set PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME% |
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PROMPT=%PROMPT% |
||||||
|
set PROMPT=__VENV_PROMPT__%PROMPT% |
||||||
|
|
||||||
|
if defined PYTHONHOME set _OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME% |
||||||
|
set PYTHONHOME= |
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PATH set PATH=%_OLD_VIRTUAL_PATH% |
||||||
|
if not defined _OLD_VIRTUAL_PATH set _OLD_VIRTUAL_PATH=%PATH% |
||||||
|
|
||||||
|
set PATH=%VIRTUAL_ENV%\__VENV_BIN_NAME__;%PATH% |
||||||
|
set VIRTUAL_ENV_PROMPT=__VENV_PROMPT__ |
||||||
|
|
||||||
|
:END |
||||||
|
if defined _OLD_CODEPAGE ( |
||||||
|
"%SystemRoot%\System32\chcp.com" %_OLD_CODEPAGE% > nul |
||||||
|
set _OLD_CODEPAGE= |
||||||
|
) |
@ -0,0 +1,22 @@ |
|||||||
|
@echo off |
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PROMPT ( |
||||||
|
set "PROMPT=%_OLD_VIRTUAL_PROMPT%" |
||||||
|
) |
||||||
|
set _OLD_VIRTUAL_PROMPT= |
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PYTHONHOME ( |
||||||
|
set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%" |
||||||
|
set _OLD_VIRTUAL_PYTHONHOME= |
||||||
|
) |
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PATH ( |
||||||
|
set "PATH=%_OLD_VIRTUAL_PATH%" |
||||||
|
) |
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PATH= |
||||||
|
|
||||||
|
set VIRTUAL_ENV= |
||||||
|
set VIRTUAL_ENV_PROMPT= |
||||||
|
|
||||||
|
:END |
@ -0,0 +1,26 @@ |
|||||||
|
# This file must be used with "source bin/activate.csh" *from csh*. |
||||||
|
# You cannot run it directly. |
||||||
|
# Created by Davide Di Blasi <davidedb@gmail.com>. |
||||||
|
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com> |
||||||
|
|
||||||
|
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' |
||||||
|
|
||||||
|
# Unset irrelevant variables. |
||||||
|
deactivate nondestructive |
||||||
|
|
||||||
|
setenv VIRTUAL_ENV "__VENV_DIR__" |
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PATH="$PATH" |
||||||
|
setenv PATH "$VIRTUAL_ENV/__VENV_BIN_NAME__:$PATH" |
||||||
|
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PROMPT="$prompt" |
||||||
|
|
||||||
|
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then |
||||||
|
set prompt = "__VENV_PROMPT__$prompt" |
||||||
|
setenv VIRTUAL_ENV_PROMPT "__VENV_PROMPT__" |
||||||
|
endif |
||||||
|
|
||||||
|
alias pydoc python -m pydoc |
||||||
|
|
||||||
|
rehash |
@ -0,0 +1,69 @@ |
|||||||
|
# This file must be used with "source <venv>/bin/activate.fish" *from fish* |
||||||
|
# (https://fishshell.com/); you cannot run it directly. |
||||||
|
|
||||||
|
function deactivate -d "Exit virtual environment and return to normal shell environment" |
||||||
|
# reset old environment variables |
||||||
|
if test -n "$_OLD_VIRTUAL_PATH" |
||||||
|
set -gx PATH $_OLD_VIRTUAL_PATH |
||||||
|
set -e _OLD_VIRTUAL_PATH |
||||||
|
end |
||||||
|
if test -n "$_OLD_VIRTUAL_PYTHONHOME" |
||||||
|
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME |
||||||
|
set -e _OLD_VIRTUAL_PYTHONHOME |
||||||
|
end |
||||||
|
|
||||||
|
if test -n "$_OLD_FISH_PROMPT_OVERRIDE" |
||||||
|
set -e _OLD_FISH_PROMPT_OVERRIDE |
||||||
|
# prevents error when using nested fish instances (Issue #93858) |
||||||
|
if functions -q _old_fish_prompt |
||||||
|
functions -e fish_prompt |
||||||
|
functions -c _old_fish_prompt fish_prompt |
||||||
|
functions -e _old_fish_prompt |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
set -e VIRTUAL_ENV |
||||||
|
set -e VIRTUAL_ENV_PROMPT |
||||||
|
if test "$argv[1]" != "nondestructive" |
||||||
|
# Self-destruct! |
||||||
|
functions -e deactivate |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
# Unset irrelevant variables. |
||||||
|
deactivate nondestructive |
||||||
|
|
||||||
|
set -gx VIRTUAL_ENV "__VENV_DIR__" |
||||||
|
|
||||||
|
set -gx _OLD_VIRTUAL_PATH $PATH |
||||||
|
set -gx PATH "$VIRTUAL_ENV/__VENV_BIN_NAME__" $PATH |
||||||
|
|
||||||
|
# Unset PYTHONHOME if set. |
||||||
|
if set -q PYTHONHOME |
||||||
|
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME |
||||||
|
set -e PYTHONHOME |
||||||
|
end |
||||||
|
|
||||||
|
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" |
||||||
|
# fish uses a function instead of an env var to generate the prompt. |
||||||
|
|
||||||
|
# Save the current fish_prompt function as the function _old_fish_prompt. |
||||||
|
functions -c fish_prompt _old_fish_prompt |
||||||
|
|
||||||
|
# With the original prompt function renamed, we can override with our own. |
||||||
|
function fish_prompt |
||||||
|
# Save the return status of the last command. |
||||||
|
set -l old_status $status |
||||||
|
|
||||||
|
# Output the venv prompt; color taken from the blue of the Python logo. |
||||||
|
printf "%s%s%s" (set_color 4B8BBE) "__VENV_PROMPT__" (set_color normal) |
||||||
|
|
||||||
|
# Restore the return status of the previous command. |
||||||
|
echo "exit $old_status" | . |
||||||
|
# Output the original/"old" prompt. |
||||||
|
_old_fish_prompt |
||||||
|
end |
||||||
|
|
||||||
|
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" |
||||||
|
set -gx VIRTUAL_ENV_PROMPT "__VENV_PROMPT__" |
||||||
|
end |
@ -0,0 +1,89 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Interactivity; |
||||||
|
using Avalonia.Threading; |
||||||
|
using FluentAvalonia.UI.Windowing; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")] |
||||||
|
public class AppWindowBase : AppWindow |
||||||
|
{ |
||||||
|
public CancellationTokenSource? ShowAsyncCts { get; set; } |
||||||
|
|
||||||
|
protected AppWindowBase() |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public void ShowWithCts(CancellationTokenSource cts) |
||||||
|
{ |
||||||
|
ShowAsyncCts?.Cancel(); |
||||||
|
ShowAsyncCts = cts; |
||||||
|
Show(); |
||||||
|
} |
||||||
|
|
||||||
|
public Task ShowAsync() |
||||||
|
{ |
||||||
|
ShowAsyncCts?.Cancel(); |
||||||
|
ShowAsyncCts = new CancellationTokenSource(); |
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource<bool>(); |
||||||
|
ShowAsyncCts.Token.Register(s => |
||||||
|
{ |
||||||
|
((TaskCompletionSource<bool>) s!).SetResult(true); |
||||||
|
}, tcs); |
||||||
|
|
||||||
|
Show(); |
||||||
|
|
||||||
|
return tcs.Task; |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e) |
||||||
|
{ |
||||||
|
base.OnClosed(e); |
||||||
|
|
||||||
|
if (ShowAsyncCts is not null) |
||||||
|
{ |
||||||
|
ShowAsyncCts.Cancel(); |
||||||
|
ShowAsyncCts = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnLoaded(RoutedEventArgs e) |
||||||
|
{ |
||||||
|
base.OnLoaded(e); |
||||||
|
|
||||||
|
if (DataContext is ViewModelBase viewModel) |
||||||
|
{ |
||||||
|
// Run synchronous load then async load |
||||||
|
viewModel.OnLoaded(); |
||||||
|
|
||||||
|
// Can't block here so we'll run as async on UI thread |
||||||
|
Dispatcher.UIThread.InvokeAsync(async () => |
||||||
|
{ |
||||||
|
await viewModel.OnLoadedAsync(); |
||||||
|
}).SafeFireAndForget(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnUnloaded(RoutedEventArgs e) |
||||||
|
{ |
||||||
|
base.OnUnloaded(e); |
||||||
|
|
||||||
|
if (DataContext is not ViewModelBase viewModel) |
||||||
|
return; |
||||||
|
|
||||||
|
// Run synchronous load then async unload |
||||||
|
viewModel.OnUnloaded(); |
||||||
|
|
||||||
|
// Can't block here so we'll run as async on UI thread |
||||||
|
Dispatcher.UIThread.InvokeAsync(async () => |
||||||
|
{ |
||||||
|
await viewModel.OnUnloadedAsync(); |
||||||
|
}).SafeFireAndForget(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,133 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.Reflection; |
||||||
|
using AsyncImageLoader; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Layout; |
||||||
|
using Avalonia.Media; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
[SuppressMessage("ReSharper", "UnusedMember.Local")] |
||||||
|
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); |
||||||
|
|
||||||
|
[NotNull] |
||||||
|
private static readonly FieldInfo? CornerRadiusClipField = typeof(AdvancedImage).GetField( |
||||||
|
"_cornerRadiusClip",BindingFlags.Instance | BindingFlags.NonPublic); |
||||||
|
|
||||||
|
private bool IsCornerRadiusUsed |
||||||
|
{ |
||||||
|
get => IsCornerRadiusUsedField.GetValue(this) as bool? ?? false; |
||||||
|
set => IsCornerRadiusUsedField.SetValue(this, value); |
||||||
|
} |
||||||
|
|
||||||
|
private RoundedRect CornerRadiusClip |
||||||
|
{ |
||||||
|
get => (RoundedRect) CornerRadiusClipField.GetValue(this)!; |
||||||
|
set => CornerRadiusClipField.SetValue(this, value); |
||||||
|
} |
||||||
|
|
||||||
|
static BetterAdvancedImage() |
||||||
|
{ |
||||||
|
if (IsCornerRadiusUsedField is null) |
||||||
|
{ |
||||||
|
throw new NullReferenceException("IsCornerRadiusUsedField was not resolved"); |
||||||
|
} |
||||||
|
if (CornerRadiusClipField is null) |
||||||
|
{ |
||||||
|
throw new NullReferenceException("CornerRadiusClipField was not resolved"); |
||||||
|
} |
||||||
|
} |
||||||
|
#endregion |
||||||
|
|
||||||
|
protected override Type StyleKeyOverride { get; } = typeof(AdvancedImage); |
||||||
|
|
||||||
|
public BetterAdvancedImage(Uri? baseUri) : base(baseUri) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public BetterAdvancedImage(IServiceProvider serviceProvider) : base(serviceProvider) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException"></exception> |
||||||
|
/// <inheritdoc /> |
||||||
|
public override void Render(DrawingContext context) |
||||||
|
{ |
||||||
|
var source = CurrentImage; |
||||||
|
|
||||||
|
if (source != null && Bounds is { Width: > 0, Height: > 0 }) |
||||||
|
{ |
||||||
|
var viewPort = new Rect(Bounds.Size); |
||||||
|
var sourceSize = source.Size; |
||||||
|
|
||||||
|
var scale = Stretch.CalculateScaling(Bounds.Size, sourceSize, StretchDirection); |
||||||
|
var scaledSize = sourceSize * scale; |
||||||
|
|
||||||
|
// Calculate starting points for dest |
||||||
|
var destX = HorizontalContentAlignment switch |
||||||
|
{ |
||||||
|
HorizontalAlignment.Left => 0, |
||||||
|
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, |
||||||
|
_ => throw new ArgumentException(nameof(HorizontalContentAlignment)) |
||||||
|
}; |
||||||
|
var destY = VerticalContentAlignment switch |
||||||
|
{ |
||||||
|
VerticalAlignment.Top => 0, |
||||||
|
VerticalAlignment.Center => (int) (viewPort.Height - scaledSize.Height) / 2, |
||||||
|
VerticalAlignment.Bottom => (int) (viewPort.Height - scaledSize.Height), |
||||||
|
VerticalAlignment.Stretch => 0, // Stretch is default, use top |
||||||
|
_ => throw new ArgumentException(nameof(VerticalContentAlignment)) |
||||||
|
}; |
||||||
|
|
||||||
|
var destRect = viewPort |
||||||
|
.CenterRect(new Rect(scaledSize)) |
||||||
|
.WithX(destX) |
||||||
|
.WithY(destY) |
||||||
|
.Intersect(viewPort); |
||||||
|
var destRectUnscaledSize = destRect.Size / scale; |
||||||
|
|
||||||
|
// Calculate starting points for source |
||||||
|
var sourceX = HorizontalContentAlignment switch |
||||||
|
{ |
||||||
|
HorizontalAlignment.Left => 0, |
||||||
|
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, |
||||||
|
_ => throw new ArgumentException(nameof(HorizontalContentAlignment)) |
||||||
|
}; |
||||||
|
var sourceY = VerticalContentAlignment switch |
||||||
|
{ |
||||||
|
VerticalAlignment.Top => 0, |
||||||
|
VerticalAlignment.Center => (int) (sourceSize - destRectUnscaledSize).Height / 2, |
||||||
|
VerticalAlignment.Bottom => (int) (sourceSize - destRectUnscaledSize).Height, |
||||||
|
VerticalAlignment.Stretch => 0, // Stretch is default, use top |
||||||
|
_ => throw new ArgumentException(nameof(VerticalContentAlignment)) |
||||||
|
}; |
||||||
|
|
||||||
|
var sourceRect = new Rect(sourceSize) |
||||||
|
.CenterRect(new Rect(destRect.Size / scale)) |
||||||
|
.WithX(sourceX) |
||||||
|
.WithY(sourceY); |
||||||
|
|
||||||
|
DrawingContext.PushedState? pushedState = |
||||||
|
IsCornerRadiusUsed ? context.PushClip(CornerRadiusClip) : null; |
||||||
|
context.DrawImage(source, sourceRect, destRect); |
||||||
|
pushedState?.Dispose(); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
base.Render(context); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,254 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.Reflection; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using Avalonia.Interactivity; |
||||||
|
using Avalonia.Threading; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] |
||||||
|
public class BetterContentDialog : ContentDialog |
||||||
|
{ |
||||||
|
#region Reflection Shenanigans for setting content dialog result |
||||||
|
[NotNull] |
||||||
|
protected static readonly FieldInfo? ResultField = typeof(ContentDialog).GetField( |
||||||
|
"_result",BindingFlags.Instance | BindingFlags.NonPublic); |
||||||
|
|
||||||
|
protected ContentDialogResult Result |
||||||
|
{ |
||||||
|
get => (ContentDialogResult) ResultField.GetValue(this)!; |
||||||
|
set => ResultField.SetValue(this, value); |
||||||
|
} |
||||||
|
|
||||||
|
[NotNull] |
||||||
|
protected static readonly MethodInfo? HideCoreMethod = typeof(ContentDialog).GetMethod( |
||||||
|
"HideCore", BindingFlags.Instance | BindingFlags.NonPublic); |
||||||
|
|
||||||
|
protected void HideCore() |
||||||
|
{ |
||||||
|
HideCoreMethod.Invoke(this, null); |
||||||
|
} |
||||||
|
|
||||||
|
// Also get button properties to hide on command execution change |
||||||
|
[NotNull] |
||||||
|
protected static readonly FieldInfo? PrimaryButtonField = typeof(ContentDialog).GetField( |
||||||
|
"_primaryButton", BindingFlags.Instance | BindingFlags.NonPublic); |
||||||
|
|
||||||
|
protected Button? PrimaryButton |
||||||
|
{ |
||||||
|
get => (Button?) PrimaryButtonField.GetValue(this)!; |
||||||
|
set => PrimaryButtonField.SetValue(this, value); |
||||||
|
} |
||||||
|
|
||||||
|
[NotNull] |
||||||
|
protected static readonly FieldInfo? SecondaryButtonField = typeof(ContentDialog).GetField( |
||||||
|
"_secondaryButton", BindingFlags.Instance | BindingFlags.NonPublic); |
||||||
|
|
||||||
|
protected Button? SecondaryButton |
||||||
|
{ |
||||||
|
get => (Button?) SecondaryButtonField.GetValue(this)!; |
||||||
|
set => SecondaryButtonField.SetValue(this, value); |
||||||
|
} |
||||||
|
|
||||||
|
[NotNull] |
||||||
|
protected static readonly FieldInfo? CloseButtonField = typeof(ContentDialog).GetField( |
||||||
|
"_closeButton", BindingFlags.Instance | BindingFlags.NonPublic); |
||||||
|
|
||||||
|
protected Button? CloseButton |
||||||
|
{ |
||||||
|
get => (Button?) CloseButtonField.GetValue(this)!; |
||||||
|
set => CloseButtonField.SetValue(this, value); |
||||||
|
} |
||||||
|
|
||||||
|
static BetterContentDialog() |
||||||
|
{ |
||||||
|
if (ResultField is null) |
||||||
|
{ |
||||||
|
throw new NullReferenceException("ResultField was not resolved"); |
||||||
|
} |
||||||
|
if (HideCoreMethod is null) |
||||||
|
{ |
||||||
|
throw new NullReferenceException("HideCoreMethod was not resolved"); |
||||||
|
} |
||||||
|
if (PrimaryButtonField is null || SecondaryButtonField is null || CloseButtonField is null) |
||||||
|
{ |
||||||
|
throw new NullReferenceException("Button fields were not resolved"); |
||||||
|
} |
||||||
|
} |
||||||
|
#endregion |
||||||
|
|
||||||
|
protected override Type StyleKeyOverride { get; } = typeof(ContentDialog); |
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> IsFooterVisibleProperty = AvaloniaProperty.Register<BetterContentDialog, bool>( |
||||||
|
"IsFooterVisible", true); |
||||||
|
|
||||||
|
public bool IsFooterVisible |
||||||
|
{ |
||||||
|
get => GetValue(IsFooterVisibleProperty); |
||||||
|
set => SetValue(IsFooterVisibleProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<ScrollBarVisibility> ContentVerticalScrollBarVisibilityProperty |
||||||
|
= AvaloniaProperty.Register<BetterContentDialog, ScrollBarVisibility>( |
||||||
|
"ContentScrollBarVisibility", ScrollBarVisibility.Auto); |
||||||
|
|
||||||
|
public ScrollBarVisibility ContentVerticalScrollBarVisibility |
||||||
|
{ |
||||||
|
get => GetValue(ContentVerticalScrollBarVisibilityProperty); |
||||||
|
set => SetValue(ContentVerticalScrollBarVisibilityProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<double> MaxDialogWidthProperty = AvaloniaProperty.Register<BetterContentDialog, double>( |
||||||
|
"MaxDialogWidth"); |
||||||
|
|
||||||
|
public double MaxDialogWidth |
||||||
|
{ |
||||||
|
get => GetValue(MaxDialogWidthProperty); |
||||||
|
set => SetValue(MaxDialogWidthProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<double> MaxDialogHeightProperty = AvaloniaProperty.Register<BetterContentDialog, double>( |
||||||
|
"MaxDialogHeight"); |
||||||
|
|
||||||
|
public double MaxDialogHeight |
||||||
|
{ |
||||||
|
get => GetValue(MaxDialogHeightProperty); |
||||||
|
set => SetValue(MaxDialogHeightProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
public BetterContentDialog() |
||||||
|
{ |
||||||
|
AddHandler(LoadedEvent, OnLoaded); |
||||||
|
} |
||||||
|
|
||||||
|
private void TryBindButtons() |
||||||
|
{ |
||||||
|
if ((Content as Control)?.DataContext is ContentDialogViewModelBase viewModel) |
||||||
|
{ |
||||||
|
viewModel.PrimaryButtonClick += OnDialogButtonClick; |
||||||
|
viewModel.SecondaryButtonClick += OnDialogButtonClick; |
||||||
|
viewModel.CloseButtonClick += OnDialogButtonClick; |
||||||
|
} |
||||||
|
|
||||||
|
// If commands provided, bind OnCanExecuteChanged to hide buttons |
||||||
|
// otherwise link visibility to IsEnabled |
||||||
|
if (PrimaryButton is not null) |
||||||
|
{ |
||||||
|
if (PrimaryButtonCommand is not null) |
||||||
|
{ |
||||||
|
PrimaryButtonCommand.CanExecuteChanged += (_, _) => |
||||||
|
PrimaryButton.IsEnabled = PrimaryButtonCommand.CanExecute(null); |
||||||
|
// Also set initial state |
||||||
|
PrimaryButton.IsEnabled = PrimaryButtonCommand.CanExecute(null); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
PrimaryButton.IsVisible = IsPrimaryButtonEnabled && !string.IsNullOrEmpty(PrimaryButtonText); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (SecondaryButton is not null) |
||||||
|
{ |
||||||
|
if (SecondaryButtonCommand is not null) |
||||||
|
{ |
||||||
|
SecondaryButtonCommand.CanExecuteChanged += (_, _) => |
||||||
|
SecondaryButton.IsEnabled = SecondaryButtonCommand.CanExecute(null); |
||||||
|
// Also set initial state |
||||||
|
SecondaryButton.IsEnabled = SecondaryButtonCommand.CanExecute(null); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
SecondaryButton.IsVisible = IsSecondaryButtonEnabled && !string.IsNullOrEmpty(SecondaryButtonText); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (CloseButton is not null) |
||||||
|
{ |
||||||
|
if (CloseButtonCommand is not null) |
||||||
|
{ |
||||||
|
CloseButtonCommand.CanExecuteChanged += (_, _) => |
||||||
|
CloseButton.IsEnabled = CloseButtonCommand.CanExecute(null); |
||||||
|
// Also set initial state |
||||||
|
CloseButton.IsEnabled = CloseButtonCommand.CanExecute(null); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected void OnDialogButtonClick(object? sender, ContentDialogResult e) |
||||||
|
{ |
||||||
|
Result = e; |
||||||
|
HideCore(); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnDataContextChanged(EventArgs e) |
||||||
|
{ |
||||||
|
base.OnDataContextChanged(e); |
||||||
|
|
||||||
|
TryBindButtons(); |
||||||
|
} |
||||||
|
|
||||||
|
private void OnLoaded(object? sender, RoutedEventArgs? e) |
||||||
|
{ |
||||||
|
TryBindButtons(); |
||||||
|
|
||||||
|
// Find the named grid |
||||||
|
// https://github.com/amwx/FluentAvalonia/blob/master/src/FluentAvalonia/Styling/ |
||||||
|
// ControlThemes/FAControls/ContentDialogStyles.axaml#L96 |
||||||
|
var border = VisualChildren[0] as Border; |
||||||
|
var panel = border?.Child as Panel; |
||||||
|
var faBorder = panel?.Children[0] as FABorder; |
||||||
|
|
||||||
|
// Set dialog maximums |
||||||
|
if (MaxDialogWidth > 0) |
||||||
|
{ |
||||||
|
faBorder!.MaxWidth = MaxDialogWidth; |
||||||
|
} |
||||||
|
if (MaxDialogHeight > 0) |
||||||
|
{ |
||||||
|
faBorder!.MaxHeight = MaxDialogHeight; |
||||||
|
} |
||||||
|
|
||||||
|
var border2 = faBorder?.Child as Border; |
||||||
|
// Named Grid 'DialogSpace' |
||||||
|
if (border2?.Child is not Grid dialogSpaceGrid) throw new InvalidOperationException("Could not find DialogSpace grid"); |
||||||
|
|
||||||
|
var scrollViewer = dialogSpaceGrid.Children[0] as ScrollViewer; |
||||||
|
var actualBorder = dialogSpaceGrid.Children[1] as Border; |
||||||
|
|
||||||
|
// Get the parent border, which is what we want to hide |
||||||
|
if (scrollViewer is null || actualBorder is null) |
||||||
|
{ |
||||||
|
throw new InvalidOperationException("Could not find parent border"); |
||||||
|
} |
||||||
|
|
||||||
|
var subBorder = scrollViewer.Content as Border; |
||||||
|
var subGrid = subBorder?.Child as Grid; |
||||||
|
if (subGrid is null) throw new InvalidOperationException("Could not find sub grid"); |
||||||
|
var contentControlTitle = subGrid.Children[0] as ContentControl; |
||||||
|
// Hide title if empty |
||||||
|
if (Title is null or string {Length: 0}) |
||||||
|
{ |
||||||
|
contentControlTitle!.IsVisible = false; |
||||||
|
} |
||||||
|
|
||||||
|
// Set footer and scrollbar visibility states |
||||||
|
actualBorder.IsVisible = IsFooterVisible; |
||||||
|
scrollViewer.VerticalScrollBarVisibility = ContentVerticalScrollBarVisibility; |
||||||
|
|
||||||
|
// Also call the vm's OnLoad |
||||||
|
if (Content is Control {DataContext: ViewModelBase viewModel}) |
||||||
|
{ |
||||||
|
viewModel.OnLoaded(); |
||||||
|
Dispatcher.UIThread.InvokeAsync( |
||||||
|
async () => await viewModel.OnLoadedAsync()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,40 @@ |
|||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using Avalonia.VisualTree; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
public class BetterFlyout : Flyout |
||||||
|
{ |
||||||
|
public static readonly StyledProperty<ScrollBarVisibility> VerticalScrollBarVisibilityProperty = AvaloniaProperty.Register<BetterFlyout, ScrollBarVisibility>( |
||||||
|
"VerticalScrollBarVisibility"); |
||||||
|
|
||||||
|
public ScrollBarVisibility VerticalScrollBarVisibility |
||||||
|
{ |
||||||
|
get => GetValue(VerticalScrollBarVisibilityProperty); |
||||||
|
set => SetValue(VerticalScrollBarVisibilityProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<ScrollBarVisibility> HorizontalScrollBarVisibilityProperty = AvaloniaProperty.Register<BetterFlyout, ScrollBarVisibility>( |
||||||
|
"HorizontalScrollBarVisibility"); |
||||||
|
|
||||||
|
public ScrollBarVisibility HorizontalScrollBarVisibility |
||||||
|
{ |
||||||
|
get => GetValue(HorizontalScrollBarVisibilityProperty); |
||||||
|
set => SetValue(HorizontalScrollBarVisibilityProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnOpened() |
||||||
|
{ |
||||||
|
base.OnOpened(); |
||||||
|
var presenter = Popup.Child; |
||||||
|
if (presenter.FindDescendantOfType<ScrollViewer>() is { } scrollViewer) |
||||||
|
{ |
||||||
|
scrollViewer.VerticalScrollBarVisibility = VerticalScrollBarVisibility; |
||||||
|
scrollViewer.HorizontalScrollBarVisibility = HorizontalScrollBarVisibility; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,140 @@ |
|||||||
|
using Avalonia; |
||||||
|
using Avalonia.Automation; |
||||||
|
using Avalonia.Automation.Peers; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Automation.Peers; |
||||||
|
using Avalonia.Media; |
||||||
|
using Avalonia.Metadata; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
public class BetterImage : Control |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="Source"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly StyledProperty<IImage?> SourceProperty = |
||||||
|
AvaloniaProperty.Register<BetterImage, IImage?>(nameof(Source)); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="Stretch"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly StyledProperty<Stretch> StretchProperty = |
||||||
|
AvaloniaProperty.Register<BetterImage, Stretch>(nameof(Stretch), Stretch.Uniform); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="StretchDirection"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly StyledProperty<StretchDirection> StretchDirectionProperty = |
||||||
|
AvaloniaProperty.Register<BetterImage, StretchDirection>( |
||||||
|
nameof(StretchDirection), |
||||||
|
StretchDirection.Both); |
||||||
|
|
||||||
|
static BetterImage() |
||||||
|
{ |
||||||
|
AffectsRender<BetterImage>(SourceProperty, StretchProperty, StretchDirectionProperty); |
||||||
|
AffectsMeasure<BetterImage>(SourceProperty, StretchProperty, StretchDirectionProperty); |
||||||
|
AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue<BetterImage>( |
||||||
|
AutomationControlType.Image); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets the image that will be displayed. |
||||||
|
/// </summary> |
||||||
|
[Content] |
||||||
|
public IImage? Source |
||||||
|
{ |
||||||
|
get { return GetValue(SourceProperty); } |
||||||
|
set { SetValue(SourceProperty, value); } |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets a value controlling how the image will be stretched. |
||||||
|
/// </summary> |
||||||
|
public Stretch Stretch |
||||||
|
{ |
||||||
|
get { return GetValue(StretchProperty); } |
||||||
|
set { SetValue(StretchProperty, value); } |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets a value controlling in what direction the image will be stretched. |
||||||
|
/// </summary> |
||||||
|
public StretchDirection StretchDirection |
||||||
|
{ |
||||||
|
get { return GetValue(StretchDirectionProperty); } |
||||||
|
set { SetValue(StretchDirectionProperty, value); } |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override bool BypassFlowDirectionPolicies => true; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Renders the control. |
||||||
|
/// </summary> |
||||||
|
/// <param name="context">The drawing context.</param> |
||||||
|
public sealed override void Render(DrawingContext context) |
||||||
|
{ |
||||||
|
var source = Source; |
||||||
|
|
||||||
|
if (source != null && Bounds.Width > 0 && Bounds.Height > 0) |
||||||
|
{ |
||||||
|
Rect viewPort = new Rect(Bounds.Size); |
||||||
|
Size sourceSize = source.Size; |
||||||
|
|
||||||
|
Vector scale = Stretch.CalculateScaling(Bounds.Size, sourceSize, StretchDirection); |
||||||
|
Size scaledSize = sourceSize * scale; |
||||||
|
Rect destRect = viewPort |
||||||
|
.CenterRect(new Rect(scaledSize)) |
||||||
|
.WithX(0) |
||||||
|
.WithY(0) |
||||||
|
.Intersect(viewPort); |
||||||
|
Rect sourceRect = new Rect(sourceSize) |
||||||
|
.CenterRect(new Rect(destRect.Size / scale)) |
||||||
|
.WithX(0) |
||||||
|
.WithY(0); |
||||||
|
|
||||||
|
context.DrawImage(source, sourceRect, destRect); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Measures the control. |
||||||
|
/// </summary> |
||||||
|
/// <param name="availableSize">The available size.</param> |
||||||
|
/// <returns>The desired size of the control.</returns> |
||||||
|
protected override Size MeasureOverride(Size availableSize) |
||||||
|
{ |
||||||
|
var source = Source; |
||||||
|
var result = new Size(); |
||||||
|
|
||||||
|
if (source != null) |
||||||
|
{ |
||||||
|
result = Stretch.CalculateSize(availableSize, source.Size, StretchDirection); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc/> |
||||||
|
protected override Size ArrangeOverride(Size finalSize) |
||||||
|
{ |
||||||
|
var source = Source; |
||||||
|
|
||||||
|
if (source != null) |
||||||
|
{ |
||||||
|
var sourceSize = source.Size; |
||||||
|
var result = Stretch.CalculateSize(finalSize, sourceSize); |
||||||
|
return result; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
return new Size(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected override AutomationPeer OnCreateAutomationPeer() |
||||||
|
{ |
||||||
|
return new ImageAutomationPeer(this); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
using System; |
||||||
|
using Avalonia.Controls; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
public class Card : ContentControl |
||||||
|
{ |
||||||
|
protected override Type StyleKeyOverride => typeof(Card); |
||||||
|
|
||||||
|
public Card() |
||||||
|
{ |
||||||
|
MinHeight = 8; |
||||||
|
MinWidth = 8; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,88 @@ |
|||||||
|
using System; |
||||||
|
using System.ComponentModel; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.Globalization; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls.Documents; |
||||||
|
using Avalonia.Media; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using Projektanker.Icons.Avalonia; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[TypeConverter(typeof(FASymbolIconSourceConverter))] |
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] |
||||||
|
public class FASymbolIconSource : PathIconSource |
||||||
|
{ |
||||||
|
public static readonly StyledProperty<string> SymbolProperty = |
||||||
|
AvaloniaProperty.Register<FASymbolIconSource, string>(nameof(Symbol)); |
||||||
|
|
||||||
|
public static readonly StyledProperty<double> FontSizeProperty = TextElement.FontSizeProperty.AddOwner<FASymbolIconSource>(); |
||||||
|
|
||||||
|
|
||||||
|
public FASymbolIconSource() |
||||||
|
{ |
||||||
|
Stretch = Stretch.None; |
||||||
|
// FontSize = 20; // Override value inherited from visual parents. |
||||||
|
InvalidateData(); |
||||||
|
} |
||||||
|
|
||||||
|
public string Symbol |
||||||
|
{ |
||||||
|
get => GetValue(SymbolProperty); |
||||||
|
set => SetValue(SymbolProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public double FontSize |
||||||
|
{ |
||||||
|
get => GetValue(FontSizeProperty); |
||||||
|
set => SetValue(FontSizeProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) |
||||||
|
{ |
||||||
|
base.OnPropertyChanged(change); |
||||||
|
|
||||||
|
if (change.Property == SymbolProperty || change.Property == FontSizeProperty) |
||||||
|
{ |
||||||
|
InvalidateData(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void InvalidateData() |
||||||
|
{ |
||||||
|
var path = IconProvider.Current.GetIconPath(Symbol); |
||||||
|
var geometry = Geometry.Parse(path); |
||||||
|
|
||||||
|
var scale = FontSize / 20; |
||||||
|
|
||||||
|
Data = geometry; |
||||||
|
// TODO: Scaling not working |
||||||
|
Data.Transform = new ScaleTransform(scale, scale); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public class FASymbolIconSourceConverter : TypeConverter |
||||||
|
{ |
||||||
|
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) |
||||||
|
{ |
||||||
|
if (sourceType == typeof(string)) |
||||||
|
{ |
||||||
|
return true; |
||||||
|
} |
||||||
|
return base.CanConvertFrom(context, sourceType); |
||||||
|
} |
||||||
|
|
||||||
|
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) |
||||||
|
{ |
||||||
|
return value switch |
||||||
|
{ |
||||||
|
string val => new FASymbolIconSource |
||||||
|
{ |
||||||
|
Symbol = val, |
||||||
|
}, |
||||||
|
_ => base.ConvertFrom(context, culture, value) |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Templates; |
||||||
|
using Avalonia.Metadata; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
public class LaunchOptionCardTemplateSelector : IDataTemplate |
||||||
|
{ |
||||||
|
// public bool SupportsRecycling => false; |
||||||
|
|
||||||
|
// ReSharper disable once CollectionNeverUpdated.Global |
||||||
|
[Content] |
||||||
|
public Dictionary<LaunchOptionType, IDataTemplate> Templates { get; } = new(); |
||||||
|
|
||||||
|
// Check if we can accept the provided data |
||||||
|
public bool Match(object? data) |
||||||
|
{ |
||||||
|
return data is LaunchOptionCard; |
||||||
|
} |
||||||
|
|
||||||
|
// Build the DataTemplate here |
||||||
|
public Control Build(object? data) |
||||||
|
{ |
||||||
|
if (data is not LaunchOptionCard card) throw new ArgumentException(null, nameof(data)); |
||||||
|
return Templates[card.Type].Build(card)!; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,130 @@ |
|||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Metadata; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// A control used to indicate the progress of an operation. |
||||||
|
/// </summary> |
||||||
|
[PseudoClasses(":preserveaspect", ":indeterminate")] |
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
public class ProgressRing : RangeBase |
||||||
|
{ |
||||||
|
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 bool PreserveAspect |
||||||
|
{ |
||||||
|
get => GetValue(PreserveAspectProperty); |
||||||
|
set => SetValue(PreserveAspectProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public double ValueAngle |
||||||
|
{ |
||||||
|
get => GetValue(ValueAngleProperty); |
||||||
|
private set => SetValue(ValueAngleProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public double StartAngle |
||||||
|
{ |
||||||
|
get => GetValue(StartAngleProperty); |
||||||
|
set => SetValue(StartAngleProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public double EndAngle |
||||||
|
{ |
||||||
|
get => GetValue(EndAngleProperty); |
||||||
|
set => SetValue(EndAngleProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) |
||||||
|
{ |
||||||
|
base.OnPropertyChanged(change); |
||||||
|
var e = change as AvaloniaPropertyChangedEventArgs<bool>; |
||||||
|
if (e is null) return; |
||||||
|
|
||||||
|
if (e.Property == IsIndeterminateProperty) |
||||||
|
{ |
||||||
|
UpdatePseudoClasses(e.NewValue.GetValueOrDefault(), null); |
||||||
|
} |
||||||
|
else if (e.Property == PreserveAspectProperty) |
||||||
|
{ |
||||||
|
UpdatePseudoClasses(null, e.NewValue.GetValueOrDefault()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void UpdatePseudoClasses( |
||||||
|
bool? isIndeterminate, |
||||||
|
bool? preserveAspect) |
||||||
|
{ |
||||||
|
if (isIndeterminate.HasValue) |
||||||
|
{ |
||||||
|
PseudoClasses.Set(":indeterminate", isIndeterminate.Value); |
||||||
|
} |
||||||
|
|
||||||
|
if (preserveAspect.HasValue) |
||||||
|
{ |
||||||
|
PseudoClasses.Set(":preserveaspect", preserveAspect.Value); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static void OnMinimumPropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e) |
||||||
|
{ |
||||||
|
sender.Minimum = (double) e.NewValue!; |
||||||
|
} |
||||||
|
|
||||||
|
private static void OnMaximumPropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e) |
||||||
|
{ |
||||||
|
sender.Maximum = (double) e.NewValue!; |
||||||
|
} |
||||||
|
|
||||||
|
private static void OnValuePropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e) |
||||||
|
{ |
||||||
|
sender.ValueAngle = ((double) e.NewValue! - sender.Minimum) * (sender.EndAngle - sender.StartAngle) / (sender.Maximum - sender.Minimum); |
||||||
|
} |
||||||
|
|
||||||
|
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!; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,41 @@ |
|||||||
|
<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" |
||||||
|
Background="Transparent" |
||||||
|
d:DataContext="{x:Static mocks:DesignData.RefreshBadgeViewModel}" |
||||||
|
x:DataType="vm:RefreshBadgeViewModel" |
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" |
||||||
|
x:Class="StabilityMatrix.Avalonia.Controls.RefreshBadge"> |
||||||
|
<Grid Background="Transparent"> |
||||||
|
<Button |
||||||
|
BorderThickness="0" |
||||||
|
Command="{Binding RefreshCommand}" |
||||||
|
FontSize="26" |
||||||
|
Foreground="{Binding ColorBrush}" |
||||||
|
Margin="4" |
||||||
|
Padding="2" |
||||||
|
Background="Transparent" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsEnabled="{Binding !IsWorking}"> |
||||||
|
<ui:SymbolIcon Symbol="{Binding Icon}"/> |
||||||
|
</Button> |
||||||
|
<controls:ProgressRing |
||||||
|
FontSize="14" |
||||||
|
Grid.Row="0" |
||||||
|
Height="20" |
||||||
|
BorderThickness="2" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsEnabled="{Binding IsWorking}" |
||||||
|
IsIndeterminate="True" |
||||||
|
IsVisible="{Binding IsWorking}" |
||||||
|
Width="20" |
||||||
|
ToolTip.Tip="{Binding CurrentToolTip}" /> |
||||||
|
</Grid> |
||||||
|
</controls:UserControlBase> |
@ -0,0 +1,16 @@ |
|||||||
|
using Avalonia.Markup.Xaml; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
public partial class RefreshBadge : UserControlBase |
||||||
|
{ |
||||||
|
public RefreshBadge() |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
} |
||||||
|
|
||||||
|
private void InitializeComponent() |
||||||
|
{ |
||||||
|
AvaloniaXamlLoader.Load(this); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Interactivity; |
||||||
|
using Avalonia.Threading; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")] |
||||||
|
public class UserControlBase : UserControl |
||||||
|
{ |
||||||
|
static UserControlBase() |
||||||
|
{ |
||||||
|
LoadedEvent.AddClassHandler<UserControlBase>( |
||||||
|
(cls, args) => cls.OnLoadedEvent(args)); |
||||||
|
|
||||||
|
UnloadedEvent.AddClassHandler<UserControlBase>( |
||||||
|
(cls, args) => cls.OnUnloadedEvent(args)); |
||||||
|
} |
||||||
|
|
||||||
|
// ReSharper disable once UnusedParameter.Global |
||||||
|
protected virtual void OnLoadedEvent(RoutedEventArgs? e) |
||||||
|
{ |
||||||
|
if (DataContext is not ViewModelBase viewModel) return; |
||||||
|
|
||||||
|
// Run synchronous load then async load |
||||||
|
viewModel.OnLoaded(); |
||||||
|
|
||||||
|
// Can't block here so we'll run as async on UI thread |
||||||
|
Dispatcher.UIThread.InvokeAsync(async () => |
||||||
|
{ |
||||||
|
await viewModel.OnLoadedAsync(); |
||||||
|
}).SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
// ReSharper disable once UnusedParameter.Global |
||||||
|
protected virtual void OnUnloadedEvent(RoutedEventArgs? e) |
||||||
|
{ |
||||||
|
if (DataContext is not ViewModelBase viewModel) return; |
||||||
|
|
||||||
|
// Run synchronous load then async load |
||||||
|
viewModel.OnUnloaded(); |
||||||
|
|
||||||
|
// Can't block here so we'll run as async on UI thread |
||||||
|
Dispatcher.UIThread.InvokeAsync(async () => |
||||||
|
{ |
||||||
|
await viewModel.OnUnloadedAsync(); |
||||||
|
}).SafeFireAndForget(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
using System; |
||||||
|
using System.Globalization; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class FitSquarelyWithinAspectRatioConverter : IValueConverter |
||||||
|
{ |
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
var bounds = value is Rect rect ? rect : default; |
||||||
|
return Math.Min(bounds.Width, bounds.Height); |
||||||
|
} |
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
throw new NotImplementedException(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,46 @@ |
|||||||
|
using System; |
||||||
|
using System.Globalization; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class LaunchOptionConverter : IValueConverter |
||||||
|
{ |
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
if (targetType == typeof(string)) |
||||||
|
{ |
||||||
|
return value?.ToString() ?? ""; |
||||||
|
} |
||||||
|
|
||||||
|
if (targetType == typeof(bool?)) |
||||||
|
{ |
||||||
|
return bool.TryParse(value?.ToString(), out var boolValue) && boolValue; |
||||||
|
} |
||||||
|
|
||||||
|
if (targetType == typeof(double?)) |
||||||
|
{ |
||||||
|
if (value == null) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
return double.TryParse(value.ToString(), out var doubleValue) ? doubleValue : 0; |
||||||
|
} |
||||||
|
|
||||||
|
if (targetType == typeof(int?)) |
||||||
|
{ |
||||||
|
if (value == null) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
return int.TryParse(value.ToString(), out var intValue) ? intValue : 0; |
||||||
|
} |
||||||
|
|
||||||
|
throw new ArgumentException("Unsupported type"); |
||||||
|
} |
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
return value; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,34 @@ |
|||||||
|
using System; |
||||||
|
using System.Globalization; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class LaunchOptionIntDoubleConverter : IValueConverter |
||||||
|
{ |
||||||
|
// Convert from int to double |
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
if (targetType == typeof(double?)) |
||||||
|
{ |
||||||
|
if (value == null) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
return System.Convert.ToDouble(value); |
||||||
|
} |
||||||
|
|
||||||
|
throw new ArgumentException($"Unsupported type {targetType}"); |
||||||
|
} |
||||||
|
|
||||||
|
// Convert from double to object int (floor) |
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
if (targetType == typeof(int?) || targetType == typeof(object)) |
||||||
|
{ |
||||||
|
return System.Convert.ToInt32(value); |
||||||
|
} |
||||||
|
|
||||||
|
throw new ArgumentException($"Unsupported type {targetType}"); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Globalization; |
||||||
|
using System.Linq; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class ValueConverterGroup : List<IValueConverter>, IValueConverter |
||||||
|
{ |
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
return this.Aggregate(value, (current, converter) => converter.Convert(current, targetType, parameter, culture)); |
||||||
|
} |
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
throw new NotImplementedException(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,268 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.Immutable; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.Net.Http; |
||||||
|
using Microsoft.Extensions.DependencyInjection; |
||||||
|
using StabilityMatrix.Avalonia.Models; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
using StabilityMatrix.Core.Api; |
||||||
|
using StabilityMatrix.Core.Database; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Helper.Cache; |
||||||
|
using StabilityMatrix.Core.Helper.Factory; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Python; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using StabilityMatrix.Core.Updater; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.DesignData; |
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
public static class DesignData |
||||||
|
{ |
||||||
|
[NotNull] public static IServiceProvider? Services { get; set; } |
||||||
|
|
||||||
|
private static bool isInitialized; |
||||||
|
|
||||||
|
// This needs to be static method instead of static constructor |
||||||
|
// or else Avalonia analyzers won't work. |
||||||
|
public static void Initialize() |
||||||
|
{ |
||||||
|
if (isInitialized) throw new InvalidOperationException("DesignData is already initialized."); |
||||||
|
|
||||||
|
var services = new ServiceCollection(); |
||||||
|
|
||||||
|
var activePackageId = Guid.NewGuid(); |
||||||
|
services.AddSingleton<ISettingsManager, MockSettingsManager>(_ => new MockSettingsManager |
||||||
|
{ |
||||||
|
Settings = |
||||||
|
{ |
||||||
|
InstalledPackages = new List<InstalledPackage> |
||||||
|
{ |
||||||
|
new() |
||||||
|
{ |
||||||
|
Id = activePackageId, |
||||||
|
DisplayName = "My Installed Package", |
||||||
|
PackageName = "stable-diffusion-webui", |
||||||
|
PackageVersion = "v1.0.0", |
||||||
|
LibraryPath = $"Packages{Environment.NewLine}example-webui", |
||||||
|
LastUpdateCheck = DateTimeOffset.Now |
||||||
|
} |
||||||
|
}, |
||||||
|
ActiveInstalledPackage = activePackageId |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// General services |
||||||
|
services.AddLogging() |
||||||
|
.AddSingleton<IPackageFactory, PackageFactory>() |
||||||
|
.AddSingleton<IUpdateHelper, UpdateHelper>() |
||||||
|
.AddSingleton<ModelFinder>() |
||||||
|
.AddSingleton<SharedState>(); |
||||||
|
|
||||||
|
// Mock services |
||||||
|
services |
||||||
|
.AddSingleton<INotificationService, MockNotificationService>() |
||||||
|
.AddSingleton<ISharedFolders, MockSharedFolders>() |
||||||
|
.AddSingleton<IDownloadService, MockDownloadService>() |
||||||
|
.AddSingleton<IHttpClientFactory, MockHttpClientFactory>(); |
||||||
|
|
||||||
|
// Placeholder services that nobody should need during design time |
||||||
|
services |
||||||
|
.AddSingleton<IPyRunner>(_ => null!) |
||||||
|
.AddSingleton<ILiteDbContext>(_ => null!) |
||||||
|
.AddSingleton<ICivitApi>(_ => null!) |
||||||
|
.AddSingleton<IGithubApiCache>(_ => null!) |
||||||
|
.AddSingleton<IPrerequisiteHelper>(_ => null!); |
||||||
|
|
||||||
|
// Using some default service implementations from App |
||||||
|
App.ConfigurePackages(services); |
||||||
|
App.ConfigurePageViewModels(services); |
||||||
|
App.ConfigureDialogViewModels(services); |
||||||
|
App.ConfigureViews(services); |
||||||
|
|
||||||
|
Services = services.BuildServiceProvider(); |
||||||
|
|
||||||
|
var dialogFactory = Services.GetRequiredService<ServiceManager<ViewModelBase>>(); |
||||||
|
var settingsManager = Services.GetRequiredService<ISettingsManager>(); |
||||||
|
var downloadService = Services.GetRequiredService<IDownloadService>(); |
||||||
|
var modelFinder = Services.GetRequiredService<ModelFinder>(); |
||||||
|
var packageFactory = Services.GetRequiredService<IPackageFactory>(); |
||||||
|
var notificationService = Services.GetRequiredService<INotificationService>(); |
||||||
|
|
||||||
|
// Sample data |
||||||
|
var sampleCivitVersions = new List<CivitModelVersion> |
||||||
|
{ |
||||||
|
new() |
||||||
|
{ |
||||||
|
Name = "BB95 Furry Mix", |
||||||
|
Description = "v1.0.0", |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
var sampleViewModel = new ModelVersionViewModel(settingsManager, sampleCivitVersions[0]); |
||||||
|
|
||||||
|
// Sample data for dialogs |
||||||
|
SelectModelVersionViewModel.Versions = new[] {sampleViewModel}; |
||||||
|
SelectModelVersionViewModel.SelectedVersionViewModel = sampleViewModel; |
||||||
|
|
||||||
|
LaunchOptionsViewModel = Services.GetRequiredService<LaunchOptionsViewModel>(); |
||||||
|
LaunchOptionsViewModel.Cards = new[] |
||||||
|
{ |
||||||
|
LaunchOptionCard.FromDefinition(new LaunchOptionDefinition |
||||||
|
{ |
||||||
|
Name = "Host", |
||||||
|
Type = LaunchOptionType.String, |
||||||
|
Description = "The host name for the Web UI", |
||||||
|
DefaultValue = "localhost", |
||||||
|
Options = { "--host" } |
||||||
|
}), |
||||||
|
LaunchOptionCard.FromDefinition(new LaunchOptionDefinition |
||||||
|
{ |
||||||
|
Name = "API", |
||||||
|
Type = LaunchOptionType.Bool, |
||||||
|
Options = { "--api" } |
||||||
|
}) |
||||||
|
}; |
||||||
|
LaunchOptionsViewModel.UpdateFilterCards(); |
||||||
|
|
||||||
|
InstallerViewModel = Services.GetRequiredService<InstallerViewModel>(); |
||||||
|
InstallerViewModel.AvailablePackages = |
||||||
|
packageFactory.GetAllAvailablePackages().ToImmutableArray(); |
||||||
|
InstallerViewModel.SelectedPackage = InstallerViewModel.AvailablePackages[0]; |
||||||
|
InstallerViewModel.ReleaseNotes = "## Release Notes\nThis is a test release note."; |
||||||
|
|
||||||
|
// Checkpoints page |
||||||
|
CheckpointsPageViewModel.CheckpointFolders = new ObservableCollection<CheckpointFolder> |
||||||
|
{ |
||||||
|
new(settingsManager, downloadService, modelFinder) |
||||||
|
{ |
||||||
|
Title = "Lora", |
||||||
|
DirectoryPath = "Packages/lora", |
||||||
|
CheckpointFiles = new ObservableCollection<CheckpointFile> |
||||||
|
{ |
||||||
|
new() |
||||||
|
{ |
||||||
|
FilePath = "~/Models/Lora/electricity-light.safetensors", |
||||||
|
Title = "Auroral Background", |
||||||
|
ConnectedModel = new ConnectedModelInfo |
||||||
|
{ |
||||||
|
VersionName = "Lightning Auroral", |
||||||
|
BaseModel = "SD 1.5", |
||||||
|
ModelName = "Auroral Background", |
||||||
|
ModelType = CivitModelType.LORA, |
||||||
|
FileMetadata = new CivitFileMetadata |
||||||
|
{ |
||||||
|
Format = CivitModelFormat.SafeTensor, |
||||||
|
Fp = CivitModelFpType.fp16, |
||||||
|
Size = CivitModelSize.pruned, |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
new() |
||||||
|
{ |
||||||
|
FilePath = "~/Models/Lora/model.safetensors", |
||||||
|
Title = "Some model" |
||||||
|
}, |
||||||
|
} |
||||||
|
}, |
||||||
|
new(settingsManager, downloadService, modelFinder) |
||||||
|
{ |
||||||
|
Title = "VAE", |
||||||
|
DirectoryPath = "Packages/VAE", |
||||||
|
CheckpointFiles = new ObservableCollection<CheckpointFile> |
||||||
|
{ |
||||||
|
new() |
||||||
|
{ |
||||||
|
FilePath = "~/Models/VAE/vae_v2.pt", |
||||||
|
Title = "VAE v2", |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
CheckpointBrowserViewModel.ModelCards = new |
||||||
|
ObservableCollection<CheckpointBrowserCardViewModel> |
||||||
|
{ |
||||||
|
new(new CivitModel |
||||||
|
{ |
||||||
|
Name = "BB95 Furry Mix", |
||||||
|
Description = "A furry mix of BB95", |
||||||
|
}, downloadService, settingsManager, |
||||||
|
dialogFactory, notificationService) |
||||||
|
}; |
||||||
|
|
||||||
|
ProgressManagerViewModel.ProgressItems = new ObservableCollection<ProgressItemViewModel> |
||||||
|
{ |
||||||
|
new(new ProgressItem(Guid.NewGuid(), "Test File.exe", new ProgressReport(0.5f, "Downloading..."))), |
||||||
|
new(new ProgressItem(Guid.NewGuid(), "Test File 2.uwu", new ProgressReport(0.25f, "Extracting..."))) |
||||||
|
}; |
||||||
|
|
||||||
|
UpdateViewModel = Services.GetRequiredService<UpdateViewModel>(); |
||||||
|
UpdateViewModel.UpdateText = |
||||||
|
$"Stability Matrix v2.0.1 is now available! You currently have v2.0.0. Would you like to update now?"; |
||||||
|
UpdateViewModel.ReleaseNotes = "## v2.0.1\n- Fixed a bug\n- Added a feature\n- Removed a feature"; |
||||||
|
|
||||||
|
isInitialized = true; |
||||||
|
} |
||||||
|
|
||||||
|
[NotNull] public static InstallerViewModel? InstallerViewModel { get; private set; } |
||||||
|
[NotNull] public static LaunchOptionsViewModel? LaunchOptionsViewModel { get; private set; } |
||||||
|
[NotNull] public static UpdateViewModel? UpdateViewModel { get; private set; } |
||||||
|
|
||||||
|
public static ServiceManager<ViewModelBase> DialogFactory => |
||||||
|
Services.GetRequiredService<ServiceManager<ViewModelBase>>(); |
||||||
|
public static MainWindowViewModel MainWindowViewModel => |
||||||
|
Services.GetRequiredService<MainWindowViewModel>(); |
||||||
|
public static FirstLaunchSetupViewModel FirstLaunchSetupViewModel => |
||||||
|
Services.GetRequiredService<FirstLaunchSetupViewModel>(); |
||||||
|
public static LaunchPageViewModel LaunchPageViewModel => |
||||||
|
Services.GetRequiredService<LaunchPageViewModel>(); |
||||||
|
public static PackageManagerViewModel PackageManagerViewModel => |
||||||
|
Services.GetRequiredService<PackageManagerViewModel>(); |
||||||
|
public static CheckpointsPageViewModel CheckpointsPageViewModel => |
||||||
|
Services.GetRequiredService<CheckpointsPageViewModel>(); |
||||||
|
public static SettingsViewModel SettingsViewModel => |
||||||
|
Services.GetRequiredService<SettingsViewModel>(); |
||||||
|
public static CheckpointBrowserViewModel CheckpointBrowserViewModel => |
||||||
|
Services.GetRequiredService<CheckpointBrowserViewModel>(); |
||||||
|
public static SelectModelVersionViewModel SelectModelVersionViewModel => |
||||||
|
Services.GetRequiredService<SelectModelVersionViewModel>(); |
||||||
|
public static OneClickInstallViewModel OneClickInstallViewModel => |
||||||
|
Services.GetRequiredService<OneClickInstallViewModel>(); |
||||||
|
public static SelectDataDirectoryViewModel SelectDataDirectoryViewModel => |
||||||
|
Services.GetRequiredService<SelectDataDirectoryViewModel>(); |
||||||
|
public static ProgressManagerViewModel ProgressManagerViewModel => |
||||||
|
Services.GetRequiredService<ProgressManagerViewModel>(); |
||||||
|
public static ExceptionViewModel ExceptionViewModel => |
||||||
|
DialogFactory.Get<ExceptionViewModel>(viewModel => |
||||||
|
{ |
||||||
|
// Use try-catch to generate traceback information |
||||||
|
try |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
throw new OperationCanceledException("Example"); |
||||||
|
} |
||||||
|
catch (OperationCanceledException e) |
||||||
|
{ |
||||||
|
throw new AggregateException(e); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (AggregateException e) |
||||||
|
{ |
||||||
|
viewModel.Exception = e; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
public static RefreshBadgeViewModel RefreshBadgeViewModel => new() |
||||||
|
{ |
||||||
|
State = ProgressState.Success |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,21 @@ |
|||||||
|
using System; |
||||||
|
using System.IO; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.DesignData; |
||||||
|
|
||||||
|
public class MockDownloadService : IDownloadService |
||||||
|
{ |
||||||
|
public Task DownloadToFileAsync(string downloadUrl, string downloadPath, |
||||||
|
IProgress<ProgressReport>? progress = null, string? httpClientName = null) |
||||||
|
{ |
||||||
|
return Task.CompletedTask; |
||||||
|
} |
||||||
|
|
||||||
|
public Task<Stream> GetImageStreamFromUrl(string url) |
||||||
|
{ |
||||||
|
return Task.FromResult(new MemoryStream(new byte[24]) as Stream); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,12 @@ |
|||||||
|
using System; |
||||||
|
using System.Net.Http; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.DesignData; |
||||||
|
|
||||||
|
public class MockHttpClientFactory : IHttpClientFactory |
||||||
|
{ |
||||||
|
public HttpClient CreateClient(string name) |
||||||
|
{ |
||||||
|
throw new NotImplementedException(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using LiteDB.Async; |
||||||
|
using StabilityMatrix.Core.Database; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Models.Database; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.DesignData; |
||||||
|
|
||||||
|
public class MockLiteDbContext : ILiteDbContext |
||||||
|
{ |
||||||
|
public LiteDatabaseAsync Database => throw new NotImplementedException(); |
||||||
|
public ILiteCollectionAsync<CivitModel> CivitModels => throw new NotImplementedException(); |
||||||
|
public ILiteCollectionAsync<CivitModelVersion> CivitModelVersions => throw new NotImplementedException(); |
||||||
|
public ILiteCollectionAsync<CivitModelQueryCacheEntry> CivitModelQueryCache => throw new NotImplementedException(); |
||||||
|
public Task<(CivitModel?, CivitModelVersion?)> FindCivitModelFromFileHashAsync(string hashBlake3) |
||||||
|
{ |
||||||
|
return Task.FromResult<(CivitModel?, CivitModelVersion?)>((null, null)); |
||||||
|
} |
||||||
|
|
||||||
|
public Task<bool> UpsertCivitModelAsync(CivitModel civitModel) |
||||||
|
{ |
||||||
|
return Task.FromResult(true); |
||||||
|
} |
||||||
|
|
||||||
|
public Task<bool> UpsertCivitModelAsync(IEnumerable<CivitModel> civitModels) |
||||||
|
{ |
||||||
|
return Task.FromResult(true); |
||||||
|
} |
||||||
|
|
||||||
|
public Task<bool> UpsertCivitModelQueryCacheEntryAsync(CivitModelQueryCacheEntry entry) |
||||||
|
{ |
||||||
|
return Task.FromResult(true); |
||||||
|
} |
||||||
|
|
||||||
|
public Task<GithubCacheEntry?> GetGithubCacheEntry(string cacheKey) |
||||||
|
{ |
||||||
|
return Task.FromResult<GithubCacheEntry>(null); |
||||||
|
} |
||||||
|
|
||||||
|
public Task<bool> UpsertGithubCacheEntry(GithubCacheEntry cacheEntry) |
||||||
|
{ |
||||||
|
return Task.FromResult(true); |
||||||
|
} |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
GC.SuppressFinalize(this); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,35 @@ |
|||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.DesignData; |
||||||
|
|
||||||
|
public class MockNotificationService : INotificationService |
||||||
|
{ |
||||||
|
public void Initialize(Visual? visual, |
||||||
|
NotificationPosition position = NotificationPosition.BottomRight, int maxItems = 3) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public void Show(INotification notification) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public Task<TaskResult<T>> TryAsync<T>(Task<T> task, string title = "Error", string? message = null, |
||||||
|
NotificationType appearance = NotificationType.Error) |
||||||
|
{ |
||||||
|
return Task.FromResult(new TaskResult<T>(default!)); |
||||||
|
} |
||||||
|
|
||||||
|
public Task<TaskResult<bool>> TryAsync(Task task, string title = "Error", string? message = null, |
||||||
|
NotificationType appearance = NotificationType.Error) |
||||||
|
{ |
||||||
|
return Task.FromResult(new TaskResult<bool>(true)); |
||||||
|
} |
||||||
|
|
||||||
|
public void Show(string title, string message, NotificationType appearance = NotificationType.Information) |
||||||
|
{ |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.DesignData; |
||||||
|
|
||||||
|
public class MockSettingsManager : SettingsManager |
||||||
|
{ |
||||||
|
protected override void LoadSettings() {} |
||||||
|
protected override void SaveSettings() {} |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
using System.Threading.Tasks; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models.FileInterfaces; |
||||||
|
using StabilityMatrix.Core.Models.Packages; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.DesignData; |
||||||
|
|
||||||
|
public class MockSharedFolders : ISharedFolders |
||||||
|
{ |
||||||
|
public void SetupLinksForPackage(BasePackage basePackage, DirectoryPath installDirectory) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public Task UpdateLinksForPackage(BasePackage basePackage, DirectoryPath installDirectory) |
||||||
|
{ |
||||||
|
return Task.CompletedTask; |
||||||
|
} |
||||||
|
|
||||||
|
public void RemoveLinksForAllPackages() |
||||||
|
{ |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,191 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.ComponentModel; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.Linq; |
||||||
|
using System.Runtime.CompilerServices; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Data; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using Markdown.Avalonia; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia; |
||||||
|
|
||||||
|
public static class DialogHelper |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Create a generic textbox entry content dialog. |
||||||
|
/// </summary> |
||||||
|
public static BetterContentDialog CreateTextEntryDialog( |
||||||
|
string title, |
||||||
|
string description, |
||||||
|
IReadOnlyList<TextBoxField> textFields) |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.VerifyAccess(); |
||||||
|
|
||||||
|
var stackPanel = new StackPanel(); |
||||||
|
var grid = new Grid |
||||||
|
{ |
||||||
|
RowDefinitions = |
||||||
|
{ |
||||||
|
new RowDefinition(GridLength.Auto), |
||||||
|
new RowDefinition(GridLength.Star) |
||||||
|
}, |
||||||
|
Children = |
||||||
|
{ |
||||||
|
new TextBlock |
||||||
|
{ |
||||||
|
Text = description |
||||||
|
}, |
||||||
|
stackPanel |
||||||
|
} |
||||||
|
}; |
||||||
|
grid.Loaded += (_, _) => |
||||||
|
{ |
||||||
|
// Focus first textbox |
||||||
|
var firstTextBox = stackPanel.Children.OfType<TextBox>().First(); |
||||||
|
firstTextBox.Focus(); |
||||||
|
firstTextBox.CaretIndex = firstTextBox.Text?.LastIndexOf('.') ?? 0; |
||||||
|
}; |
||||||
|
|
||||||
|
// Disable primary button if any textboxes are invalid |
||||||
|
var primaryCommand = new RelayCommand(delegate { }, |
||||||
|
() => |
||||||
|
{ |
||||||
|
var invalidCount = textFields.Count(field => !field.IsValid); |
||||||
|
Debug.WriteLine($"Checking can execute: {invalidCount} invalid fields"); |
||||||
|
return invalidCount == 0; |
||||||
|
}); |
||||||
|
|
||||||
|
// Create textboxes |
||||||
|
foreach (var field in textFields) |
||||||
|
{ |
||||||
|
var label = new TextBlock |
||||||
|
{ |
||||||
|
Text = field.Label |
||||||
|
}; |
||||||
|
stackPanel.Children.Add(label); |
||||||
|
|
||||||
|
var textBox = new TextBox |
||||||
|
{ |
||||||
|
[!TextBox.TextProperty] = new Binding("TextProperty"), |
||||||
|
Watermark = field.Watermark, |
||||||
|
DataContext = field, |
||||||
|
}; |
||||||
|
stackPanel.Children.Add(textBox); |
||||||
|
|
||||||
|
// When IsValid property changes, update invalid count and primary button |
||||||
|
field.PropertyChanged += (_, args) => |
||||||
|
{ |
||||||
|
if (args.PropertyName == nameof(TextBoxField.IsValid)) |
||||||
|
{ |
||||||
|
primaryCommand.NotifyCanExecuteChanged(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// Set initial value |
||||||
|
textBox.Text = field.Text; |
||||||
|
|
||||||
|
// See if initial value is valid |
||||||
|
try |
||||||
|
{ |
||||||
|
field.Validator?.Invoke(field.Text); |
||||||
|
} |
||||||
|
catch (Exception) |
||||||
|
{ |
||||||
|
field.IsValid = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return new BetterContentDialog |
||||||
|
{ |
||||||
|
Title = title, |
||||||
|
Content = grid, |
||||||
|
PrimaryButtonText = "OK", |
||||||
|
CloseButtonText = "Cancel", |
||||||
|
IsPrimaryButtonEnabled = true, |
||||||
|
PrimaryButtonCommand = primaryCommand, |
||||||
|
DefaultButton = ContentDialogButton.Primary |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Create a generic dialog for showing a markdown document |
||||||
|
/// </summary> |
||||||
|
public static BetterContentDialog CreateMarkdownDialog(string markdown, string? title = null) |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.VerifyAccess(); |
||||||
|
|
||||||
|
var viewer = new MarkdownScrollViewer |
||||||
|
{ |
||||||
|
Markdown = markdown |
||||||
|
}; |
||||||
|
|
||||||
|
return new BetterContentDialog |
||||||
|
{ |
||||||
|
Title = title, |
||||||
|
Content = viewer, |
||||||
|
CloseButtonText = "Close", |
||||||
|
IsPrimaryButtonEnabled = false, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Text fields |
||||||
|
public sealed class TextBoxField : INotifyPropertyChanged |
||||||
|
{ |
||||||
|
// Label above the textbox |
||||||
|
public string Label { get; init; } = string.Empty; |
||||||
|
// Actual text value |
||||||
|
public string Text { get; set; } = string.Empty; |
||||||
|
// Watermark text |
||||||
|
public string Watermark { get; init; } = string.Empty; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Validation action on text changes. Throw exception if invalid. |
||||||
|
/// </summary> |
||||||
|
public Action<string>? Validator { get; init; } |
||||||
|
|
||||||
|
public string TextProperty |
||||||
|
{ |
||||||
|
get => Text; |
||||||
|
[DebuggerStepThrough] |
||||||
|
set |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
Validator?.Invoke(value); |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
IsValid = false; |
||||||
|
throw new DataValidationException(e.Message); |
||||||
|
} |
||||||
|
Text = value; |
||||||
|
IsValid = true; |
||||||
|
OnPropertyChanged(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Default to true if no validator is provided |
||||||
|
private bool isValid; |
||||||
|
public bool IsValid |
||||||
|
{ |
||||||
|
get => Validator == null || isValid; |
||||||
|
set |
||||||
|
{ |
||||||
|
isValid = value; |
||||||
|
OnPropertyChanged(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged; |
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) |
||||||
|
{ |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,222 @@ |
|||||||
|
using System; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Runtime.Versioning; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia.Controls; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Core.Exceptions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models.FileInterfaces; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Python; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Helpers; |
||||||
|
|
||||||
|
[SupportedOSPlatform("macos")] |
||||||
|
[SupportedOSPlatform("linux")] |
||||||
|
public class UnixPrerequisiteHelper : IPrerequisiteHelper |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
|
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly IPyRunner pyRunner; |
||||||
|
|
||||||
|
private DirectoryPath HomeDir => settingsManager.LibraryDir; |
||||||
|
private DirectoryPath AssetsDir => HomeDir + "Assets"; |
||||||
|
|
||||||
|
private DirectoryPath PythonDir => AssetsDir + "Python310"; |
||||||
|
private FilePath PythonDllPath => PythonDir + "python310.dll"; |
||||||
|
public bool IsPythonInstalled => PythonDllPath.Exists; |
||||||
|
|
||||||
|
private DirectoryPath PortableGitInstallDir => HomeDir + "PortableGit"; |
||||||
|
public string GitBinPath => PortableGitInstallDir + "bin"; |
||||||
|
|
||||||
|
// Cached store of whether or not git is installed |
||||||
|
private bool? isGitInstalled; |
||||||
|
|
||||||
|
public UnixPrerequisiteHelper( |
||||||
|
IDownloadService downloadService, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
IPyRunner pyRunner) |
||||||
|
{ |
||||||
|
this.downloadService = downloadService; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.pyRunner = pyRunner; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<bool> CheckIsGitInstalled() |
||||||
|
{ |
||||||
|
var result = await ProcessRunner.RunBashCommand("git --version"); |
||||||
|
isGitInstalled = result.ExitCode == 0; |
||||||
|
return isGitInstalled == true; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task InstallAllIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
await UnpackResourcesIfNecessary(progress); |
||||||
|
await InstallPythonIfNecessary(progress); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task UnpackResourcesIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
// Array of (asset_uri, extract_to) |
||||||
|
var assets = new[] |
||||||
|
{ |
||||||
|
(Assets.SevenZipExecutable, AssetsDir), |
||||||
|
(Assets.SevenZipLicense, AssetsDir), |
||||||
|
}; |
||||||
|
|
||||||
|
progress?.Report(new ProgressReport(0, message: "Unpacking resources", isIndeterminate: true)); |
||||||
|
|
||||||
|
Directory.CreateDirectory(AssetsDir); |
||||||
|
foreach (var (asset, extractDir) in assets) |
||||||
|
{ |
||||||
|
await asset.ExtractToDir(extractDir); |
||||||
|
} |
||||||
|
|
||||||
|
progress?.Report(new ProgressReport(1, message: "Unpacking resources", isIndeterminate: false)); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task InstallGitIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
if (isGitInstalled == true || (isGitInstalled == null && await CheckIsGitInstalled())) return; |
||||||
|
|
||||||
|
// Show prompt to install git |
||||||
|
var dialog = new ContentDialog |
||||||
|
{ |
||||||
|
Title = "Git not found", |
||||||
|
Content = new StackPanel |
||||||
|
{ |
||||||
|
Children = |
||||||
|
{ |
||||||
|
new TextBlock |
||||||
|
{ |
||||||
|
Text = "The current operation requires Git. Please install it to continue." |
||||||
|
}, |
||||||
|
new SelectableTextBlock |
||||||
|
{ |
||||||
|
Text = "$ sudo apt install git" |
||||||
|
}, |
||||||
|
} |
||||||
|
}, |
||||||
|
PrimaryButtonText = "Retry", |
||||||
|
CloseButtonText = "Close", |
||||||
|
DefaultButton = ContentDialogButton.Primary, |
||||||
|
}; |
||||||
|
|
||||||
|
while (true) |
||||||
|
{ |
||||||
|
// Return if installed |
||||||
|
if (await CheckIsGitInstalled()) return; |
||||||
|
if (await dialog.ShowAsync() == ContentDialogResult.None) |
||||||
|
{ |
||||||
|
// Cancel |
||||||
|
throw new OperationCanceledException("Git installation canceled"); |
||||||
|
} |
||||||
|
// Otherwise continue to retry indefinitely |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public async Task RunGit(string? workingDirectory = null, params string[] args) |
||||||
|
{ |
||||||
|
var command = args.Length == 0 ? "git" : |
||||||
|
"git " + string.Join(" ", args.Select(ProcessRunner.Quote)); |
||||||
|
|
||||||
|
var result = await ProcessRunner.RunBashCommand(command, workingDirectory ?? ""); |
||||||
|
if (result.ExitCode != 0) |
||||||
|
{ |
||||||
|
Logger.Error("Git command [{Command}] failed with exit code " + |
||||||
|
"{ExitCode}:\n{StdOut}\n{StdErr}", |
||||||
|
command, result.ExitCode, result.StandardOutput, result.StandardError); |
||||||
|
|
||||||
|
throw new ProcessException($"Git command [{command}] failed with exit code" + |
||||||
|
$" {result.ExitCode}:\n{result.StandardOutput}\n{result.StandardError}"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public async Task InstallPythonIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
if (IsPythonInstalled) return; |
||||||
|
|
||||||
|
Directory.CreateDirectory(AssetsDir); |
||||||
|
|
||||||
|
// Download |
||||||
|
var remote = Assets.PythonDownloadUrl; |
||||||
|
var url = remote.Url; |
||||||
|
var hashSha256 = remote.HashSha256; |
||||||
|
|
||||||
|
var fileName = Path.GetFileName(url.LocalPath); |
||||||
|
var downloadPath = Path.Combine(AssetsDir, fileName); |
||||||
|
Logger.Info($"Downloading Python from {url} to {downloadPath}"); |
||||||
|
try |
||||||
|
{ |
||||||
|
await downloadService.DownloadToFileAsync(url.ToString(), downloadPath, progress); |
||||||
|
|
||||||
|
// Verify hash |
||||||
|
var actualHash = await FileHash.GetSha256Async(downloadPath); |
||||||
|
Logger.Info($"Verifying Python hash: (expected: {hashSha256}, actual: {actualHash})"); |
||||||
|
if (actualHash != hashSha256) |
||||||
|
{ |
||||||
|
throw new Exception($"Python download hash mismatch: expected {hashSha256}, actual {actualHash}"); |
||||||
|
} |
||||||
|
|
||||||
|
// Extract |
||||||
|
Logger.Info($"Extracting Python Zip: {downloadPath} to {PythonDir}"); |
||||||
|
if (PythonDir.Exists) |
||||||
|
{ |
||||||
|
await PythonDir.DeleteAsync(true); |
||||||
|
} |
||||||
|
progress?.Report(new ProgressReport(0, "Installing Python", isIndeterminate: true)); |
||||||
|
await ArchiveHelper.Extract7ZAuto(downloadPath, PythonDir); |
||||||
|
|
||||||
|
// For Linux, move the inner 'python' folder up to the root PythonDir |
||||||
|
if (Compat.IsLinux) |
||||||
|
{ |
||||||
|
var innerPythonDir = PythonDir.JoinDir("python"); |
||||||
|
if (!innerPythonDir.Exists) |
||||||
|
{ |
||||||
|
throw new Exception($"Python download did not contain expected inner 'python' folder: {innerPythonDir}"); |
||||||
|
} |
||||||
|
|
||||||
|
foreach (var folder in Directory.EnumerateDirectories(innerPythonDir)) |
||||||
|
{ |
||||||
|
var folderName = Path.GetFileName(folder); |
||||||
|
var dest = Path.Combine(PythonDir, folderName); |
||||||
|
Directory.Move(folder, dest); |
||||||
|
} |
||||||
|
Directory.Delete(innerPythonDir); |
||||||
|
} |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
// Cleanup download file |
||||||
|
if (File.Exists(downloadPath)) |
||||||
|
{ |
||||||
|
File.Delete(downloadPath); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Initialize pyrunner and install virtualenv |
||||||
|
await pyRunner.Initialize(); |
||||||
|
await pyRunner.InstallPackage("virtualenv"); |
||||||
|
|
||||||
|
progress?.Report(new ProgressReport(1, "Installing Python", isIndeterminate: false)); |
||||||
|
} |
||||||
|
|
||||||
|
public Task<string> GetGitOutput(string? workingDirectory = null, params string[] args) |
||||||
|
{ |
||||||
|
throw new NotImplementedException(); |
||||||
|
} |
||||||
|
|
||||||
|
[UnsupportedOSPlatform("Linux")] |
||||||
|
[UnsupportedOSPlatform("macOS")] |
||||||
|
public Task InstallVcRedistIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
throw new PlatformNotSupportedException(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,290 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Runtime.Versioning; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Microsoft.Win32; |
||||||
|
using NLog; |
||||||
|
using Octokit; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Helpers; |
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")] |
||||||
|
public class WindowsPrerequisiteHelper : IPrerequisiteHelper |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
|
||||||
|
private readonly IGitHubClient gitHubClient; |
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
|
||||||
|
private const string VcRedistDownloadUrl = "https://aka.ms/vs/16/release/vc_redist.x64.exe"; |
||||||
|
|
||||||
|
private string HomeDir => settingsManager.LibraryDir; |
||||||
|
|
||||||
|
private string VcRedistDownloadPath => Path.Combine(HomeDir, "vcredist.x64.exe"); |
||||||
|
|
||||||
|
private string AssetsDir => Path.Combine(HomeDir, "Assets"); |
||||||
|
private string SevenZipPath => Path.Combine(AssetsDir, "7za.exe"); |
||||||
|
|
||||||
|
private string PythonDownloadPath => Path.Combine(AssetsDir, "python-3.10.11-embed-amd64.zip"); |
||||||
|
private string PythonDir => Path.Combine(AssetsDir, "Python310"); |
||||||
|
private string PythonDllPath => Path.Combine(PythonDir, "python310.dll"); |
||||||
|
private string PythonLibraryZipPath => Path.Combine(PythonDir, "python310.zip"); |
||||||
|
private string GetPipPath => Path.Combine(PythonDir, "get-pip.pyc"); |
||||||
|
// Temporary directory to extract venv to during python install |
||||||
|
private string VenvTempDir => Path.Combine(PythonDir, "venv"); |
||||||
|
|
||||||
|
private string PortableGitInstallDir => Path.Combine(HomeDir, "PortableGit"); |
||||||
|
private string PortableGitDownloadPath => Path.Combine(HomeDir, "PortableGit.7z.exe"); |
||||||
|
private string GitExePath => Path.Combine(PortableGitInstallDir, "bin", "git.exe"); |
||||||
|
public string GitBinPath => Path.Combine(PortableGitInstallDir, "bin"); |
||||||
|
|
||||||
|
public bool IsPythonInstalled => File.Exists(PythonDllPath); |
||||||
|
|
||||||
|
public WindowsPrerequisiteHelper( |
||||||
|
IGitHubClient gitHubClient, |
||||||
|
IDownloadService downloadService, |
||||||
|
ISettingsManager settingsManager) |
||||||
|
{ |
||||||
|
this.gitHubClient = gitHubClient; |
||||||
|
this.downloadService = downloadService; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task RunGit(string? workingDirectory = null, params string[] args) |
||||||
|
{ |
||||||
|
var process = ProcessRunner.StartAnsiProcess(GitExePath, args, |
||||||
|
workingDirectory: workingDirectory, |
||||||
|
environmentVariables: new Dictionary<string, string> |
||||||
|
{ |
||||||
|
{"PATH", Compat.GetEnvPathWithExtensions(GitBinPath)} |
||||||
|
}); |
||||||
|
|
||||||
|
await ProcessRunner.WaitForExitConditionAsync(process); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<string> GetGitOutput(string? workingDirectory = null, params string[] args) |
||||||
|
{ |
||||||
|
var process = await ProcessRunner.GetProcessOutputAsync( |
||||||
|
GitExePath, string.Join(" ", args), |
||||||
|
workingDirectory: workingDirectory, |
||||||
|
environmentVariables: new Dictionary<string, string> |
||||||
|
{ |
||||||
|
{"PATH", Compat.GetEnvPathWithExtensions(GitBinPath)} |
||||||
|
}); |
||||||
|
|
||||||
|
return process; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task InstallAllIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
await InstallVcRedistIfNecessary(progress); |
||||||
|
await UnpackResourcesIfNecessary(progress); |
||||||
|
await InstallPythonIfNecessary(progress); |
||||||
|
await InstallGitIfNecessary(progress); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task UnpackResourcesIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
// Array of (asset_uri, extract_to) |
||||||
|
var assets = new[] |
||||||
|
{ |
||||||
|
(Assets.SevenZipExecutable, AssetsDir), |
||||||
|
(Assets.SevenZipLicense, AssetsDir), |
||||||
|
}; |
||||||
|
|
||||||
|
progress?.Report(new ProgressReport(0, message: "Unpacking resources", isIndeterminate: true)); |
||||||
|
|
||||||
|
Directory.CreateDirectory(AssetsDir); |
||||||
|
foreach (var (asset, extractDir) in assets) |
||||||
|
{ |
||||||
|
await asset.ExtractToDir(extractDir); |
||||||
|
} |
||||||
|
|
||||||
|
progress?.Report(new ProgressReport(1, message: "Unpacking resources", isIndeterminate: false)); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task InstallPythonIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
if (File.Exists(PythonDllPath)) |
||||||
|
{ |
||||||
|
Logger.Debug("Python already installed at {PythonDllPath}", PythonDllPath); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
Logger.Info("Python not found at {PythonDllPath}, downloading...", PythonDllPath); |
||||||
|
|
||||||
|
Directory.CreateDirectory(AssetsDir); |
||||||
|
|
||||||
|
// Delete existing python zip if it exists |
||||||
|
if (File.Exists(PythonLibraryZipPath)) |
||||||
|
{ |
||||||
|
File.Delete(PythonLibraryZipPath); |
||||||
|
} |
||||||
|
|
||||||
|
var remote = Assets.PythonDownloadUrl; |
||||||
|
var url = remote.Url.ToString(); |
||||||
|
Logger.Info($"Downloading Python from {url} to {PythonLibraryZipPath}"); |
||||||
|
|
||||||
|
// Cleanup to remove zip if download fails |
||||||
|
try |
||||||
|
{ |
||||||
|
// Download python zip |
||||||
|
await downloadService.DownloadToFileAsync(url, PythonDownloadPath, progress: progress); |
||||||
|
|
||||||
|
// Verify python hash |
||||||
|
var downloadHash = await FileHash.GetSha256Async(PythonDownloadPath, progress); |
||||||
|
if (downloadHash != remote.HashSha256) |
||||||
|
{ |
||||||
|
var fileExists = File.Exists(PythonDownloadPath); |
||||||
|
var fileSize = new FileInfo(PythonDownloadPath).Length; |
||||||
|
var msg = $"Python download hash mismatch: {downloadHash} != {remote.HashSha256} " + |
||||||
|
$"(file exists: {fileExists}, size: {fileSize})"; |
||||||
|
throw new Exception(msg); |
||||||
|
} |
||||||
|
|
||||||
|
progress?.Report(new ProgressReport(progress: 1f, message: "Python download complete")); |
||||||
|
|
||||||
|
progress?.Report(new ProgressReport(-1, "Installing Python...", isIndeterminate: true)); |
||||||
|
|
||||||
|
// We also need 7z if it's not already unpacked |
||||||
|
if (!File.Exists(SevenZipPath)) |
||||||
|
{ |
||||||
|
await Assets.SevenZipExecutable.ExtractToDir(AssetsDir); |
||||||
|
await Assets.SevenZipLicense.ExtractToDir(AssetsDir); |
||||||
|
} |
||||||
|
|
||||||
|
// Delete existing python dir |
||||||
|
if (Directory.Exists(PythonDir)) |
||||||
|
{ |
||||||
|
Directory.Delete(PythonDir, true); |
||||||
|
} |
||||||
|
|
||||||
|
// Unzip python |
||||||
|
await ArchiveHelper.Extract7Z(PythonDownloadPath, PythonDir); |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
// Extract embedded venv folder |
||||||
|
Directory.CreateDirectory(VenvTempDir); |
||||||
|
foreach (var (resource, relativePath) in Assets.PyModuleVenv) |
||||||
|
{ |
||||||
|
var path = Path.Combine(VenvTempDir, relativePath); |
||||||
|
// Create missing directories |
||||||
|
var dir = Path.GetDirectoryName(path); |
||||||
|
if (dir != null) |
||||||
|
{ |
||||||
|
Directory.CreateDirectory(dir); |
||||||
|
} |
||||||
|
|
||||||
|
await resource.ExtractTo(path); |
||||||
|
} |
||||||
|
// Add venv to python's library zip |
||||||
|
|
||||||
|
await ArchiveHelper.AddToArchive7Z(PythonLibraryZipPath, VenvTempDir); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
// Remove venv |
||||||
|
if (Directory.Exists(VenvTempDir)) |
||||||
|
{ |
||||||
|
Directory.Delete(VenvTempDir, true); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Extract get-pip.pyc |
||||||
|
await Assets.PyScriptGetPip.ExtractToDir(PythonDir); |
||||||
|
|
||||||
|
// We need to uncomment the #import site line in python310._pth for pip to work |
||||||
|
var pythonPthPath = Path.Combine(PythonDir, "python310._pth"); |
||||||
|
var pythonPthContent = await File.ReadAllTextAsync(pythonPthPath); |
||||||
|
pythonPthContent = pythonPthContent.Replace("#import site", "import site"); |
||||||
|
await File.WriteAllTextAsync(pythonPthPath, pythonPthContent); |
||||||
|
|
||||||
|
progress?.Report(new ProgressReport(1f, "Python install complete")); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
// Always delete zip after download |
||||||
|
if (File.Exists(PythonDownloadPath)) |
||||||
|
{ |
||||||
|
File.Delete(PythonDownloadPath); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public async Task InstallGitIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
if (File.Exists(GitExePath)) |
||||||
|
{ |
||||||
|
Logger.Debug("Git already installed at {GitExePath}", GitExePath); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
Logger.Info("Git not found at {GitExePath}, downloading...", GitExePath); |
||||||
|
|
||||||
|
var portableGitUrl = |
||||||
|
"https://github.com/git-for-windows/git/releases/download/v2.41.0.windows.1/PortableGit-2.41.0-64-bit.7z.exe"; |
||||||
|
|
||||||
|
if (!File.Exists(PortableGitDownloadPath)) |
||||||
|
{ |
||||||
|
await downloadService.DownloadToFileAsync(portableGitUrl, PortableGitDownloadPath, progress: progress); |
||||||
|
progress?.Report(new ProgressReport(progress: 1f, message: "Git download complete")); |
||||||
|
} |
||||||
|
|
||||||
|
await UnzipGit(progress); |
||||||
|
} |
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")] |
||||||
|
public async Task InstallVcRedistIfNecessary(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
var registry = Registry.LocalMachine; |
||||||
|
var key = registry.OpenSubKey( |
||||||
|
@"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64", false); |
||||||
|
if (key != null) |
||||||
|
{ |
||||||
|
var buildId = Convert.ToUInt32(key.GetValue("Bld")); |
||||||
|
if (buildId >= 30139) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Logger.Info("Downloading VC Redist"); |
||||||
|
|
||||||
|
await downloadService.DownloadToFileAsync(VcRedistDownloadUrl, VcRedistDownloadPath, progress: progress); |
||||||
|
progress?.Report(new ProgressReport(progress: 1f, message: "Visual C++ download complete", |
||||||
|
type: ProgressType.Download)); |
||||||
|
|
||||||
|
Logger.Info("Installing VC Redist"); |
||||||
|
progress?.Report(new ProgressReport(progress: 0.5f, isIndeterminate: true, type: ProgressType.Generic, message: "Installing prerequisites...")); |
||||||
|
var process = ProcessRunner.StartAnsiProcess(VcRedistDownloadPath, "/install /quiet /norestart"); |
||||||
|
await process.WaitForExitAsync(); |
||||||
|
progress?.Report(new ProgressReport(progress: 1f, message: "Visual C++ install complete", |
||||||
|
type: ProgressType.Generic)); |
||||||
|
|
||||||
|
File.Delete(VcRedistDownloadPath); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task UnzipGit(IProgress<ProgressReport>? progress = null) |
||||||
|
{ |
||||||
|
if (progress == null) |
||||||
|
{ |
||||||
|
await ArchiveHelper.Extract7Z(PortableGitDownloadPath, PortableGitInstallDir); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
await ArchiveHelper.Extract7Z(PortableGitDownloadPath, PortableGitInstallDir, progress); |
||||||
|
} |
||||||
|
|
||||||
|
Logger.Info("Extracted Git"); |
||||||
|
|
||||||
|
File.Delete(PortableGitDownloadPath); |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,66 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.IO; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia.Platform; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models.FileInterfaces; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Models; |
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
public readonly record struct AvaloniaResource( |
||||||
|
Uri UriPath, |
||||||
|
UnixFileMode WriteUnixFileMode = UnixFileMode.None) |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// File name component of the Uri path. |
||||||
|
/// </summary> |
||||||
|
public string FileName => Path.GetFileName(UriPath.ToString()); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// File path relative to the 'Assets' folder. |
||||||
|
/// </summary> |
||||||
|
public Uri RelativeAssetPath => |
||||||
|
new Uri("avares://StabilityMatrix.Avalonia/Assets/").MakeRelativeUri(UriPath); |
||||||
|
|
||||||
|
public AvaloniaResource(string uriPath, UnixFileMode writeUnixFileMode = UnixFileMode.None) |
||||||
|
: this(new Uri(uriPath), writeUnixFileMode) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Opens a stream to this resource. |
||||||
|
/// </summary> |
||||||
|
public Stream Open() => AssetLoader.Open(UriPath); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Extracts this resource to a target file path. |
||||||
|
/// </summary> |
||||||
|
public async Task ExtractTo(FilePath outputPath, bool overwrite = true) |
||||||
|
{ |
||||||
|
if (outputPath.Exists) |
||||||
|
{ |
||||||
|
// Skip if not overwriting |
||||||
|
if (!overwrite) return; |
||||||
|
// Otherwise delete the file |
||||||
|
outputPath.Delete(); |
||||||
|
} |
||||||
|
var stream = AssetLoader.Open(UriPath); |
||||||
|
await using var fileStream = File.Create(outputPath); |
||||||
|
await stream.CopyToAsync(fileStream); |
||||||
|
// Write permissions |
||||||
|
if (!Compat.IsWindows && Compat.IsUnix && WriteUnixFileMode != UnixFileMode.None) |
||||||
|
{ |
||||||
|
File.SetUnixFileMode(outputPath, WriteUnixFileMode); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Extracts this resource to the output directory. |
||||||
|
/// </summary> |
||||||
|
public Task ExtractToDir(DirectoryPath outputDir, bool overwrite = true) |
||||||
|
{ |
||||||
|
return ExtractTo(outputDir.JoinFile(FileName), overwrite); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,125 @@ |
|||||||
|
using System.Collections; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.Specialized; |
||||||
|
using System.ComponentModel; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Models; |
||||||
|
|
||||||
|
public class ObservableDictionary<TKey, TValue> : IDictionary<TKey, TValue>, |
||||||
|
INotifyCollectionChanged, INotifyPropertyChanged where TKey : notnull |
||||||
|
{ |
||||||
|
private readonly IDictionary<TKey, TValue> dictionary; |
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged; |
||||||
|
public event PropertyChangedEventHandler? PropertyChanged; |
||||||
|
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() => dictionary.GetEnumerator(); |
||||||
|
|
||||||
|
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); |
||||||
|
|
||||||
|
public ObservableDictionary() |
||||||
|
{ |
||||||
|
dictionary = new Dictionary<TKey, TValue>(); |
||||||
|
} |
||||||
|
|
||||||
|
public ObservableDictionary(Dictionary<TKey, TValue> dictionary) |
||||||
|
{ |
||||||
|
this.dictionary = dictionary; |
||||||
|
} |
||||||
|
|
||||||
|
public void Add(KeyValuePair<TKey, TValue> item) |
||||||
|
{ |
||||||
|
dictionary.Add(item); |
||||||
|
CollectionChanged?.Invoke(this, |
||||||
|
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item)); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values))); |
||||||
|
} |
||||||
|
|
||||||
|
public void Clear() |
||||||
|
{ |
||||||
|
dictionary.Clear(); |
||||||
|
CollectionChanged?.Invoke(this, |
||||||
|
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values))); |
||||||
|
} |
||||||
|
|
||||||
|
public bool Contains(KeyValuePair<TKey, TValue> item) => dictionary.Contains(item); |
||||||
|
|
||||||
|
public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex) |
||||||
|
{ |
||||||
|
dictionary.CopyTo(array, arrayIndex); |
||||||
|
} |
||||||
|
|
||||||
|
public bool Remove(KeyValuePair<TKey, TValue> item) |
||||||
|
{ |
||||||
|
var success = dictionary.Remove(item); |
||||||
|
if (!success) return false; |
||||||
|
|
||||||
|
CollectionChanged?.Invoke(this, |
||||||
|
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item)); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values))); |
||||||
|
|
||||||
|
return success; |
||||||
|
} |
||||||
|
|
||||||
|
public int Count => dictionary.Count; |
||||||
|
public bool IsReadOnly => dictionary.IsReadOnly; |
||||||
|
|
||||||
|
public void Add(TKey key, TValue value) |
||||||
|
{ |
||||||
|
dictionary.Add(key, value); |
||||||
|
CollectionChanged?.Invoke(this, |
||||||
|
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, |
||||||
|
new KeyValuePair<TKey, TValue>(key, value))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values))); |
||||||
|
} |
||||||
|
|
||||||
|
public bool ContainsKey(TKey key) => dictionary.ContainsKey(key); |
||||||
|
|
||||||
|
public bool Remove(TKey key) |
||||||
|
{ |
||||||
|
var success = dictionary.TryGetValue(key, out var value) && dictionary.Remove(key); |
||||||
|
if (!success) return false; |
||||||
|
|
||||||
|
CollectionChanged?.Invoke(this, |
||||||
|
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, |
||||||
|
new KeyValuePair<TKey, TValue>(key, value!))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values))); |
||||||
|
|
||||||
|
return success; |
||||||
|
} |
||||||
|
|
||||||
|
public bool TryGetValue([NotNull] TKey key, [MaybeNullWhen(false)] out TValue value) |
||||||
|
=> dictionary.TryGetValue(key, out value); |
||||||
|
|
||||||
|
public TValue this[TKey key] |
||||||
|
{ |
||||||
|
get => dictionary[key]; |
||||||
|
set |
||||||
|
{ |
||||||
|
var exists = dictionary.ContainsKey(key); |
||||||
|
var action = exists |
||||||
|
? NotifyCollectionChangedAction.Replace |
||||||
|
: NotifyCollectionChangedAction.Add; |
||||||
|
dictionary[key] = value; |
||||||
|
CollectionChanged?.Invoke(this, |
||||||
|
new NotifyCollectionChangedEventArgs(action, dictionary[key])); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys))); |
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values))); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public ICollection<TKey> Keys => dictionary.Keys; |
||||||
|
public ICollection<TValue> Values => dictionary.Values; |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
using System; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Models; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines a remote downloadable resource. |
||||||
|
/// </summary> |
||||||
|
public readonly record struct RemoteResource |
||||||
|
{ |
||||||
|
public Uri Url { get; } |
||||||
|
|
||||||
|
public Uri[]? FallbackUrls { get; } |
||||||
|
|
||||||
|
public string HashSha256 { get; } |
||||||
|
|
||||||
|
public RemoteResource(Uri url, string hashSha256) |
||||||
|
{ |
||||||
|
Url = url; |
||||||
|
HashSha256 = hashSha256; |
||||||
|
} |
||||||
|
|
||||||
|
public RemoteResource(Uri[] urls, string hashSha256) |
||||||
|
{ |
||||||
|
if (urls.Length == 0) |
||||||
|
{ |
||||||
|
throw new ArgumentException("Must have at least one url.", nameof(urls)); |
||||||
|
} |
||||||
|
|
||||||
|
Url = urls[0]; |
||||||
|
FallbackUrls = urls.Length > 1 ? urls[1..] : null; |
||||||
|
HashSha256 = hashSha256; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,14 @@ |
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Models; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Singleton DI service for observable shared UI state. |
||||||
|
/// </summary> |
||||||
|
public partial class SharedState : ObservableObject |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Whether debug mode enabled from settings page version tap. |
||||||
|
/// </summary> |
||||||
|
[ObservableProperty] private bool isDebugMode; |
||||||
|
} |
@ -0,0 +1,198 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.Linq; |
||||||
|
using System.Reflection; |
||||||
|
using System.Runtime.InteropServices; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.ApplicationLifetimes; |
||||||
|
using Avalonia.Threading; |
||||||
|
using NLog; |
||||||
|
using Polly.Contrib.WaitAndRetry; |
||||||
|
using Projektanker.Icons.Avalonia; |
||||||
|
using Projektanker.Icons.Avalonia.FontAwesome; |
||||||
|
using Semver; |
||||||
|
using Sentry; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Updater; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia; |
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] |
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
public class Program |
||||||
|
{ |
||||||
|
private static bool isExceptionDialogEnabled; |
||||||
|
|
||||||
|
// 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. |
||||||
|
[STAThread] |
||||||
|
public static void Main(string[] args) |
||||||
|
{ |
||||||
|
HandleUpdateReplacement(); |
||||||
|
|
||||||
|
var infoVersion = Assembly.GetExecutingAssembly() |
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion; |
||||||
|
Compat.AppVersion = SemVersion.Parse(infoVersion ?? "0.0.0", SemVersionStyles.Strict); |
||||||
|
|
||||||
|
// Configure exception dialog for unhandled exceptions |
||||||
|
if (!Debugger.IsAttached || args.Contains("--debug-exception-dialog")) |
||||||
|
{ |
||||||
|
isExceptionDialogEnabled = true; |
||||||
|
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; |
||||||
|
} |
||||||
|
|
||||||
|
// Configure Sentry |
||||||
|
if ((Debugger.IsAttached && args.Contains("--debug-sentry")) || !args.Contains("--no-sentry")) |
||||||
|
{ |
||||||
|
ConfigureSentry(); |
||||||
|
} |
||||||
|
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); |
||||||
|
} |
||||||
|
|
||||||
|
// Avalonia configuration, don't remove; also used by visual designer. |
||||||
|
public static AppBuilder BuildAvaloniaApp() |
||||||
|
{ |
||||||
|
IconProvider.Current.Register<FontAwesomeIconProvider>(); |
||||||
|
|
||||||
|
return AppBuilder.Configure<App>() |
||||||
|
.UsePlatformDetect() |
||||||
|
.WithInterFont() |
||||||
|
.LogToTrace(); |
||||||
|
} |
||||||
|
|
||||||
|
private static void HandleUpdateReplacement() |
||||||
|
{ |
||||||
|
// Check if we're in the named update folder |
||||||
|
if (Compat.AppCurrentDir.Parent is {Name: UpdateHelper.UpdateFolderName} parentDir) |
||||||
|
{ |
||||||
|
var retryDelays = Backoff.DecorrelatedJitterBackoffV2( |
||||||
|
TimeSpan.FromMilliseconds(350), retryCount: 5); |
||||||
|
|
||||||
|
foreach (var delay in retryDelays) |
||||||
|
{ |
||||||
|
// Copy our current file to the parent directory, overwriting the old app file |
||||||
|
var currentExe = Compat.AppCurrentDir.JoinFile(Compat.GetExecutableName()); |
||||||
|
var targetExe = parentDir.JoinFile(Compat.GetExecutableName()); |
||||||
|
try |
||||||
|
{ |
||||||
|
currentExe.CopyTo(targetExe, true); |
||||||
|
|
||||||
|
// Start the new app |
||||||
|
Process.Start(targetExe); |
||||||
|
|
||||||
|
// Shutdown the current app |
||||||
|
Environment.Exit(0); |
||||||
|
} |
||||||
|
catch (Exception) |
||||||
|
{ |
||||||
|
Thread.Sleep(delay); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Delete update folder if it exists in current directory |
||||||
|
var updateDir = UpdateHelper.UpdateFolder; |
||||||
|
if (updateDir.Exists) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
updateDir.Delete(true); |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
var logger = LogManager.GetCurrentClassLogger(); |
||||||
|
logger.Error(e, "Failed to delete update file"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static void ConfigureSentry() |
||||||
|
{ |
||||||
|
SentrySdk.Init(o => |
||||||
|
{ |
||||||
|
o.Dsn = "https://eac7a5ea065d44cf9a8565e0f1817da2@o4505314753380352.ingest.sentry.io/4505314756067328"; |
||||||
|
o.StackTraceMode = StackTraceMode.Enhanced; |
||||||
|
o.TracesSampleRate = 1.0; |
||||||
|
o.IsGlobalModeEnabled = true; |
||||||
|
// Enables Sentry's "Release Health" feature. |
||||||
|
o.AutoSessionTracking = true; |
||||||
|
// 1.0 to capture 100% of transactions for performance monitoring. |
||||||
|
o.TracesSampleRate = 1.0; |
||||||
|
#if DEBUG |
||||||
|
o.Environment = "Development"; |
||||||
|
#endif |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) |
||||||
|
{ |
||||||
|
if (e.ExceptionObject is not Exception ex) return; |
||||||
|
|
||||||
|
var logger = LogManager.GetCurrentClassLogger(); |
||||||
|
logger.Fatal(ex, "Unhandled {Type}: {Message}", ex.GetType().Name, ex.Message); |
||||||
|
|
||||||
|
if (SentrySdk.IsEnabled) |
||||||
|
{ |
||||||
|
SentrySdk.CaptureException(ex); |
||||||
|
} |
||||||
|
|
||||||
|
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) |
||||||
|
{ |
||||||
|
var dialog = new ExceptionDialog |
||||||
|
{ |
||||||
|
DataContext = new ExceptionViewModel |
||||||
|
{ |
||||||
|
Exception = ex |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
var mainWindow = lifetime.MainWindow; |
||||||
|
// We can only show dialog if main window exists, and is visible |
||||||
|
if (mainWindow is {PlatformImpl: not null, IsVisible: true}) |
||||||
|
{ |
||||||
|
// Configure for dialog mode |
||||||
|
dialog.ShowAsDialog = true; |
||||||
|
dialog.WindowStartupLocation = WindowStartupLocation.CenterOwner; |
||||||
|
|
||||||
|
// Show synchronously without blocking UI thread |
||||||
|
// https://github.com/AvaloniaUI/Avalonia/issues/4810#issuecomment-704259221 |
||||||
|
var cts = new CancellationTokenSource(); |
||||||
|
|
||||||
|
dialog.ShowDialog(mainWindow).ContinueWith(_ => |
||||||
|
{ |
||||||
|
cts.Cancel(); |
||||||
|
ExitWithException(ex); |
||||||
|
}, TaskScheduler.FromCurrentSynchronizationContext()); |
||||||
|
|
||||||
|
Dispatcher.UIThread.MainLoop(cts.Token); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
// No parent window available |
||||||
|
var cts = new CancellationTokenSource(); |
||||||
|
// Exit on token cancellation |
||||||
|
cts.Token.Register(() => ExitWithException(ex)); |
||||||
|
|
||||||
|
dialog.ShowWithCts(cts); |
||||||
|
|
||||||
|
Dispatcher.UIThread.MainLoop(cts.Token); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[DoesNotReturn] |
||||||
|
private static void ExitWithException(Exception exception) |
||||||
|
{ |
||||||
|
App.Shutdown(1); |
||||||
|
Dispatcher.UIThread.InvokeShutdown(); |
||||||
|
Environment.Exit(Marshal.GetHRForException(exception)); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,46 @@ |
|||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Services; |
||||||
|
|
||||||
|
public interface INotificationService |
||||||
|
{ |
||||||
|
public void Initialize( |
||||||
|
Visual? visual, |
||||||
|
NotificationPosition position = NotificationPosition.BottomRight, |
||||||
|
int maxItems = 3); |
||||||
|
|
||||||
|
public void Show(INotification notification); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Attempt to run the given task, showing a generic error notification if it fails. |
||||||
|
/// </summary> |
||||||
|
/// <param name="task">The task to run.</param> |
||||||
|
/// <param name="title">The title to show in the notification.</param> |
||||||
|
/// <param name="message">The message to show, default to exception.Message</param> |
||||||
|
/// <param name="appearance">The appearance of the notification.</param> |
||||||
|
Task<TaskResult<T>> TryAsync<T>( |
||||||
|
Task<T> task, |
||||||
|
string title = "Error", |
||||||
|
string? message = null, |
||||||
|
NotificationType appearance = NotificationType.Error); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Attempt to run the given void task, showing a generic error notification if it fails. |
||||||
|
/// Return a TaskResult with true if the task succeeded, false if it failed. |
||||||
|
/// </summary> |
||||||
|
/// <param name="task">The task to run.</param> |
||||||
|
/// <param name="title">The title to show in the notification.</param> |
||||||
|
/// <param name="message">The message to show, default to exception.Message</param> |
||||||
|
/// <param name="appearance">The appearance of the notification.</param> |
||||||
|
Task<TaskResult<bool>> TryAsync( |
||||||
|
Task task, |
||||||
|
string title = "Error", |
||||||
|
string? message = null, |
||||||
|
NotificationType appearance = NotificationType.Error); |
||||||
|
|
||||||
|
void Show(string title, string message, |
||||||
|
NotificationType appearance = NotificationType.Information); |
||||||
|
} |
@ -0,0 +1,74 @@ |
|||||||
|
using System; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Services; |
||||||
|
|
||||||
|
public class NotificationService : INotificationService |
||||||
|
{ |
||||||
|
private WindowNotificationManager? notificationManager; |
||||||
|
|
||||||
|
public void Initialize( |
||||||
|
Visual? visual, |
||||||
|
NotificationPosition position = NotificationPosition.BottomRight, |
||||||
|
int maxItems = 3) |
||||||
|
{ |
||||||
|
if (notificationManager is not null) return; |
||||||
|
notificationManager = new WindowNotificationManager(TopLevel.GetTopLevel(visual)) |
||||||
|
{ |
||||||
|
Position = position, |
||||||
|
MaxItems = maxItems |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
public void Show(INotification notification) |
||||||
|
{ |
||||||
|
notificationManager?.Show(notification); |
||||||
|
} |
||||||
|
|
||||||
|
public void Show(string title, string message, |
||||||
|
NotificationType appearance = NotificationType.Information) |
||||||
|
{ |
||||||
|
Show(new Notification(title, message, appearance)); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public async Task<TaskResult<T>> TryAsync<T>( |
||||||
|
Task<T> task, |
||||||
|
string title = "Error", |
||||||
|
string? message = null, |
||||||
|
NotificationType appearance = NotificationType.Error) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
return new TaskResult<T>(await task); |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
Show(new Notification(title, message ?? e.Message, appearance)); |
||||||
|
return TaskResult<T>.FromException(e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public async Task<TaskResult<bool>> TryAsync( |
||||||
|
Task task, |
||||||
|
string title = "Error", |
||||||
|
string? message = null, |
||||||
|
NotificationType appearance = NotificationType.Error) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
await task; |
||||||
|
return new TaskResult<bool>(true); |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
Show(new Notification(title, message ?? e.Message, appearance)); |
||||||
|
return new TaskResult<bool>(false, e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,132 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using Microsoft.Extensions.DependencyInjection; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Services; |
||||||
|
|
||||||
|
public class ServiceManager<T> |
||||||
|
{ |
||||||
|
// Holds providers |
||||||
|
private readonly Dictionary<Type, Func<T>> providers = new(); |
||||||
|
|
||||||
|
// Holds singleton instances |
||||||
|
private readonly Dictionary<Type, T> instances = new(); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Register a new dialog view model (singleton instance) |
||||||
|
/// </summary> |
||||||
|
public ServiceManager<T> Register<TService>(TService instance) where TService : T |
||||||
|
{ |
||||||
|
lock (instances) |
||||||
|
{ |
||||||
|
if (instances.ContainsKey(typeof(TService)) || providers.ContainsKey(typeof(TService))) |
||||||
|
{ |
||||||
|
throw new ArgumentException( |
||||||
|
$"Service of type {typeof(TService)} is already registered for {typeof(T)}"); |
||||||
|
} |
||||||
|
|
||||||
|
instances[typeof(TService)] = instance; |
||||||
|
} |
||||||
|
|
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Register a new dialog view model provider action (called on each dialog creation) |
||||||
|
/// </summary> |
||||||
|
public ServiceManager<T> Register<TService>(Func<TService> provider) where TService : T |
||||||
|
{ |
||||||
|
lock (providers) |
||||||
|
{ |
||||||
|
if (instances.ContainsKey(typeof(TService)) || providers.ContainsKey(typeof(TService))) |
||||||
|
{ |
||||||
|
throw new ArgumentException( |
||||||
|
$"Service of type {typeof(TService)} is already registered for {typeof(T)}"); |
||||||
|
} |
||||||
|
|
||||||
|
// Return type is wrong during build with method group syntax |
||||||
|
// ReSharper disable once RedundantCast |
||||||
|
providers[typeof(TService)] = () => (TService) provider(); |
||||||
|
} |
||||||
|
|
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Register a new dialog view model instance using a service provider |
||||||
|
/// Equal to Register[TService](serviceProvider.GetRequiredService[TService]) |
||||||
|
/// </summary> |
||||||
|
public ServiceManager<T> RegisterProvider<TService>(IServiceProvider provider) where TService : notnull, T |
||||||
|
{ |
||||||
|
lock (providers) |
||||||
|
{ |
||||||
|
if (instances.ContainsKey(typeof(TService)) || providers.ContainsKey(typeof(TService))) |
||||||
|
{ |
||||||
|
throw new ArgumentException( |
||||||
|
$"Service of type {typeof(TService)} is already registered for {typeof(T)}"); |
||||||
|
} |
||||||
|
|
||||||
|
// Return type is wrong during build with method group syntax |
||||||
|
// ReSharper disable once RedundantCast |
||||||
|
providers[typeof(TService)] = () => (TService) provider.GetRequiredService<TService>(); |
||||||
|
} |
||||||
|
|
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Get a view model instance |
||||||
|
/// </summary> |
||||||
|
[SuppressMessage("ReSharper", "InconsistentlySynchronizedField")] |
||||||
|
public TService Get<TService>() where TService : T |
||||||
|
{ |
||||||
|
if (instances.TryGetValue(typeof(TService), out var instance)) |
||||||
|
{ |
||||||
|
if (instance is null) |
||||||
|
{ |
||||||
|
throw new ArgumentException( |
||||||
|
$"Service of type {typeof(TService)} was registered as null"); |
||||||
|
} |
||||||
|
return (TService) instance; |
||||||
|
} |
||||||
|
|
||||||
|
if (providers.TryGetValue(typeof(TService), out var provider)) |
||||||
|
{ |
||||||
|
if (provider is null) |
||||||
|
{ |
||||||
|
throw new ArgumentException( |
||||||
|
$"Service of type {typeof(TService)} was registered as null"); |
||||||
|
} |
||||||
|
var result = provider(); |
||||||
|
if (result is null) |
||||||
|
{ |
||||||
|
throw new ArgumentException( |
||||||
|
$"Service provider for type {typeof(TService)} returned null"); |
||||||
|
} |
||||||
|
return (TService) result; |
||||||
|
} |
||||||
|
|
||||||
|
throw new ArgumentException( |
||||||
|
$"Service of type {typeof(TService)} is not registered for {typeof(T)}"); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Get a view model instance with an initializer parameter |
||||||
|
/// </summary> |
||||||
|
public TService Get<TService>(Func<TService, TService> initializer) where TService : T |
||||||
|
{ |
||||||
|
var instance = Get<TService>(); |
||||||
|
return initializer(instance); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Get a view model instance with an initializer for a mutable instance |
||||||
|
/// </summary> |
||||||
|
public TService Get<TService>(Action<TService> initializer) where TService : T |
||||||
|
{ |
||||||
|
var instance = Get<TService>(); |
||||||
|
initializer(instance); |
||||||
|
return instance; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,79 @@ |
|||||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||||
|
<PropertyGroup> |
||||||
|
<OutputType>WinExe</OutputType> |
||||||
|
<TargetFramework>net7.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> |
||||||
|
<TrimMode>partial</TrimMode> |
||||||
|
<PublishTrimmed>true</PublishTrimmed> |
||||||
|
<ApplicationIcon>./Assets/Icon.ico</ApplicationIcon> |
||||||
|
<Version>2.0.0-dev.1</Version> |
||||||
|
<InformationalVersion>$(Version)</InformationalVersion> |
||||||
|
</PropertyGroup> |
||||||
|
|
||||||
|
<ItemGroup> |
||||||
|
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.0.0-avalonia11-preview6" /> |
||||||
|
<PackageReference Include="Avalonia" Version="11.0.0" /> |
||||||
|
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.0.0" /> |
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.0.0" /> |
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.0" /> |
||||||
|
<!--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.0" /> |
||||||
|
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.0.1" /> |
||||||
|
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.0" /> |
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" /> |
||||||
|
<PackageReference Include="FluentAvaloniaUI" Version="2.0.0" /> |
||||||
|
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.207" /> |
||||||
|
<PackageReference Include="FluentIcons.FluentAvalonia" Version="1.1.207" /> |
||||||
|
<PackageReference Include="FuzzySharp" Version="2.0.2" /> |
||||||
|
<PackageReference Include="Markdown.Avalonia" Version="11.0.0" /> |
||||||
|
<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.8" /> |
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" /> |
||||||
|
<PackageReference Include="Nito.AsyncEx" Version="5.1.2" /> |
||||||
|
<PackageReference Include="NLog" Version="5.2.2" /> |
||||||
|
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.2" /> |
||||||
|
<PackageReference Include="Polly" Version="7.2.4" /> |
||||||
|
<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="6.6.0" /> |
||||||
|
<PackageReference Include="Sentry" Version="3.33.1" /> |
||||||
|
<PackageReference Include="Sentry.NLog" Version="3.33.1" /> |
||||||
|
<PackageReference Include="TextMateSharp.Grammars" Version="1.0.55" /> |
||||||
|
</ItemGroup> |
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup> |
||||||
|
<AvaloniaResource Include="Assets\Icon.ico" /> |
||||||
|
<AvaloniaResource Include="Assets\Icon.png" /> |
||||||
|
</ItemGroup> |
||||||
|
|
||||||
|
<ItemGroup> |
||||||
|
<ProjectReference Include="..\StabilityMatrix.Core\StabilityMatrix.Core.csproj" /> |
||||||
|
</ItemGroup> |
||||||
|
|
||||||
|
<ItemGroup> |
||||||
|
<AvaloniaResource Include="Assets\noimage.png" /> |
||||||
|
</ItemGroup> |
||||||
|
|
||||||
|
<ItemGroup> |
||||||
|
<AvaloniaResource Include="Assets\licenses.json" /> |
||||||
|
</ItemGroup> |
||||||
|
|
||||||
|
<ItemGroup> |
||||||
|
<AvaloniaResource Include="Assets\sitecustomize.py" /> |
||||||
|
</ItemGroup> |
||||||
|
|
||||||
|
<ItemGroup> |
||||||
|
<!-- Only for win-64 --> |
||||||
|
<AvaloniaResource Include="Assets\win-x64\**" Condition="'$(RuntimeIdentifier)' == 'win-x64'" /> |
||||||
|
<!-- Only for linux-64 --> |
||||||
|
<AvaloniaResource Include="Assets\linux-x64\**" Condition="'$(RuntimeIdentifier)' == 'linux-x64'" /> |
||||||
|
</ItemGroup> |
||||||
|
</Project> |
@ -0,0 +1,4 @@ |
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:s="clr-namespace:System;assembly=mscorlib" |
||||||
|
xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> |
||||||
|
<s:String x:Key="/Default/CodeInspection/Daemon/ConfigureAwaitAnalysisMode/@EntryValue">UI</s:String></wpf:ResourceDictionary> |
@ -0,0 +1,305 @@ |
|||||||
|
<Styles xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<Border Padding="20"> |
||||||
|
<StackPanel> |
||||||
|
<Button Classes="success" Content="Success Button" Margin="8" HorizontalAlignment="Center" /> |
||||||
|
<Button Classes="accent" Content="FA Accent Button" Margin="8" HorizontalAlignment="Center" /> |
||||||
|
<Button Classes="systemaccent" Content="System Accent Button" Margin="8" HorizontalAlignment="Center" /> |
||||||
|
<Button Classes="danger" Content="Danger Button" Margin="8" HorizontalAlignment="Center" /> |
||||||
|
<Button Classes="info" Content="Info Button" Margin="8" HorizontalAlignment="Center" /> |
||||||
|
<Button Classes="transparent-info" Content="Semi-Transparent Info Button" Margin="8" HorizontalAlignment="Center" /> |
||||||
|
<Button Classes="transparent" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" /> |
||||||
|
<Button Content="Disabled Button" Margin="8" IsEnabled="False" HorizontalAlignment="Center" /> |
||||||
|
</StackPanel> |
||||||
|
</Border> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<!-- Success --> |
||||||
|
<Style Selector="Button.success"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeGreenColor}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeGreenColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" /> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pointerover"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeDarkGreenColor}"/> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkGreenColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pressed"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeDarkDarkGreenColor}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkDarkGreenColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:disabled"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<!-- Danger --> |
||||||
|
<Style Selector="Button.danger"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeRedColor}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeRedColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" /> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pointerover"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeDarkRedColor}"/> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkRedColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pressed"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeDarkDarkRedColor}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkDarkRedColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:disabled"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<!-- Info --> |
||||||
|
<Style Selector="Button.info"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeLightBlueColor}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeLightBlueColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" /> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pointerover"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeBlueColor}"/> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeBlueColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pressed"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColor}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:disabled"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<!--Accent Button--> |
||||||
|
<Style Selector="Button.accent"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentButtonBackground}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrush}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForeground}" /> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pointerover"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundPointerOver}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushPointerOver}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundPointerOver}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pressed"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundPressed}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushPressed}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundPressed}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:disabled"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundDisabled}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushDisabled}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundDisabled}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<!-- SystemAccent --> |
||||||
|
<Style Selector="Button.systemaccent"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" /> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pointerover"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource SystemAccentColorDark1}"/> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorDark1}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pressed"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource SystemAccentColorDark2}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorDark2}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:disabled"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<!-- Transparent --> |
||||||
|
<Style Selector="Button.transparent"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource SystemControlTransparentBrush}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrush}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" /> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pointerover"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pressed"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:disabled"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<!-- Semi-Transparent Info --> |
||||||
|
<Style Selector="Button.transparent-info"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeLightBlueColorTransparent}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeLightBlueColorTransparent}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" /> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pointerover"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeBlueColorTransparent}"/> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeBlueColorTransparent}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:pressed"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColorTransparent}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColorTransparent}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="^:disabled"> |
||||||
|
<Style Selector="^ /template/ ui|FABorder#Root"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
</Styles> |
@ -0,0 +1,93 @@ |
|||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<Border Padding="20"> |
||||||
|
<StackPanel> |
||||||
|
<controls:Card |
||||||
|
Margin="8" |
||||||
|
MaxHeight="450" |
||||||
|
Name="ModelCard" |
||||||
|
Width="330"> |
||||||
|
<TextBlock Text="Normal Card" /> |
||||||
|
</controls:Card> |
||||||
|
<controls:Card |
||||||
|
Margin="8" |
||||||
|
MaxHeight="450" |
||||||
|
Classes="success" |
||||||
|
Width="330"> |
||||||
|
<TextBlock Text="Success Card" /> |
||||||
|
</controls:Card> |
||||||
|
<controls:Card |
||||||
|
Margin="8" |
||||||
|
MaxHeight="450" |
||||||
|
Classes="info" |
||||||
|
Width="330"> |
||||||
|
<TextBlock Text="Info Card" /> |
||||||
|
</controls:Card> |
||||||
|
</StackPanel> |
||||||
|
</Border> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<ControlTheme x:Key="{x:Type controls:Card}" TargetType="controls:Card"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ComboBoxBackground}"/> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ComboBoxBorderBrush}" /> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComboBoxForeground}" /> |
||||||
|
<Setter Property="BorderThickness" Value="1" /> |
||||||
|
<Setter Property="Padding" Value="14,16,14,16" /> |
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" /> |
||||||
|
<Setter Property="VerticalAlignment" Value="Center" /> |
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" /> |
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" /> |
||||||
|
<Setter Property="FontSize" Value="{DynamicResource ControlContentThemeFontSize}" /> |
||||||
|
<Setter Property="FontWeight" Value="Normal" /> |
||||||
|
<Setter Property="Border.CornerRadius" Value="{DynamicResource ControlCornerRadius}" /> |
||||||
|
<Setter Property="Focusable" Value="False" /> |
||||||
|
<Setter Property="KeyboardNavigation.IsTabStop" Value="False" /> |
||||||
|
<Setter Property="Template"> |
||||||
|
<Setter.Value> |
||||||
|
<ControlTemplate> |
||||||
|
<Grid> |
||||||
|
<Border |
||||||
|
x:Name="ContentBorder" |
||||||
|
Padding="{TemplateBinding Padding}" |
||||||
|
HorizontalAlignment="{TemplateBinding HorizontalAlignment}" |
||||||
|
VerticalAlignment="{TemplateBinding VerticalAlignment}" |
||||||
|
Background="{TemplateBinding Background}" |
||||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||||
|
CornerRadius="{TemplateBinding Border.CornerRadius}"> |
||||||
|
<ContentPresenter |
||||||
|
x:Name="ContentPresenter" |
||||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" |
||||||
|
Content="{TemplateBinding Content}" /> |
||||||
|
</Border> |
||||||
|
</Grid> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter.Value> |
||||||
|
</Setter> |
||||||
|
|
||||||
|
<Styles> |
||||||
|
|
||||||
|
<!-- Success --> |
||||||
|
<Style Selector="controls|Card.success"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeGreenColorTransparent}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeGreenColorTransparent}" /> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<!-- Info --> |
||||||
|
<Style Selector="controls|Card.info"> |
||||||
|
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColorTransparent}" /> |
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColorTransparent}" /> |
||||||
|
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" /> |
||||||
|
</Style> |
||||||
|
</Style> |
||||||
|
|
||||||
|
</Styles> |
||||||
|
</ControlTheme> |
||||||
|
</ResourceDictionary> |
@ -0,0 +1,113 @@ |
|||||||
|
<Styles xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
|
x:CompileBindings="True"> |
||||||
|
|
||||||
|
<Design.PreviewWith> |
||||||
|
<StackPanel> |
||||||
|
<Border Padding="20"> |
||||||
|
<controls:ProgressRing StartAngle="90" EndAngle="270" Value="50" Foreground="Red" BorderThickness="5" Width="100" Height="100" /> |
||||||
|
</Border> |
||||||
|
<Border Padding="20"> |
||||||
|
<controls:ProgressRing IsIndeterminate="True" BorderThickness="10" Width="100" Height="100" /> |
||||||
|
</Border> |
||||||
|
</StackPanel> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<Styles.Resources> |
||||||
|
<converters:FitSquarelyWithinAspectRatioConverter x:Key="FitSquarelyWithinAspectRatioConverter"/> |
||||||
|
</Styles.Resources> |
||||||
|
|
||||||
|
<Style Selector="controls|ProgressRing"> |
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColor}"/> |
||||||
|
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" /> |
||||||
|
<Setter Property="BorderThickness" Value="9" /> |
||||||
|
<Setter Property="MinHeight" Value="16" /> |
||||||
|
<Setter Property="MinWidth" Value="16" /> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<Panel x:Name="FluentRingRoot"> |
||||||
|
<Ellipse x:Name="Track" |
||||||
|
Stroke="{TemplateBinding Background}" |
||||||
|
StrokeThickness="{Binding BorderThickness.Left, RelativeSource={RelativeSource Mode=TemplatedParent}}" /> |
||||||
|
<Arc x:Name="Fill" |
||||||
|
Stroke="{TemplateBinding Foreground}" |
||||||
|
StrokeThickness="{Binding BorderThickness.Left, RelativeSource={RelativeSource Mode=TemplatedParent}}" |
||||||
|
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> |
||||||
|
|
||||||
|
<Style Selector="controls|ProgressRing:preserveaspect"> |
||||||
|
<Setter Property="MinWidth" Value="32" /> |
||||||
|
<Setter Property="MinHeight" Value="32" /> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="controls|ProgressRing:preserveaspect /template/ Panel#FluentRingRoot"> |
||||||
|
<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.Animations> |
||||||
|
<Animation Duration="0:0:5" Easing="LinearEasing" IterationCount="INFINITE" FillMode="Both"> |
||||||
|
<KeyFrame Cue="0%"> |
||||||
|
<Setter Property="StartAngle" Value="-720"/> |
||||||
|
<Setter Property="SweepAngle" Value="0"/> |
||||||
|
</KeyFrame> |
||||||
|
<KeyFrame Cue="12.5%"> |
||||||
|
<Setter Property="StartAngle" Value="-540"/> |
||||||
|
<Setter Property="SweepAngle" Value="50"/> |
||||||
|
</KeyFrame> |
||||||
|
<KeyFrame Cue="25%"> |
||||||
|
<Setter Property="StartAngle" Value="-360"/> |
||||||
|
<Setter Property="SweepAngle" Value="100"/> |
||||||
|
</KeyFrame> |
||||||
|
<KeyFrame Cue="32.5%"> |
||||||
|
<Setter Property="StartAngle" Value="-180"/> |
||||||
|
<Setter Property="SweepAngle" Value="50"/> |
||||||
|
</KeyFrame> |
||||||
|
<KeyFrame Cue="50%"> |
||||||
|
<Setter Property="StartAngle" Value="0"/> |
||||||
|
<Setter Property="SweepAngle" Value="5"/> |
||||||
|
</KeyFrame> |
||||||
|
<KeyFrame Cue="62.5%"> |
||||||
|
<Setter Property="StartAngle" Value="180"/> |
||||||
|
<Setter Property="SweepAngle" Value="50"/> |
||||||
|
</KeyFrame> |
||||||
|
<KeyFrame Cue="75%"> |
||||||
|
<Setter Property="StartAngle" Value="360"/> |
||||||
|
<Setter Property="SweepAngle" Value="100"/> |
||||||
|
</KeyFrame> |
||||||
|
<KeyFrame Cue="87.5%"> |
||||||
|
<Setter Property="StartAngle" Value="540"/> |
||||||
|
<Setter Property="SweepAngle" Value="50"/> |
||||||
|
</KeyFrame> |
||||||
|
<KeyFrame Cue="100%"> |
||||||
|
<Setter Property="StartAngle" Value="720"/> |
||||||
|
<Setter Property="SweepAngle" Value="0"/> |
||||||
|
</KeyFrame> |
||||||
|
</Animation> |
||||||
|
</Style.Animations> |
||||||
|
</Style> |
||||||
|
|
||||||
|
<Style Selector="controls|ProgressRing[IsEnabled=True] /template/ Ellipse#Track"> |
||||||
|
<Style.Animations> |
||||||
|
<Animation Duration="0:0:1" IterationCount="INFINITE"> |
||||||
|
<KeyFrame Cue="0%"> |
||||||
|
<Setter Property="Opacity" Value="0.99"/> |
||||||
|
</KeyFrame> |
||||||
|
<KeyFrame Cue="100%"> |
||||||
|
<Setter Property="Opacity" Value="1"/> |
||||||
|
</KeyFrame> |
||||||
|
</Animation> |
||||||
|
</Style.Animations> |
||||||
|
</Style> |
||||||
|
</Styles> |
@ -0,0 +1,33 @@ |
|||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> |
||||||
|
<!-- Add Resources Here --> |
||||||
|
<Color x:Key="ThemePrimaryColor">#333333</Color> |
||||||
|
<Color x:Key="ThemeDarkDarkRedColor">#952923</Color> |
||||||
|
<Color x:Key="ThemeDarkRedColor">#C2362E</Color> |
||||||
|
<Color x:Key="ThemeRedColor">#F44336</Color> |
||||||
|
<Color x:Key="ThemePinkColor">#E91E63</Color> |
||||||
|
<Color x:Key="ThemePurpleColor">#9C27B0</Color> |
||||||
|
<Color x:Key="ThemeDeepPurpleColor">#673AB7</Color> |
||||||
|
<Color x:Key="ThemeIndigoColor">#3F51B5</Color> |
||||||
|
<Color x:Key="ThemeDarkBlueColor">#1A72BD</Color> |
||||||
|
<Color x:Key="ThemeDarkBlueColorTransparent">#AA1A72BD</Color> |
||||||
|
<Color x:Key="ThemeBlueColor">#2196F3</Color> |
||||||
|
<Color x:Key="ThemeBlueColorTransparent">#AA2196F3</Color> |
||||||
|
<Color x:Key="ThemeLightBlueColor">#03A9F4</Color> |
||||||
|
<Color x:Key="ThemeLightBlueColorTransparent">#AA03A9F4</Color> |
||||||
|
<Color x:Key="ThemeCyanColor">#00BCD4</Color> |
||||||
|
<Color x:Key="ThemeTealColor">#009688</Color> |
||||||
|
<Color x:Key="ThemeDarkDarkGreenColor">#2C582C</Color> |
||||||
|
<Color x:Key="ThemeDarkGreenColor">#3A783C</Color> |
||||||
|
<Color x:Key="ThemeGreenColor">#4BA04F</Color> |
||||||
|
<Color x:Key="ThemeGreenColorTransparent">#AA4BA04F</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="ThemeBrownColor">#795548</Color> |
||||||
|
<Color x:Key="ThemeGreyColor">#9E9E9E</Color> |
||||||
|
<Color x:Key="ThemeBlueGreyColor">#607D8B</Color> |
||||||
|
</ResourceDictionary> |
@ -0,0 +1,10 @@ |
|||||||
|
using Avalonia.Media; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Styles; |
||||||
|
|
||||||
|
public static class ThemeColors |
||||||
|
{ |
||||||
|
public static readonly SolidColorBrush ThemeGreen = SolidColorBrush.Parse("#4caf50"); |
||||||
|
public static readonly SolidColorBrush ThemeRed = SolidColorBrush.Parse("#f44336"); |
||||||
|
public static readonly SolidColorBrush ThemeYellow = SolidColorBrush.Parse("#ffeb3b"); |
||||||
|
} |
@ -0,0 +1,47 @@ |
|||||||
|
using System; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Templates; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia; |
||||||
|
|
||||||
|
public class ViewLocator : IDataTemplate |
||||||
|
{ |
||||||
|
public Control Build(object? data) |
||||||
|
{ |
||||||
|
if (data is null) throw new ArgumentNullException(nameof(data)); |
||||||
|
|
||||||
|
var type = data.GetType(); |
||||||
|
|
||||||
|
if (type is null) throw new InvalidOperationException("Type is null"); |
||||||
|
|
||||||
|
if (Attribute.GetCustomAttribute(type, typeof(ViewAttribute)) is ViewAttribute viewAttr) |
||||||
|
{ |
||||||
|
var viewType = viewAttr.GetViewType(); |
||||||
|
|
||||||
|
#pragma warning disable IL2072 |
||||||
|
// In design mode, just create a new instance of the view |
||||||
|
if (Design.IsDesignMode) |
||||||
|
{ |
||||||
|
return (Control) Activator.CreateInstance(viewType)!; |
||||||
|
} |
||||||
|
#pragma warning restore IL2072 |
||||||
|
// Otherwise get from the service provider |
||||||
|
if (App.Services.GetService(viewType) is Control view) |
||||||
|
{ |
||||||
|
return view; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return new TextBlock |
||||||
|
{ |
||||||
|
Text = "Not Found: " + data.GetType().FullName |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
public bool Match(object? data) |
||||||
|
{ |
||||||
|
return data is ViewModelBase; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,360 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.Immutable; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Net.Http; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using Octokit; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Models.FileInterfaces; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using Notification = Avalonia.Controls.Notifications.Notification; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
public partial class CheckpointBrowserCardViewModel : ProgressViewModel |
||||||
|
|
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly ServiceManager<ViewModelBase> dialogFactory; |
||||||
|
private readonly INotificationService notificationService; |
||||||
|
public CivitModel CivitModel { get; init; } |
||||||
|
public override bool IsTextVisible => Value > 0; |
||||||
|
|
||||||
|
[ObservableProperty] private Uri? cardImage; |
||||||
|
[ObservableProperty] private bool isImporting; |
||||||
|
[ObservableProperty] private string updateCardText = string.Empty; |
||||||
|
[ObservableProperty] private bool showUpdateCard; |
||||||
|
|
||||||
|
public CheckpointBrowserCardViewModel( |
||||||
|
CivitModel civitModel, |
||||||
|
IDownloadService downloadService, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
ServiceManager<ViewModelBase> dialogFactory, |
||||||
|
INotificationService notificationService) |
||||||
|
{ |
||||||
|
this.downloadService = downloadService; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.dialogFactory = dialogFactory; |
||||||
|
this.notificationService = notificationService; |
||||||
|
CivitModel = civitModel; |
||||||
|
|
||||||
|
UpdateImage(); |
||||||
|
|
||||||
|
CheckIfInstalled(); |
||||||
|
|
||||||
|
// Update image when nsfw setting changes |
||||||
|
settingsManager.RegisterPropertyChangedHandler( |
||||||
|
s => s.ModelBrowserNsfwEnabled, |
||||||
|
_ => Dispatcher.UIThread.Post(UpdateImage)); |
||||||
|
} |
||||||
|
|
||||||
|
private void CheckIfInstalled() |
||||||
|
{ |
||||||
|
if (Design.IsDesignMode) |
||||||
|
{ |
||||||
|
UpdateCardText = "Installed"; |
||||||
|
ShowUpdateCard = true; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (CivitModel.ModelVersions == null) return; |
||||||
|
|
||||||
|
var installedModels = settingsManager.Settings.InstalledModelHashes; |
||||||
|
if (!installedModels.Any()) return; |
||||||
|
|
||||||
|
// check if latest version is installed |
||||||
|
var latestVersion = CivitModel.ModelVersions[0]; |
||||||
|
var latestVersionInstalled = latestVersion.Files != null && latestVersion.Files.Any(file => |
||||||
|
file is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} && |
||||||
|
installedModels.Contains(file.Hashes.BLAKE3)); |
||||||
|
|
||||||
|
// check if any of the ModelVersion.Files.Hashes.BLAKE3 hashes are in the installedModels list |
||||||
|
var anyVersionInstalled = latestVersionInstalled || CivitModel.ModelVersions.Any(version => |
||||||
|
version.Files != null && version.Files.Any(file => |
||||||
|
file is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} && |
||||||
|
installedModels.Contains(file.Hashes.BLAKE3))); |
||||||
|
|
||||||
|
UpdateCardText = latestVersionInstalled ? "Installed" : |
||||||
|
anyVersionInstalled ? "Update Available" : string.Empty; |
||||||
|
|
||||||
|
ShowUpdateCard = anyVersionInstalled; |
||||||
|
} |
||||||
|
|
||||||
|
// Choose and load image based on nsfw setting |
||||||
|
private void UpdateImage() |
||||||
|
{ |
||||||
|
var nsfwEnabled = settingsManager.Settings.ModelBrowserNsfwEnabled; |
||||||
|
var version = CivitModel.ModelVersions?.FirstOrDefault(); |
||||||
|
var images = version?.Images; |
||||||
|
|
||||||
|
// Try to find a valid image |
||||||
|
var image = images?.FirstOrDefault(image => nsfwEnabled || image.Nsfw == "None"); |
||||||
|
if (image != null) |
||||||
|
{ |
||||||
|
// var imageStream = await downloadService.GetImageStreamFromUrl(image.Url); |
||||||
|
// Dispatcher.UIThread.Post(() => { CardImage = new Bitmap(imageStream); }); |
||||||
|
CardImage = new Uri(image.Url); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// If no valid image found, use no image |
||||||
|
CardImage = Assets.NoImage; |
||||||
|
|
||||||
|
// var assetStream = AssetLoader.Open(new Uri("avares://StabilityMatrix.Avalonia/Assets/noimage.png")); |
||||||
|
// Otherwise Default image |
||||||
|
// Dispatcher.UIThread.Post(() => { CardImage = new Bitmap(assetStream); }); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private void OpenModel() |
||||||
|
{ |
||||||
|
ProcessRunner.OpenUrl($"https://civitai.com/models/{CivitModel.Id}"); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task Import(CivitModel model) |
||||||
|
{ |
||||||
|
await DoImport(model); |
||||||
|
CheckIfInstalled(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task ShowVersionDialog(CivitModel model) |
||||||
|
{ |
||||||
|
var versions = model.ModelVersions; |
||||||
|
if (versions is null || versions.Count == 0) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification("Model has no versions available", |
||||||
|
"This model has no versions available for download", NotificationType.Warning)); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
Title = model.Name, |
||||||
|
IsPrimaryButtonEnabled = false, |
||||||
|
IsSecondaryButtonEnabled = false, |
||||||
|
IsFooterVisible = false, |
||||||
|
}; |
||||||
|
|
||||||
|
var viewModel = dialogFactory.Get<SelectModelVersionViewModel>(); |
||||||
|
viewModel.Dialog = dialog; |
||||||
|
viewModel.Versions = versions.Select(v => new ModelVersionViewModel(settingsManager, v)) |
||||||
|
.ToImmutableArray(); |
||||||
|
viewModel.SelectedVersionViewModel = viewModel.Versions[0]; |
||||||
|
|
||||||
|
dialog.Content = new SelectModelVersionDialog |
||||||
|
{ |
||||||
|
DataContext = viewModel |
||||||
|
}; |
||||||
|
|
||||||
|
var result = await dialog.ShowAsync(); |
||||||
|
|
||||||
|
if (result != ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var selectedVersion = viewModel?.SelectedVersionViewModel?.ModelVersion; |
||||||
|
var selectedFile = viewModel?.SelectedFile?.CivitFile; |
||||||
|
|
||||||
|
await Task.Delay(100); |
||||||
|
await DoImport(model, selectedVersion, selectedFile); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task DoImport(CivitModel model, CivitModelVersion? selectedVersion = null, |
||||||
|
CivitFile? selectedFile = null) |
||||||
|
{ |
||||||
|
IsImporting = true; |
||||||
|
Text = "Downloading..."; |
||||||
|
|
||||||
|
// Holds files to be deleted on errors |
||||||
|
var filesForCleanup = new HashSet<FilePath>(); |
||||||
|
|
||||||
|
// Set Text when exiting, finally block will set 100 and delay clear progress |
||||||
|
try |
||||||
|
{ |
||||||
|
// Get latest version |
||||||
|
var modelVersion = selectedVersion ?? model.ModelVersions?.FirstOrDefault(); |
||||||
|
if (modelVersion is null) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification("Model has no versions available", |
||||||
|
"This model has no versions available for download", NotificationType.Warning)); |
||||||
|
Text = "Unable to Download"; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Get latest version file |
||||||
|
var modelFile = selectedFile ?? |
||||||
|
modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model); |
||||||
|
if (modelFile is null) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification("Model has no files available", |
||||||
|
"This model has no files available for download", NotificationType.Warning)); |
||||||
|
Text = "Unable to Download"; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var downloadFolder = Path.Combine(settingsManager.ModelsDirectory, |
||||||
|
model.Type.ConvertTo<SharedFolderType>().GetStringValue()); |
||||||
|
// Folders might be missing if user didn't install any packages yet |
||||||
|
Directory.CreateDirectory(downloadFolder); |
||||||
|
var downloadPath = Path.GetFullPath(Path.Combine(downloadFolder, modelFile.Name)); |
||||||
|
filesForCleanup.Add(downloadPath); |
||||||
|
|
||||||
|
// Do the download |
||||||
|
var progressId = Guid.NewGuid(); |
||||||
|
var downloadTask = downloadService.DownloadToFileAsync(modelFile.DownloadUrl, |
||||||
|
downloadPath, |
||||||
|
new Progress<ProgressReport>(report => |
||||||
|
{ |
||||||
|
if (Math.Abs(report.Percentage - Value) > 0.1) |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.Post(() => |
||||||
|
{ |
||||||
|
Value = report.Percentage; |
||||||
|
Text = $"Downloading... {report.Percentage}%"; |
||||||
|
}); |
||||||
|
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, |
||||||
|
modelFile.Name, report)); |
||||||
|
} |
||||||
|
})); |
||||||
|
|
||||||
|
var downloadResult = |
||||||
|
await notificationService.TryAsync(downloadTask, "Could not download file"); |
||||||
|
|
||||||
|
// Failed download handling |
||||||
|
if (downloadResult.Exception is not null) |
||||||
|
{ |
||||||
|
// For exceptions other than ApiException or TaskCanceledException, log error |
||||||
|
var logLevel = downloadResult.Exception switch |
||||||
|
{ |
||||||
|
HttpRequestException or ApiException or TaskCanceledException => LogLevel.Warn, |
||||||
|
_ => LogLevel.Error |
||||||
|
}; |
||||||
|
Logger.Log(logLevel, downloadResult.Exception, "Error during model download"); |
||||||
|
|
||||||
|
Text = "Download Failed"; |
||||||
|
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, |
||||||
|
modelFile.Name, new ProgressReport(0f), true)); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// When sha256 is available, validate the downloaded file |
||||||
|
var fileExpectedSha256 = modelFile.Hashes.SHA256; |
||||||
|
if (!string.IsNullOrEmpty(fileExpectedSha256)) |
||||||
|
{ |
||||||
|
var hashProgress = new Progress<ProgressReport>(progress => |
||||||
|
{ |
||||||
|
Value = progress.Percentage; |
||||||
|
Text = $"Validating... {progress.Percentage}%"; |
||||||
|
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, |
||||||
|
modelFile.Name, progress)); |
||||||
|
}); |
||||||
|
var sha256 = await FileHash.GetSha256Async(downloadPath, hashProgress); |
||||||
|
if (sha256 != fileExpectedSha256.ToLowerInvariant()) |
||||||
|
{ |
||||||
|
Text = "Import Failed!"; |
||||||
|
DelayedClearProgress(TimeSpan.FromMilliseconds(800)); |
||||||
|
notificationService.Show(new Notification("Download failed hash validation", |
||||||
|
"This may be caused by network or server issues from CivitAI, please try again in a few minutes.", |
||||||
|
NotificationType.Error)); |
||||||
|
Text = "Download Failed"; |
||||||
|
|
||||||
|
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, |
||||||
|
modelFile.Name, new ProgressReport(0f), true)); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
settingsManager.Transaction( |
||||||
|
s => s.InstalledModelHashes.Add(modelFile.Hashes.BLAKE3)); |
||||||
|
|
||||||
|
notificationService.Show(new Notification("Import complete", |
||||||
|
$"{model.Type} {model.Name} imported successfully!", NotificationType.Success)); |
||||||
|
} |
||||||
|
|
||||||
|
IsIndeterminate = true; |
||||||
|
|
||||||
|
// Save connected model info |
||||||
|
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Name); |
||||||
|
var modelInfo = |
||||||
|
new ConnectedModelInfo(CivitModel, modelVersion, modelFile, DateTime.UtcNow); |
||||||
|
var modelInfoPath = Path.GetFullPath(Path.Combine( |
||||||
|
downloadFolder, modelFileName + ConnectedModelInfo.FileExtension)); |
||||||
|
filesForCleanup.Add(modelInfoPath); |
||||||
|
await modelInfo.SaveJsonToDirectory(downloadFolder, modelFileName); |
||||||
|
|
||||||
|
// If available, save a model image |
||||||
|
if (modelVersion.Images != null && modelVersion.Images.Any()) |
||||||
|
{ |
||||||
|
var image = modelVersion.Images[0]; |
||||||
|
var imageExtension = Path.GetExtension(image.Url).TrimStart('.'); |
||||||
|
if (imageExtension is "jpg" or "jpeg" or "png") |
||||||
|
{ |
||||||
|
var imageDownloadPath = Path.GetFullPath(Path.Combine(downloadFolder, |
||||||
|
$"{modelFileName}.preview.{imageExtension}")); |
||||||
|
filesForCleanup.Add(imageDownloadPath); |
||||||
|
var imageTask = |
||||||
|
downloadService.DownloadToFileAsync(image.Url, imageDownloadPath); |
||||||
|
await notificationService.TryAsync(imageTask, "Could not download preview image"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Successful - clear cleanup list |
||||||
|
filesForCleanup.Clear(); |
||||||
|
|
||||||
|
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, |
||||||
|
modelFile.Name, new ProgressReport(1f, "Import complete"))); |
||||||
|
|
||||||
|
Text = "Import complete!"; |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
Debug.WriteLine(e); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
foreach (var file in filesForCleanup.Where(file => file.Exists)) |
||||||
|
{ |
||||||
|
file.Delete(); |
||||||
|
Logger.Info($"Download cleanup: Deleted file {file}"); |
||||||
|
} |
||||||
|
|
||||||
|
IsIndeterminate = false; |
||||||
|
Value = 100; |
||||||
|
CheckIfInstalled(); |
||||||
|
DelayedClearProgress(TimeSpan.FromMilliseconds(800)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void DelayedClearProgress(TimeSpan delay) |
||||||
|
{ |
||||||
|
Task.Delay(delay).ContinueWith(_ => |
||||||
|
{ |
||||||
|
Text = string.Empty; |
||||||
|
Value = 0; |
||||||
|
IsImporting = false; |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,382 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.Linq; |
||||||
|
using System.Net.Http; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Collections; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using Refit; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Core.Api; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
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.Settings; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
[View(typeof(CheckpointBrowserPage))] |
||||||
|
public partial class CheckpointBrowserViewModel : PageViewModelBase |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
private readonly ICivitApi civitApi; |
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly ServiceManager<ViewModelBase> dialogFactory; |
||||||
|
private readonly ILiteDbContext liteDbContext; |
||||||
|
private readonly INotificationService notificationService; |
||||||
|
private const int MaxModelsPerPage = 14; |
||||||
|
|
||||||
|
[ObservableProperty] private ObservableCollection<CheckpointBrowserCardViewModel>? modelCards; |
||||||
|
[ObservableProperty] private DataGridCollectionView? modelCardsView; |
||||||
|
|
||||||
|
[ObservableProperty] private string searchQuery = string.Empty; |
||||||
|
[ObservableProperty] private bool showNsfw; |
||||||
|
[ObservableProperty] private bool showMainLoadingSpinner; |
||||||
|
[ObservableProperty] private CivitPeriod selectedPeriod = CivitPeriod.Month; |
||||||
|
[ObservableProperty] private CivitSortMode sortMode = CivitSortMode.HighestRated; |
||||||
|
[ObservableProperty] private CivitModelType selectedModelType = CivitModelType.Checkpoint; |
||||||
|
[ObservableProperty] private int currentPageNumber; |
||||||
|
[ObservableProperty] private int totalPages; |
||||||
|
[ObservableProperty] private bool hasSearched; |
||||||
|
[ObservableProperty] private bool canGoToNextPage; |
||||||
|
[ObservableProperty] private bool canGoToPreviousPage; |
||||||
|
[ObservableProperty] private bool isIndeterminate; |
||||||
|
[ObservableProperty] private bool noResultsFound; |
||||||
|
[ObservableProperty] private string noResultsText = string.Empty; |
||||||
|
|
||||||
|
private List<CheckpointBrowserCardViewModel> allModelCards = new(); |
||||||
|
|
||||||
|
public IEnumerable<CivitPeriod> AllCivitPeriods => Enum.GetValues(typeof(CivitPeriod)).Cast<CivitPeriod>(); |
||||||
|
public IEnumerable<CivitSortMode> AllSortModes => Enum.GetValues(typeof(CivitSortMode)).Cast<CivitSortMode>(); |
||||||
|
|
||||||
|
public IEnumerable<CivitModelType> AllModelTypes => Enum.GetValues(typeof(CivitModelType)) |
||||||
|
.Cast<CivitModelType>() |
||||||
|
.Where(t => t == CivitModelType.All || t.ConvertTo<SharedFolderType>() > 0) |
||||||
|
.OrderBy(t => t.ToString()); |
||||||
|
|
||||||
|
public CheckpointBrowserViewModel( |
||||||
|
ICivitApi civitApi, |
||||||
|
IDownloadService downloadService, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
ServiceManager<ViewModelBase> dialogFactory, |
||||||
|
ILiteDbContext liteDbContext, |
||||||
|
INotificationService notificationService) |
||||||
|
{ |
||||||
|
this.civitApi = civitApi; |
||||||
|
this.downloadService = downloadService; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.dialogFactory = dialogFactory; |
||||||
|
this.liteDbContext = liteDbContext; |
||||||
|
this.notificationService = notificationService; |
||||||
|
|
||||||
|
CurrentPageNumber = 1; |
||||||
|
CanGoToNextPage = true; |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnLoaded() |
||||||
|
{ |
||||||
|
if (Design.IsDesignMode) return; |
||||||
|
|
||||||
|
var searchOptions = settingsManager.Settings.ModelSearchOptions; |
||||||
|
|
||||||
|
SelectedPeriod = searchOptions?.SelectedPeriod ?? CivitPeriod.Month; |
||||||
|
SortMode = searchOptions?.SortMode ?? CivitSortMode.HighestRated; |
||||||
|
SelectedModelType = searchOptions?.SelectedModelType ?? CivitModelType.Checkpoint; |
||||||
|
|
||||||
|
ShowNsfw = settingsManager.Settings.ModelBrowserNsfwEnabled; |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor(this, model => model.ShowNsfw, |
||||||
|
settings => settings.ModelBrowserNsfwEnabled); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Filter predicate for model cards |
||||||
|
/// </summary> |
||||||
|
private bool FilterModelCardsPredicate(object? item) |
||||||
|
{ |
||||||
|
if (item is not CheckpointBrowserCardViewModel card) return false; |
||||||
|
return !card.CivitModel.Nsfw || ShowNsfw; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Background update task |
||||||
|
/// </summary> |
||||||
|
private async Task CivitModelQuery(CivitModelsRequest request) |
||||||
|
{ |
||||||
|
var timer = Stopwatch.StartNew(); |
||||||
|
var queryText = request.Query; |
||||||
|
try |
||||||
|
{ |
||||||
|
var modelsResponse = await civitApi.GetModels(request); |
||||||
|
var models = modelsResponse.Items; |
||||||
|
if (models is null) |
||||||
|
{ |
||||||
|
Logger.Debug("CivitAI Query {Text} returned no results (in {Elapsed:F1} s)", |
||||||
|
queryText, timer.Elapsed.TotalSeconds); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
Logger.Debug("CivitAI Query {Text} returned {Results} results (in {Elapsed:F1} s)", |
||||||
|
queryText, models.Count, timer.Elapsed.TotalSeconds); |
||||||
|
|
||||||
|
var unknown = models.Where(m => m.Type == CivitModelType.Unknown).ToList(); |
||||||
|
if (unknown.Any()) |
||||||
|
{ |
||||||
|
var names = unknown.Select(m => m.Name).ToList(); |
||||||
|
Logger.Warn("Excluded {Unknown} unknown model types: {Models}", unknown.Count, |
||||||
|
names); |
||||||
|
} |
||||||
|
|
||||||
|
// Filter out unknown model types and archived/taken-down models |
||||||
|
models = models.Where(m => m.Type.ConvertTo<SharedFolderType>() > 0) |
||||||
|
.Where(m => m.Mode == null).ToList(); |
||||||
|
|
||||||
|
// Database update calls will invoke `OnModelsUpdated` |
||||||
|
// Add to database |
||||||
|
await liteDbContext.UpsertCivitModelAsync(models); |
||||||
|
// Add as cache entry |
||||||
|
var cacheNew = await liteDbContext.UpsertCivitModelQueryCacheEntryAsync(new() |
||||||
|
{ |
||||||
|
Id = ObjectHash.GetMd5Guid(request), |
||||||
|
InsertedAt = DateTimeOffset.UtcNow, |
||||||
|
Request = request, |
||||||
|
Items = models, |
||||||
|
Metadata = modelsResponse.Metadata |
||||||
|
}); |
||||||
|
|
||||||
|
if (cacheNew) |
||||||
|
{ |
||||||
|
Logger.Debug("New cache entry, updating model cards"); |
||||||
|
UpdateModelCards(models, modelsResponse.Metadata); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
Logger.Debug("Cache entry already exists, not updating model cards"); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification("Request to CivitAI timed out", |
||||||
|
"Please try again in a few minutes")); |
||||||
|
Logger.Warn($"CivitAI query timed out ({request})"); |
||||||
|
} |
||||||
|
catch (HttpRequestException e) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification("CivitAI can't be reached right now", |
||||||
|
"Please try again in a few minutes")); |
||||||
|
Logger.Warn(e, $"CivitAI query HttpRequestException ({request})"); |
||||||
|
} |
||||||
|
catch (ApiException e) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification("CivitAI can't be reached right now", |
||||||
|
"Please try again in a few minutes")); |
||||||
|
Logger.Warn(e, $"CivitAI query ApiException ({request})"); |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification("CivitAI can't be reached right now", |
||||||
|
$"Unknown exception during CivitAI query: {e.GetType().Name}")); |
||||||
|
Logger.Error(e, $"CivitAI query unknown exception ({request})"); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
ShowMainLoadingSpinner = false; |
||||||
|
UpdateResultsText(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Updates model cards using api response object. |
||||||
|
/// </summary> |
||||||
|
private void UpdateModelCards(IEnumerable<CivitModel>? models, CivitMetadata? metadata) |
||||||
|
{ |
||||||
|
if (models is null) |
||||||
|
{ |
||||||
|
ModelCards?.Clear(); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
var updateCards = models |
||||||
|
.Select(model => new CheckpointBrowserCardViewModel(model, |
||||||
|
downloadService, settingsManager, dialogFactory, notificationService)).ToList(); |
||||||
|
allModelCards = updateCards; |
||||||
|
ModelCards = |
||||||
|
new ObservableCollection<CheckpointBrowserCardViewModel>( |
||||||
|
updateCards.Where(FilterModelCardsPredicate)); |
||||||
|
} |
||||||
|
TotalPages = metadata?.TotalPages ?? 1; |
||||||
|
CanGoToPreviousPage = CurrentPageNumber > 1; |
||||||
|
CanGoToNextPage = CurrentPageNumber < TotalPages; |
||||||
|
// Status update |
||||||
|
ShowMainLoadingSpinner = false; |
||||||
|
IsIndeterminate = false; |
||||||
|
HasSearched = true; |
||||||
|
} |
||||||
|
|
||||||
|
private string previousSearchQuery = string.Empty; |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task SearchModels() |
||||||
|
{ |
||||||
|
var timer = Stopwatch.StartNew(); |
||||||
|
|
||||||
|
if (SearchQuery != previousSearchQuery) |
||||||
|
{ |
||||||
|
// Reset page number |
||||||
|
CurrentPageNumber = 1; |
||||||
|
previousSearchQuery = SearchQuery; |
||||||
|
} |
||||||
|
|
||||||
|
// Build request |
||||||
|
var modelRequest = new CivitModelsRequest |
||||||
|
{ |
||||||
|
Limit = MaxModelsPerPage, |
||||||
|
Nsfw = "true", // Handled by local view filter |
||||||
|
Sort = SortMode, |
||||||
|
Period = SelectedPeriod, |
||||||
|
Page = CurrentPageNumber |
||||||
|
}; |
||||||
|
|
||||||
|
if (SearchQuery.StartsWith("#")) |
||||||
|
{ |
||||||
|
modelRequest.Tag = SearchQuery[1..]; |
||||||
|
} |
||||||
|
else if (SearchQuery.StartsWith("@")) |
||||||
|
{ |
||||||
|
modelRequest.Username = SearchQuery[1..]; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
modelRequest.Query = SearchQuery; |
||||||
|
} |
||||||
|
|
||||||
|
if (SelectedModelType != CivitModelType.All) |
||||||
|
{ |
||||||
|
modelRequest.Types = new[] {SelectedModelType}; |
||||||
|
} |
||||||
|
|
||||||
|
// See if query is cached |
||||||
|
var cachedQuery = await liteDbContext.CivitModelQueryCache |
||||||
|
.IncludeAll() |
||||||
|
.FindByIdAsync(ObjectHash.GetMd5Guid(modelRequest)); |
||||||
|
|
||||||
|
// If cached, update model cards |
||||||
|
if (cachedQuery is not null) |
||||||
|
{ |
||||||
|
var elapsed = timer.Elapsed; |
||||||
|
Logger.Debug("Using cached query for {Text} [{RequestHash}] (in {Elapsed:F1} s)", |
||||||
|
SearchQuery, modelRequest.GetHashCode(), elapsed.TotalSeconds); |
||||||
|
UpdateModelCards(cachedQuery.Items, cachedQuery.Metadata); |
||||||
|
|
||||||
|
// Start remote query (background mode) |
||||||
|
// Skip when last query was less than 2 min ago |
||||||
|
var timeSinceCache = DateTimeOffset.UtcNow - cachedQuery.InsertedAt; |
||||||
|
if (timeSinceCache?.TotalMinutes >= 2) |
||||||
|
{ |
||||||
|
CivitModelQuery(modelRequest).SafeFireAndForget(); |
||||||
|
Logger.Debug( |
||||||
|
"Cached query was more than 2 minutes ago ({Seconds:F0} s), updating cache with remote query", |
||||||
|
timeSinceCache.Value.TotalSeconds); |
||||||
|
} |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
// Not cached, wait for remote query |
||||||
|
ShowMainLoadingSpinner = true; |
||||||
|
await CivitModelQuery(modelRequest); |
||||||
|
} |
||||||
|
|
||||||
|
UpdateResultsText(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task PreviousPage() |
||||||
|
{ |
||||||
|
if (CurrentPageNumber == 1) return; |
||||||
|
|
||||||
|
CurrentPageNumber--; |
||||||
|
await TrySearchAgain(false); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task NextPage() |
||||||
|
{ |
||||||
|
CurrentPageNumber++; |
||||||
|
await TrySearchAgain(false); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnShowNsfwChanged(bool value) |
||||||
|
{ |
||||||
|
settingsManager.Transaction(s => s.ModelBrowserNsfwEnabled, value); |
||||||
|
// ModelCardsView?.Refresh(); |
||||||
|
var updateCards = allModelCards |
||||||
|
.Where(FilterModelCardsPredicate); |
||||||
|
ModelCards = new ObservableCollection<CheckpointBrowserCardViewModel>(updateCards); |
||||||
|
|
||||||
|
if (!HasSearched) return; |
||||||
|
|
||||||
|
UpdateResultsText(); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedPeriodChanged(CivitPeriod value) |
||||||
|
{ |
||||||
|
TrySearchAgain().SafeFireAndForget(); |
||||||
|
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions( |
||||||
|
value, SortMode, SelectedModelType)); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSortModeChanged(CivitSortMode value) |
||||||
|
{ |
||||||
|
TrySearchAgain().SafeFireAndForget(); |
||||||
|
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions( |
||||||
|
SelectedPeriod, value, SelectedModelType)); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedModelTypeChanged(CivitModelType value) |
||||||
|
{ |
||||||
|
TrySearchAgain().SafeFireAndForget(); |
||||||
|
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions( |
||||||
|
SelectedPeriod, SortMode, value)); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task TrySearchAgain(bool shouldUpdatePageNumber = true) |
||||||
|
{ |
||||||
|
if (!HasSearched) return; |
||||||
|
ModelCards?.Clear(); |
||||||
|
|
||||||
|
if (shouldUpdatePageNumber) |
||||||
|
{ |
||||||
|
CurrentPageNumber = 1; |
||||||
|
} |
||||||
|
|
||||||
|
// execute command instead of calling method directly so that the IsRunning property gets updated |
||||||
|
await SearchModelsCommand.ExecuteAsync(null); |
||||||
|
} |
||||||
|
|
||||||
|
private void UpdateResultsText() |
||||||
|
{ |
||||||
|
NoResultsFound = ModelCards?.Count <= 0; |
||||||
|
NoResultsText = allModelCards.Count > 0 |
||||||
|
? $"{allModelCards.Count} results hidden by filters" |
||||||
|
: "No results found"; |
||||||
|
} |
||||||
|
|
||||||
|
public override string Title => "Model Browser"; |
||||||
|
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.BrainCircuit, IsFilled = true }; |
||||||
|
} |
@ -0,0 +1,250 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Data; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
public partial class CheckpointFile : ViewModelBase |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
|
||||||
|
// Event for when this file is deleted |
||||||
|
public event EventHandler<CheckpointFile>? Deleted; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Absolute path to the checkpoint file. |
||||||
|
/// </summary> |
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(FileName))] |
||||||
|
private string filePath = string.Empty; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Custom title for UI. |
||||||
|
/// </summary> |
||||||
|
[ObservableProperty] |
||||||
|
private string title = string.Empty; |
||||||
|
|
||||||
|
public string? PreviewImagePath { get; set; } |
||||||
|
public Bitmap? PreviewImage { get; set; } |
||||||
|
public bool IsPreviewImageLoaded => PreviewImage != null; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private ConnectedModelInfo? connectedModel; |
||||||
|
public bool IsConnectedModel => ConnectedModel != null; |
||||||
|
|
||||||
|
[ObservableProperty] private bool isLoading; |
||||||
|
|
||||||
|
public string FileName => Path.GetFileName(FilePath); |
||||||
|
|
||||||
|
public ObservableCollection<string> Badges { get; set; } = new(); |
||||||
|
|
||||||
|
private static readonly string[] SupportedCheckpointExtensions = { ".safetensors", ".pt", ".ckpt", ".pth", "bin" }; |
||||||
|
private static readonly string[] SupportedImageExtensions = { ".png", ".jpg", ".jpeg" }; |
||||||
|
private static readonly string[] SupportedMetadataExtensions = { ".json" }; |
||||||
|
|
||||||
|
partial void OnConnectedModelChanged(ConnectedModelInfo? value) |
||||||
|
{ |
||||||
|
// Update title, first check user defined, then connected model name |
||||||
|
Title = value?.UserTitle ?? value?.ModelName ?? string.Empty; |
||||||
|
// Update badges |
||||||
|
Badges.Clear(); |
||||||
|
var fpType = value?.FileMetadata.Fp?.GetStringValue().ToUpperInvariant(); |
||||||
|
if (fpType != null) |
||||||
|
{ |
||||||
|
Badges.Add(fpType); |
||||||
|
} |
||||||
|
if (!string.IsNullOrWhiteSpace(value?.BaseModel)) |
||||||
|
{ |
||||||
|
Badges.Add(value.BaseModel); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task DeleteAsync() |
||||||
|
{ |
||||||
|
if (File.Exists(FilePath)) |
||||||
|
{ |
||||||
|
IsLoading = true; |
||||||
|
try |
||||||
|
{ |
||||||
|
await using var delay = new MinimumDelay(200, 500); |
||||||
|
await Task.Run(() => File.Delete(FilePath)); |
||||||
|
if (PreviewImagePath != null && File.Exists(PreviewImagePath)) |
||||||
|
{ |
||||||
|
await Task.Run(() => File.Delete(PreviewImagePath)); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (IOException ex) |
||||||
|
{ |
||||||
|
Logger.Warn($"Failed to delete checkpoint file {FilePath}: {ex.Message}"); |
||||||
|
return; // Don't delete from collection |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
IsLoading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
Deleted?.Invoke(this, this); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task RenameAsync() |
||||||
|
{ |
||||||
|
// Parent folder path |
||||||
|
var parentPath = Path.GetDirectoryName(FilePath) ?? ""; |
||||||
|
|
||||||
|
var textFields = new TextBoxField[] |
||||||
|
{ |
||||||
|
new() |
||||||
|
{ |
||||||
|
Label = "File name", |
||||||
|
Validator = text => |
||||||
|
{ |
||||||
|
if (string.IsNullOrWhiteSpace(text)) throw new |
||||||
|
DataValidationException("File name is required"); |
||||||
|
|
||||||
|
if (File.Exists(Path.Combine(parentPath, text))) throw new |
||||||
|
DataValidationException("File name already exists"); |
||||||
|
}, |
||||||
|
Text = FileName |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
var dialog = DialogHelper.CreateTextEntryDialog("Rename Model", "", textFields); |
||||||
|
|
||||||
|
if (await dialog.ShowAsync() == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
var name = textFields[0].Text; |
||||||
|
var nameNoExt = Path.GetFileNameWithoutExtension(name); |
||||||
|
var originalNameNoExt = Path.GetFileNameWithoutExtension(FileName); |
||||||
|
// Rename file in OS |
||||||
|
try |
||||||
|
{ |
||||||
|
var newFilePath = Path.Combine(parentPath, name); |
||||||
|
File.Move(FilePath, newFilePath); |
||||||
|
FilePath = newFilePath; |
||||||
|
// If preview image exists, rename it too |
||||||
|
if (PreviewImagePath != null && File.Exists(PreviewImagePath)) |
||||||
|
{ |
||||||
|
var newPreviewImagePath = Path.Combine(parentPath, $"{nameNoExt}.preview{Path.GetExtension(PreviewImagePath)}"); |
||||||
|
File.Move(PreviewImagePath, newPreviewImagePath); |
||||||
|
PreviewImagePath = newPreviewImagePath; |
||||||
|
} |
||||||
|
// If connected model info exists, rename it too (<name>.cm-info.json) |
||||||
|
if (ConnectedModel != null) |
||||||
|
{ |
||||||
|
var cmInfoPath = Path.Combine(parentPath, $"{originalNameNoExt}.cm-info.json"); |
||||||
|
if (File.Exists(cmInfoPath)) |
||||||
|
{ |
||||||
|
File.Move(cmInfoPath, Path.Combine(parentPath, $"{nameNoExt}.cm-info.json")); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
Logger.Warn(e, $"Failed to rename checkpoint file {FilePath}"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private void OpenOnCivitAi() |
||||||
|
{ |
||||||
|
if (ConnectedModel?.ModelId == null) return; |
||||||
|
ProcessRunner.OpenUrl($"https://civitai.com/models/{ConnectedModel.ModelId}"); |
||||||
|
} |
||||||
|
|
||||||
|
// Loads image from path |
||||||
|
private async Task LoadPreviewImage() |
||||||
|
{ |
||||||
|
if (PreviewImagePath == null) return; |
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => |
||||||
|
{ |
||||||
|
PreviewImage = new Bitmap(File.OpenRead(PreviewImagePath)); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Indexes directory and yields all checkpoint files. |
||||||
|
/// First we match all files with supported extensions. |
||||||
|
/// If found, we also look for |
||||||
|
/// - {filename}.preview.{image-extensions} (preview image) |
||||||
|
/// - {filename}.cm-info.json (connected model info) |
||||||
|
/// </summary> |
||||||
|
public static IEnumerable<CheckpointFile> FromDirectoryIndex(string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly) |
||||||
|
{ |
||||||
|
// Get all files with supported extensions |
||||||
|
var allExtensions = SupportedCheckpointExtensions |
||||||
|
.Concat(SupportedImageExtensions) |
||||||
|
.Concat(SupportedMetadataExtensions); |
||||||
|
|
||||||
|
var files = allExtensions.AsParallel() |
||||||
|
.SelectMany(pattern => Directory.EnumerateFiles(directory, $"*{pattern}", searchOption)).ToDictionary<string, string>(Path.GetFileName); |
||||||
|
|
||||||
|
foreach (var file in files.Keys.Where(k => SupportedCheckpointExtensions.Contains(Path.GetExtension(k)))) |
||||||
|
{ |
||||||
|
var checkpointFile = new CheckpointFile() |
||||||
|
{ |
||||||
|
Title = Path.GetFileNameWithoutExtension(file), |
||||||
|
FilePath = Path.Combine(directory, file), |
||||||
|
}; |
||||||
|
|
||||||
|
// Check for connected model info |
||||||
|
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); |
||||||
|
var cmInfoPath = $"{fileNameWithoutExtension}.cm-info.json"; |
||||||
|
if (files.TryGetValue(cmInfoPath, out var jsonPath)) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
var jsonData = File.ReadAllText(jsonPath); |
||||||
|
checkpointFile.ConnectedModel = ConnectedModelInfo.FromJson(jsonData); |
||||||
|
} |
||||||
|
catch (IOException e) |
||||||
|
{ |
||||||
|
Debug.WriteLine($"Failed to parse {cmInfoPath}: {e}"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Check for preview image |
||||||
|
var previewImage = SupportedImageExtensions.Select(ext => $"{fileNameWithoutExtension}.preview{ext}").FirstOrDefault(files.ContainsKey); |
||||||
|
if (previewImage != null) |
||||||
|
{ |
||||||
|
checkpointFile.PreviewImagePath = files[previewImage]; |
||||||
|
checkpointFile.LoadPreviewImage().SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
yield return checkpointFile; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Index with progress reporting. |
||||||
|
/// </summary> |
||||||
|
public static IEnumerable<CheckpointFile> FromDirectoryIndex(string directory, IProgress<ProgressReport> progress, |
||||||
|
SearchOption searchOption = SearchOption.TopDirectoryOnly) |
||||||
|
{ |
||||||
|
var current = 0ul; |
||||||
|
foreach (var checkpointFile in FromDirectoryIndex(directory, searchOption)) |
||||||
|
{ |
||||||
|
current++; |
||||||
|
progress.Report(new ProgressReport(current, "Indexing", checkpointFile.FileName)); |
||||||
|
yield return checkpointFile; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,400 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.Collections.Specialized; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Input; |
||||||
|
using Avalonia.Platform.Storage; |
||||||
|
using Avalonia.Threading; |
||||||
|
using AvaloniaEdit.Utils; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.FileInterfaces; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
public partial class CheckpointFolder : ViewModelBase |
||||||
|
{ |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
private readonly ModelFinder modelFinder; |
||||||
|
// ReSharper disable once FieldCanBeMadeReadOnly.Local |
||||||
|
private bool useCategoryVisibility; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Absolute path to the folder. |
||||||
|
/// </summary> |
||||||
|
public string DirectoryPath { get; init; } = string.Empty; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Custom title for UI. |
||||||
|
/// </summary> |
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(FolderType))] |
||||||
|
[NotifyPropertyChangedFor(nameof(TitleWithFilesCount))] |
||||||
|
private string title = string.Empty; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private SharedFolderType folderType; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// True if the category is enabled for the manager page. |
||||||
|
/// </summary> |
||||||
|
[ObservableProperty] private bool isCategoryEnabled = true; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// True if currently expanded in the UI. |
||||||
|
/// </summary> |
||||||
|
[ObservableProperty] private bool isExpanded = true; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(IsDragBlurEnabled))] |
||||||
|
private bool isCurrentDragTarget; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(IsDragBlurEnabled))] |
||||||
|
private bool isImportInProgress; |
||||||
|
|
||||||
|
[ObservableProperty] private string searchFilter = string.Empty; |
||||||
|
|
||||||
|
public bool IsDragBlurEnabled => IsCurrentDragTarget || IsImportInProgress; |
||||||
|
|
||||||
|
public string TitleWithFilesCount => |
||||||
|
CheckpointFiles.Any() || SubFolders.Any(f => f.CheckpointFiles.Any()) |
||||||
|
? $"{Title} ({CheckpointFiles.Count + SubFolders.Sum(folder => folder.CheckpointFiles.Count)})" |
||||||
|
: Title; |
||||||
|
|
||||||
|
public ProgressViewModel Progress { get; } = new(); |
||||||
|
|
||||||
|
public CheckpointFolder? ParentFolder { get; init; } |
||||||
|
public ObservableCollection<CheckpointFolder> SubFolders { get; init; } = new(); |
||||||
|
public ObservableCollection<CheckpointFile> CheckpointFiles { get; init; } = new(); |
||||||
|
public ObservableCollection<CheckpointFile> DisplayedCheckpointFiles { get; set; } |
||||||
|
|
||||||
|
public CheckpointFolder( |
||||||
|
ISettingsManager settingsManager, |
||||||
|
IDownloadService downloadService, |
||||||
|
ModelFinder modelFinder, |
||||||
|
bool useCategoryVisibility = true) |
||||||
|
{ |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.downloadService = downloadService; |
||||||
|
this.modelFinder = modelFinder; |
||||||
|
this.useCategoryVisibility = useCategoryVisibility; |
||||||
|
|
||||||
|
CheckpointFiles.CollectionChanged += OnCheckpointFilesChanged; |
||||||
|
DisplayedCheckpointFiles = CheckpointFiles; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// When title is set, set the category enabled state from settings. |
||||||
|
/// </summary> |
||||||
|
// ReSharper disable once UnusedParameterInPartialMethod |
||||||
|
partial void OnTitleChanged(string value) |
||||||
|
{ |
||||||
|
if (!useCategoryVisibility) return; |
||||||
|
|
||||||
|
// Update folder type |
||||||
|
var result = Enum.TryParse(Title, out SharedFolderType type); |
||||||
|
FolderType = result ? type : new SharedFolderType(); |
||||||
|
|
||||||
|
IsCategoryEnabled = settingsManager.IsSharedFolderCategoryVisible(FolderType); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSearchFilterChanged(string value) |
||||||
|
{ |
||||||
|
if (string.IsNullOrEmpty(value)) |
||||||
|
{ |
||||||
|
DisplayedCheckpointFiles = CheckpointFiles; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
var filteredFiles = CheckpointFiles.Where(y => |
||||||
|
y.FileName.Contains(value, StringComparison.OrdinalIgnoreCase)); |
||||||
|
DisplayedCheckpointFiles = new ObservableCollection<CheckpointFile>(filteredFiles); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// When toggling the category enabled state, save it to settings. |
||||||
|
/// </summary> |
||||||
|
partial void OnIsCategoryEnabledChanged(bool value) |
||||||
|
{ |
||||||
|
if (!useCategoryVisibility) return; |
||||||
|
if (value != settingsManager.IsSharedFolderCategoryVisible(FolderType)) |
||||||
|
{ |
||||||
|
settingsManager.SetSharedFolderCategoryVisible(FolderType, value); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// On collection changes |
||||||
|
private void OnCheckpointFilesChanged(object? sender, NotifyCollectionChangedEventArgs e) |
||||||
|
{ |
||||||
|
OnPropertyChanged(nameof(TitleWithFilesCount)); |
||||||
|
if (e.NewItems == null) return; |
||||||
|
// On new added items, add event handler for deletion |
||||||
|
foreach (CheckpointFile item in e.NewItems) |
||||||
|
{ |
||||||
|
item.Deleted += OnCheckpointFileDelete; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Handler for CheckpointFile requesting to be deleted from the collection. |
||||||
|
/// </summary> |
||||||
|
/// <param name="sender"></param> |
||||||
|
/// <param name="file"></param> |
||||||
|
private void OnCheckpointFileDelete(object? sender, CheckpointFile file) |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.Post(() => |
||||||
|
{ |
||||||
|
CheckpointFiles.Remove(file); |
||||||
|
DisplayedCheckpointFiles.Remove(file); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task OnDrop(DragEventArgs e) |
||||||
|
{ |
||||||
|
IsImportInProgress = true; |
||||||
|
IsCurrentDragTarget = false; |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
// {System.Linq.Enumerable.WhereEnumerableIterator<Avalonia.Platform.Storage.IStorageItem>} |
||||||
|
if (e.Data.Get(DataFormats.Files) is IEnumerable<IStorageItem> files) |
||||||
|
{ |
||||||
|
var paths = files.Select(f => f.Path.LocalPath).ToArray(); |
||||||
|
await ImportFilesAsync(paths, settingsManager.Settings.IsImportAsConnected); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception) |
||||||
|
{ |
||||||
|
// If no exception this will be handled by DelayedClearProgress() |
||||||
|
IsImportInProgress = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task ShowInExplorer(string path) |
||||||
|
{ |
||||||
|
await ProcessRunner.OpenFolderBrowser(path); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task CreateSubFolder() |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.VerifyAccess(); |
||||||
|
|
||||||
|
var textBox = new TextBox(); |
||||||
|
var dialog = new ContentDialog |
||||||
|
{ |
||||||
|
Content = textBox, |
||||||
|
DefaultButton = ContentDialogButton.Primary, |
||||||
|
PrimaryButtonText = "OK", |
||||||
|
IsPrimaryButtonEnabled = true, |
||||||
|
}; |
||||||
|
|
||||||
|
var result = await dialog.ShowAsync(); |
||||||
|
if (result == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
var targetName = textBox.Text; |
||||||
|
if (string.IsNullOrWhiteSpace(targetName)) return; |
||||||
|
|
||||||
|
var subFolderPath = Path.Combine(DirectoryPath, targetName); |
||||||
|
|
||||||
|
Directory.CreateDirectory(subFolderPath); |
||||||
|
|
||||||
|
SubFolders.Add(new CheckpointFolder(settingsManager, |
||||||
|
downloadService, modelFinder, |
||||||
|
useCategoryVisibility: false) |
||||||
|
{ |
||||||
|
Title = Path.GetFileName(subFolderPath), |
||||||
|
DirectoryPath = subFolderPath, |
||||||
|
FolderType = FolderType, |
||||||
|
ParentFolder = this, |
||||||
|
IsExpanded = false, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Imports files to the folder. Reports progress to instance properties. |
||||||
|
/// </summary> |
||||||
|
public async Task ImportFilesAsync(IEnumerable<string> files, bool convertToConnected = false) |
||||||
|
{ |
||||||
|
Progress.Value = 0; |
||||||
|
var copyPaths = files.ToDictionary(k => k, v => Path.Combine(DirectoryPath, Path.GetFileName(v))); |
||||||
|
|
||||||
|
var progress = new Progress<ProgressReport>(report => |
||||||
|
{ |
||||||
|
Progress.IsIndeterminate = false; |
||||||
|
Progress.Value = report.Percentage; |
||||||
|
// For multiple files, add count |
||||||
|
Progress.Text = copyPaths.Count > 1 ? $"Importing {report.Title} ({report.Message})" : $"Importing {report.Title}"; |
||||||
|
}); |
||||||
|
|
||||||
|
await FileTransfers.CopyFiles(copyPaths, progress); |
||||||
|
|
||||||
|
// Hash files and convert them to connected model if found |
||||||
|
if (convertToConnected) |
||||||
|
{ |
||||||
|
var modelFilesCount = copyPaths.Count; |
||||||
|
var modelFiles = copyPaths.Values |
||||||
|
.Select(path => new FilePath(path)); |
||||||
|
|
||||||
|
// Holds tasks for model queries after hash |
||||||
|
var modelQueryTasks = new List<Task<bool>>(); |
||||||
|
|
||||||
|
foreach (var (i, modelFile) in modelFiles.Enumerate()) |
||||||
|
{ |
||||||
|
var hashProgress = new Progress<ProgressReport>(report => |
||||||
|
{ |
||||||
|
Progress.IsIndeterminate = false; |
||||||
|
Progress.Value = report.Percentage; |
||||||
|
Progress.Text = modelFilesCount > 1 ? |
||||||
|
$"Computing metadata for {modelFile.Info.Name} ({i}/{modelFilesCount})" : |
||||||
|
$"Computing metadata for {report.Title}"; |
||||||
|
}); |
||||||
|
|
||||||
|
var hashBlake3 = await FileHash.GetBlake3Async(modelFile, hashProgress); |
||||||
|
|
||||||
|
// Start a task to query the model in background |
||||||
|
var queryTask = Task.Run(async () => |
||||||
|
{ |
||||||
|
var result = await modelFinder.LocalFindModel(hashBlake3); |
||||||
|
result ??= await modelFinder.RemoteFindModel(hashBlake3); |
||||||
|
|
||||||
|
if (result is null) return false; // Not found |
||||||
|
|
||||||
|
var (model, version, file) = result.Value; |
||||||
|
|
||||||
|
// Save connected model info json |
||||||
|
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Info.Name); |
||||||
|
var modelInfo = new ConnectedModelInfo( |
||||||
|
model, version, file, DateTimeOffset.UtcNow); |
||||||
|
await modelInfo.SaveJsonToDirectory(DirectoryPath, modelFileName); |
||||||
|
|
||||||
|
// If available, save thumbnail |
||||||
|
var image = version.Images?.FirstOrDefault(); |
||||||
|
if (image != null) |
||||||
|
{ |
||||||
|
var imageExt = Path.GetExtension(image.Url).TrimStart('.'); |
||||||
|
if (imageExt is "jpg" or "jpeg" or "png") |
||||||
|
{ |
||||||
|
var imageDownloadPath = Path.GetFullPath( |
||||||
|
Path.Combine(DirectoryPath, $"{modelFileName}.preview.{imageExt}")); |
||||||
|
await downloadService.DownloadToFileAsync(image.Url, imageDownloadPath); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
}); |
||||||
|
modelQueryTasks.Add(queryTask); |
||||||
|
} |
||||||
|
|
||||||
|
// Set progress to indeterminate |
||||||
|
Progress.IsIndeterminate = true; |
||||||
|
Progress.Text = "Checking connected model information"; |
||||||
|
|
||||||
|
// Wait for all model queries to finish |
||||||
|
var modelQueryResults = await Task.WhenAll(modelQueryTasks); |
||||||
|
|
||||||
|
var successCount = modelQueryResults.Count(r => r); |
||||||
|
var totalCount = modelQueryResults.Length; |
||||||
|
var failCount = totalCount - successCount; |
||||||
|
|
||||||
|
await IndexAsync(); |
||||||
|
|
||||||
|
Progress.Value = 100; |
||||||
|
Progress.Text = successCount switch |
||||||
|
{ |
||||||
|
0 when failCount > 0 => |
||||||
|
"Import complete. No connected data found.", |
||||||
|
> 0 when failCount > 0 => |
||||||
|
$"Import complete. Found connected data for {successCount} of {totalCount} models.", |
||||||
|
_ => $"Import complete. Found connected data for all {totalCount} models." |
||||||
|
}; |
||||||
|
|
||||||
|
DelayedClearProgress(TimeSpan.FromSeconds(1.5)); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
Progress.Text = "Import complete"; |
||||||
|
Progress.Value = 100; |
||||||
|
await IndexAsync(); |
||||||
|
DelayedClearProgress(TimeSpan.FromSeconds(1.5)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Clears progress after a delay. |
||||||
|
/// </summary> |
||||||
|
private void DelayedClearProgress(TimeSpan delay) |
||||||
|
{ |
||||||
|
Task.Delay(delay).ContinueWith(_ => |
||||||
|
{ |
||||||
|
IsImportInProgress = false; |
||||||
|
Progress.Value = 0; |
||||||
|
Progress.Text = string.Empty; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets checkpoint files from folder index |
||||||
|
/// </summary> |
||||||
|
private async Task<List<CheckpointFile>> GetCheckpointFilesAsync(IProgress<ProgressReport>? progress = default) |
||||||
|
{ |
||||||
|
if (!Directory.Exists(DirectoryPath)) |
||||||
|
{ |
||||||
|
return new List<CheckpointFile>(); |
||||||
|
} |
||||||
|
|
||||||
|
return await (progress switch |
||||||
|
{ |
||||||
|
null => Task.Run(() => |
||||||
|
CheckpointFile.FromDirectoryIndex(DirectoryPath).ToList()), |
||||||
|
|
||||||
|
_ => Task.Run(() => |
||||||
|
CheckpointFile.FromDirectoryIndex(DirectoryPath, progress).ToList()) |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Indexes the folder for checkpoint files and refreshes the CheckPointFiles collection. |
||||||
|
/// </summary> |
||||||
|
public async Task IndexAsync(IProgress<ProgressReport>? progress = default) |
||||||
|
{ |
||||||
|
SubFolders.Clear(); |
||||||
|
// Get subfolders |
||||||
|
foreach (var folder in Directory.GetDirectories(DirectoryPath)) |
||||||
|
{ |
||||||
|
// Create subfolder |
||||||
|
var subFolder = new CheckpointFolder(settingsManager, |
||||||
|
downloadService, modelFinder, |
||||||
|
useCategoryVisibility: false) |
||||||
|
{ |
||||||
|
Title = Path.GetFileName(folder), |
||||||
|
DirectoryPath = folder, |
||||||
|
FolderType = FolderType, // Inherit our folder type |
||||||
|
ParentFolder = this, |
||||||
|
IsExpanded = false, // Subfolders are collapsed by default |
||||||
|
}; |
||||||
|
|
||||||
|
await subFolder.IndexAsync(progress); |
||||||
|
SubFolders.Add(subFolder); |
||||||
|
} |
||||||
|
|
||||||
|
CheckpointFiles.Clear(); |
||||||
|
CheckpointFiles.AddRange(await GetCheckpointFilesAsync()); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,157 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia.Controls; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
[View(typeof(CheckpointsPage))] |
||||||
|
public partial class CheckpointsPageViewModel : PageViewModelBase |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
private readonly ISharedFolders sharedFolders; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly ModelFinder modelFinder; |
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
|
||||||
|
public override string Title => "Checkpoints"; |
||||||
|
|
||||||
|
public override IconSource IconSource => new SymbolIconSource |
||||||
|
{Symbol = Symbol.Notebook, IsFilled = true}; |
||||||
|
|
||||||
|
// Toggle button for auto hashing new drag-and-dropped files for connected upgrade |
||||||
|
[ObservableProperty] private bool isImportAsConnected; |
||||||
|
[ObservableProperty] private bool isLoading; |
||||||
|
[ObservableProperty] private bool isIndexing; |
||||||
|
[ObservableProperty] private string searchFilter; |
||||||
|
|
||||||
|
partial void OnIsImportAsConnectedChanged(bool value) |
||||||
|
{ |
||||||
|
if (settingsManager.IsLibraryDirSet && |
||||||
|
value != settingsManager.Settings.IsImportAsConnected) |
||||||
|
{ |
||||||
|
settingsManager.Transaction(s => s.IsImportAsConnected = value); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[ObservableProperty] private ObservableCollection<CheckpointFolder> checkpointFolders = new(); |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private ObservableCollection<CheckpointFolder> displayedCheckpointFolders = new(); |
||||||
|
|
||||||
|
public CheckpointsPageViewModel( |
||||||
|
ISharedFolders sharedFolders, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
IDownloadService downloadService, |
||||||
|
ModelFinder modelFinder) |
||||||
|
{ |
||||||
|
this.sharedFolders = sharedFolders; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.downloadService = downloadService; |
||||||
|
this.modelFinder = modelFinder; |
||||||
|
} |
||||||
|
|
||||||
|
public override async Task OnLoadedAsync() |
||||||
|
{ |
||||||
|
DisplayedCheckpointFolders = CheckpointFolders; |
||||||
|
if (Design.IsDesignMode) return; |
||||||
|
|
||||||
|
// Set UI states |
||||||
|
IsImportAsConnected = settingsManager.Settings.IsImportAsConnected; |
||||||
|
SearchFilter = string.Empty; |
||||||
|
|
||||||
|
IsLoading = CheckpointFolders.Count == 0; |
||||||
|
IsIndexing = CheckpointFolders.Count > 0; |
||||||
|
await IndexFolders(); |
||||||
|
IsLoading = false; |
||||||
|
IsIndexing = false; |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSearchFilterChanged(string value) |
||||||
|
{ |
||||||
|
var filteredFolders = CheckpointFolders |
||||||
|
.Where(ContainsSearchFilter).ToList(); |
||||||
|
foreach (var folder in filteredFolders) |
||||||
|
{ |
||||||
|
folder.SearchFilter = SearchFilter; |
||||||
|
} |
||||||
|
|
||||||
|
DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(filteredFolders); |
||||||
|
} |
||||||
|
|
||||||
|
private bool ContainsSearchFilter(CheckpointFolder folder) |
||||||
|
{ |
||||||
|
if (folder == null) |
||||||
|
throw new ArgumentNullException(nameof(folder)); |
||||||
|
|
||||||
|
// Check files in the current folder |
||||||
|
return folder.CheckpointFiles.Any(x => |
||||||
|
x.FileName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase)) || |
||||||
|
// If no matching files were found in the current folder, check in all subfolders |
||||||
|
folder.SubFolders.Any(subFolder => ContainsSearchFilter(subFolder)); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task IndexFolders() |
||||||
|
{ |
||||||
|
var modelsDirectory = settingsManager.ModelsDirectory; |
||||||
|
// Get all folders within the shared folder root |
||||||
|
if (string.IsNullOrWhiteSpace(modelsDirectory)) |
||||||
|
{ |
||||||
|
CheckpointFolders.Clear(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Skip if the shared folder root doesn't exist |
||||||
|
if (!Directory.Exists(modelsDirectory)) |
||||||
|
{ |
||||||
|
Logger.Debug($"Skipped shared folder index - {modelsDirectory} doesn't exist"); |
||||||
|
CheckpointFolders.Clear(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var folders = Directory.GetDirectories(modelsDirectory); |
||||||
|
|
||||||
|
// Index all folders |
||||||
|
var indexTasks = folders.Select(f => Task.Run(async () => |
||||||
|
{ |
||||||
|
var checkpointFolder = |
||||||
|
new CheckpointFolder(settingsManager, downloadService, modelFinder) |
||||||
|
{ |
||||||
|
Title = Path.GetFileName(f), |
||||||
|
DirectoryPath = f, |
||||||
|
IsExpanded = true, // Top level folders expanded by default |
||||||
|
}; |
||||||
|
await checkpointFolder.IndexAsync(); |
||||||
|
return checkpointFolder; |
||||||
|
})).ToList(); |
||||||
|
|
||||||
|
await Task.WhenAll(indexTasks); |
||||||
|
|
||||||
|
// Set new observable collection, ordered by alphabetical order |
||||||
|
CheckpointFolders = |
||||||
|
new ObservableCollection<CheckpointFolder>(indexTasks |
||||||
|
.Select(t => t.Result) |
||||||
|
.OrderBy(f => f.Title)); |
||||||
|
DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(CheckpointFolders |
||||||
|
.Where(x => x.CheckpointFiles.Any(y => y.FileName.Contains(SearchFilter)))); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task OpenModelsFolder() |
||||||
|
{ |
||||||
|
await ProcessRunner.OpenFolderBrowser(settingsManager.ModelsDirectory); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,462 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using System.Threading.Tasks.Dataflow; |
||||||
|
using Avalonia.Threading; |
||||||
|
using AvaloniaEdit.Document; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using Nito.AsyncEx; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
public partial class ConsoleViewModel : ObservableObject, IDisposable |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
|
||||||
|
// Queue for console updates |
||||||
|
private BufferBlock<ProcessOutput> buffer = new(); |
||||||
|
// Task that updates the console (runs on UI thread) |
||||||
|
private Task? updateTask; |
||||||
|
// Cancellation token source for updateTask |
||||||
|
private CancellationTokenSource? updateCts; |
||||||
|
|
||||||
|
public bool IsUpdatesRunning => updateTask?.IsCompleted == false; |
||||||
|
|
||||||
|
[ObservableProperty] private TextDocument document = new(); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Current offset for write operations. |
||||||
|
/// </summary> |
||||||
|
private int writeCursor; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Lock for accessing <see cref="writeCursor"/> |
||||||
|
/// </summary> |
||||||
|
private readonly AsyncLock writeCursorLock = new(); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Timeout for acquiring locks on <see cref="writeCursor"/> |
||||||
|
/// </summary> |
||||||
|
// ReSharper disable once MemberCanBePrivate.Global |
||||||
|
public TimeSpan WriteCursorLockTimeout { get; init; } = TimeSpan.FromMilliseconds(100); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets a cancellation token using the cursor lock timeout |
||||||
|
/// </summary> |
||||||
|
private CancellationToken WriteCursorLockTimeoutToken => |
||||||
|
new CancellationTokenSource(WriteCursorLockTimeout).Token; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Event invoked when an ApcMessage of type Input is received. |
||||||
|
/// </summary> |
||||||
|
public event EventHandler<ApcMessage>? ApcInput; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Starts update task for processing Post messages. |
||||||
|
/// </summary> |
||||||
|
/// <exception cref="InvalidOperationException">If update task is already running</exception> |
||||||
|
public void StartUpdates() |
||||||
|
{ |
||||||
|
if (updateTask is not null) |
||||||
|
{ |
||||||
|
throw new InvalidOperationException("Update task is already running"); |
||||||
|
} |
||||||
|
updateCts = new CancellationTokenSource(); |
||||||
|
updateTask = Dispatcher.UIThread.InvokeAsync(ConsoleUpdateLoop, DispatcherPriority.Render); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Cancels the update task and waits for it to complete. |
||||||
|
/// </summary> |
||||||
|
public async Task StopUpdatesAsync() |
||||||
|
{ |
||||||
|
Logger.Trace($"Stopping console updates, current buffer items: {buffer.Count}"); |
||||||
|
// First complete the buffer |
||||||
|
buffer.Complete(); |
||||||
|
// Wait for buffer to complete, max 3 seconds |
||||||
|
var completionCts = new CancellationTokenSource(3000); |
||||||
|
try |
||||||
|
{ |
||||||
|
await buffer.Completion.WaitAsync(completionCts.Token); |
||||||
|
} |
||||||
|
catch (TaskCanceledException e) |
||||||
|
{ |
||||||
|
// We can still continue since this just means we lose |
||||||
|
// some remaining output |
||||||
|
Logger.Warn("Buffer completion timed out: " + e.Message); |
||||||
|
} |
||||||
|
|
||||||
|
// Cancel update task |
||||||
|
updateCts?.Cancel(); |
||||||
|
updateCts = null; |
||||||
|
|
||||||
|
// Wait for update task |
||||||
|
if (updateTask is not null) |
||||||
|
{ |
||||||
|
await updateTask; |
||||||
|
updateTask = null; |
||||||
|
} |
||||||
|
Logger.Trace($"Stopped console updates with {buffer.Count} buffer items remaining"); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Clears the console and sets a new buffer. |
||||||
|
/// This also resets the write cursor to 0. |
||||||
|
/// </summary> |
||||||
|
public async Task Clear() |
||||||
|
{ |
||||||
|
// Clear document |
||||||
|
Document.Text = string.Empty; |
||||||
|
// Reset write cursor |
||||||
|
await ResetWriteCursor(); |
||||||
|
// Clear buffer and create new one |
||||||
|
buffer.Complete(); |
||||||
|
buffer = new BufferBlock<ProcessOutput>(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Resets the write cursor to be equal to the document length. |
||||||
|
/// </summary> |
||||||
|
public async Task ResetWriteCursor() |
||||||
|
{ |
||||||
|
using (await writeCursorLock.LockAsync(WriteCursorLockTimeoutToken)) |
||||||
|
{ |
||||||
|
Debug.WriteLine($"Reset cursor to end: ({writeCursor} -> {Document.TextLength})"); |
||||||
|
writeCursor = Document.TextLength; |
||||||
|
} |
||||||
|
DebugPrintDocument(); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task ConsoleUpdateLoop() |
||||||
|
{ |
||||||
|
// This must be run in the UI thread |
||||||
|
Dispatcher.UIThread.VerifyAccess(); |
||||||
|
|
||||||
|
// Get cancellation token |
||||||
|
var ct = updateCts?.Token |
||||||
|
?? throw new InvalidOperationException("Update cancellation token must be set"); |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
while (!ct.IsCancellationRequested) |
||||||
|
{ |
||||||
|
ProcessOutput output; |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
output = await buffer.ReceiveAsync(ct); |
||||||
|
} |
||||||
|
catch (InvalidOperationException e) |
||||||
|
{ |
||||||
|
// Thrown when buffer is completed, convert to OperationCanceledException |
||||||
|
throw new OperationCanceledException("Update buffer completed", e); |
||||||
|
} |
||||||
|
|
||||||
|
var outputType = output.IsStdErr ? "stderr" : "stdout"; |
||||||
|
Debug.WriteLine($"Processing: [{outputType}] (Text = {output.Text.ToRepr()}, " + |
||||||
|
$"Raw = {output.RawText?.ToRepr()}, " + |
||||||
|
$"CarriageReturn = {output.CarriageReturn}, " + |
||||||
|
$"CursorUp = {output.CursorUp}, " + |
||||||
|
$"AnsiCommand = {output.AnsiCommand})"); |
||||||
|
|
||||||
|
// Link the cancellation token to the write cursor lock timeout |
||||||
|
var linkedCt = CancellationTokenSource |
||||||
|
.CreateLinkedTokenSource(ct, WriteCursorLockTimeoutToken).Token; |
||||||
|
|
||||||
|
using (await writeCursorLock.LockAsync(linkedCt)) |
||||||
|
{ |
||||||
|
ConsoleUpdateOne(output); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
catch (OperationCanceledException e) |
||||||
|
{ |
||||||
|
Logger.Debug($"Console update loop canceled: {e.Message}"); |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
// Log other errors and continue here to not crash the UI thread |
||||||
|
Logger.Error(e, $"Unexpected error in console update loop: {e.GetType().Name} {e.Message}"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Handle one instance of ProcessOutput. |
||||||
|
/// Calls to this function must be synchronized with <see cref="writeCursorLock"/> |
||||||
|
/// </summary> |
||||||
|
/// <remarks>Not checked, but must be run in the UI thread.</remarks> |
||||||
|
private void ConsoleUpdateOne(ProcessOutput output) |
||||||
|
{ |
||||||
|
Debug.Assert(Dispatcher.UIThread.CheckAccess()); |
||||||
|
|
||||||
|
// Check for Apc messages |
||||||
|
if (output.ApcMessage is not null) |
||||||
|
{ |
||||||
|
// Handle Apc message, for now just input audit events |
||||||
|
var message = output.ApcMessage.Value; |
||||||
|
if (message.Type == ApcType.Input) |
||||||
|
{ |
||||||
|
ApcInput?.Invoke(this, message); |
||||||
|
} |
||||||
|
// Ignore further processing |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// If we have a carriage return, |
||||||
|
// start current write at the beginning of the current line |
||||||
|
if (output.CarriageReturn > 0) |
||||||
|
{ |
||||||
|
var currentLine = Document.GetLineByOffset(writeCursor); |
||||||
|
|
||||||
|
// Get the start of current line as new write cursor |
||||||
|
var lineStartOffset = currentLine.Offset; |
||||||
|
|
||||||
|
// See if we need to move the cursor |
||||||
|
if (lineStartOffset == writeCursor) |
||||||
|
{ |
||||||
|
Debug.WriteLine($"Cursor already at start for carriage return " + |
||||||
|
$"(offset = {lineStartOffset}, line = {currentLine.LineNumber})"); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
// Also remove everything on current line |
||||||
|
// We'll temporarily do this for now to fix progress |
||||||
|
var lineEndOffset = currentLine.EndOffset; |
||||||
|
var lineLength = lineEndOffset - lineStartOffset; |
||||||
|
Document.Remove(lineStartOffset, lineLength); |
||||||
|
|
||||||
|
Debug.WriteLine($"Moving cursor to start for carriage return " + |
||||||
|
$"({writeCursor} -> {lineStartOffset})"); |
||||||
|
writeCursor = lineStartOffset; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Write new text |
||||||
|
if (!string.IsNullOrEmpty(output.Text)) |
||||||
|
{ |
||||||
|
DirectWriteLinesToConsole(output.Text); |
||||||
|
} |
||||||
|
|
||||||
|
// Handle cursor movements |
||||||
|
if (output.CursorUp > 0) |
||||||
|
{ |
||||||
|
// Get the line and column of the current cursor |
||||||
|
var currentLocation = Document.GetLocation(writeCursor); |
||||||
|
|
||||||
|
if (currentLocation.Line == 1) |
||||||
|
{ |
||||||
|
// We are already on the first line, ignore |
||||||
|
Debug.WriteLine($"Cursor up: Already on first line"); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
// We want to move up one line |
||||||
|
var targetLocation = new TextLocation(currentLocation.Line - 1, currentLocation.Column); |
||||||
|
var targetOffset = Document.GetOffset(targetLocation); |
||||||
|
|
||||||
|
// Update cursor to target offset |
||||||
|
Debug.WriteLine($"Cursor up: Moving (line {currentLocation.Line}, {writeCursor})" + |
||||||
|
$" -> (line {targetLocation.Line}, {targetOffset})"); |
||||||
|
|
||||||
|
writeCursor = targetOffset; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Handle erase commands, different to cursor move as they don't move the cursor |
||||||
|
// We'll insert blank spaces instead |
||||||
|
if (output.AnsiCommand.HasFlag(AnsiCommand.EraseLine)) |
||||||
|
{ |
||||||
|
// Get the current line, we'll insert spaces from start to end |
||||||
|
var currentLine = Document.GetLineByOffset(writeCursor); |
||||||
|
// Make some spaces to insert |
||||||
|
var spaces = new string(' ', currentLine.Length); |
||||||
|
|
||||||
|
// Insert the text |
||||||
|
Debug.WriteLine($"Erasing line {currentLine.LineNumber}: (length = {currentLine.Length})"); |
||||||
|
using (Document.RunUpdate()) |
||||||
|
{ |
||||||
|
Document.Replace(currentLine.Offset, currentLine.Length, spaces); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
DebugPrintDocument(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Write text potentially containing line breaks to the console. |
||||||
|
/// <remarks>This call will hold a upgradeable read lock</remarks> |
||||||
|
/// </summary> |
||||||
|
private void DirectWriteLinesToConsole(string text) |
||||||
|
{ |
||||||
|
// When our cursor is not at end, newlines should be interpreted as commands to |
||||||
|
// move cursor forward to the next linebreak instead of inserting a newline. |
||||||
|
|
||||||
|
// If text contains no newlines, we can just call DirectWriteToConsole |
||||||
|
// Also if cursor is equal to document length |
||||||
|
if (!text.Contains(Environment.NewLine) || writeCursor == Document.TextLength) |
||||||
|
{ |
||||||
|
DirectWriteToConsole(text); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise we need to handle how linebreaks are treated |
||||||
|
// Split text into lines |
||||||
|
var lines = text.Split(Environment.NewLine).ToList(); |
||||||
|
|
||||||
|
foreach (var lineText in lines.SkipLast(1)) |
||||||
|
{ |
||||||
|
// Insert text |
||||||
|
DirectWriteToConsole(lineText); |
||||||
|
|
||||||
|
// Set cursor to start of next line, if we're not already there |
||||||
|
var currentLine = Document.GetLineByOffset(writeCursor); |
||||||
|
// If next line is available, move cursor to start of next line |
||||||
|
if (currentLine.LineNumber < Document.LineCount) |
||||||
|
{ |
||||||
|
var nextLine = Document.GetLineByNumber(currentLine.LineNumber + 1); |
||||||
|
Debug.WriteLine($"Moving cursor to start of next line " + |
||||||
|
$"({writeCursor} -> {nextLine.Offset})"); |
||||||
|
writeCursor = nextLine.Offset; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
// Otherwise move to end of current line, and direct insert a newline |
||||||
|
var lineEndOffset = currentLine.EndOffset; |
||||||
|
Debug.WriteLine($"Moving cursor to end of current line " + |
||||||
|
$"({writeCursor} -> {lineEndOffset})"); |
||||||
|
writeCursor = lineEndOffset; |
||||||
|
DirectWriteToConsole(Environment.NewLine); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Write text to the console, does not handle newlines. |
||||||
|
/// This should probably only be used by <see cref="DirectWriteLinesToConsole"/> |
||||||
|
/// <remarks>This call will hold a upgradeable read lock</remarks> |
||||||
|
/// </summary> |
||||||
|
private void DirectWriteToConsole(string text) |
||||||
|
{ |
||||||
|
using (Document.RunUpdate()) |
||||||
|
{ |
||||||
|
// Need to replace text first if cursor lower than document length |
||||||
|
var replaceLength = Math.Min(Document.TextLength - writeCursor, text.Length); |
||||||
|
if (replaceLength > 0) |
||||||
|
{ |
||||||
|
var newText = text[..replaceLength]; |
||||||
|
Debug.WriteLine( |
||||||
|
$"Replacing: (cursor = {writeCursor}, length = {replaceLength}, " + |
||||||
|
$"text = {Document.GetText(writeCursor, replaceLength).ToRepr()} " + |
||||||
|
$"-> {newText.ToRepr()})"); |
||||||
|
|
||||||
|
Document.Replace(writeCursor, replaceLength, newText); |
||||||
|
writeCursor += replaceLength; |
||||||
|
} |
||||||
|
|
||||||
|
// If we replaced less than content.Length, we need to insert the rest |
||||||
|
var remainingLength = text.Length - replaceLength; |
||||||
|
if (remainingLength > 0) |
||||||
|
{ |
||||||
|
var textToInsert = text[replaceLength..]; |
||||||
|
Debug.WriteLine($"Inserting: (cursor = {writeCursor}, " + |
||||||
|
$"text = {textToInsert.ToRepr()})"); |
||||||
|
|
||||||
|
Document.Insert(writeCursor, textToInsert); |
||||||
|
writeCursor += textToInsert.Length; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Debug function to print the current document to the console. |
||||||
|
/// Includes formatted cursor position. |
||||||
|
/// </summary> |
||||||
|
[Conditional("DEBUG")] |
||||||
|
private void DebugPrintDocument() |
||||||
|
{ |
||||||
|
var text = Document.Text; |
||||||
|
// Add a number for each line |
||||||
|
// Add an arrow line for where the cursor is, for example (cursor on offset 3): |
||||||
|
// |
||||||
|
// 1 | This is the first line |
||||||
|
// ~~~~~~~^ (3) |
||||||
|
// 2 | This is the second line |
||||||
|
// |
||||||
|
|
||||||
|
var lines = text.Split(Environment.NewLine).ToList(); |
||||||
|
var numberPadding = lines.Count.ToString().Length; |
||||||
|
for (var i = 0; i < lines.Count; i++) |
||||||
|
{ |
||||||
|
lines[i] = $"{(i + 1).ToString().PadLeft(numberPadding)} | {lines[i]}"; |
||||||
|
} |
||||||
|
var cursorLine = Document.GetLineByOffset(writeCursor); |
||||||
|
var cursorLineOffset = writeCursor - cursorLine.Offset; |
||||||
|
|
||||||
|
// Need to account for padding + line number + space + cursor line offset |
||||||
|
var linePadding = numberPadding + 3 + cursorLineOffset; |
||||||
|
var cursorLineArrow = new string('~', linePadding) + $"^ ({writeCursor})"; |
||||||
|
|
||||||
|
// If more than line count, append to end |
||||||
|
if (cursorLine.LineNumber >= lines.Count) |
||||||
|
{ |
||||||
|
lines.Add(cursorLineArrow); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
lines.Insert(cursorLine.LineNumber, cursorLineArrow); |
||||||
|
} |
||||||
|
|
||||||
|
var textWithCursor = string.Join(Environment.NewLine, lines); |
||||||
|
|
||||||
|
Debug.WriteLine("[Current Document]"); |
||||||
|
Debug.WriteLine(textWithCursor); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Posts an update to the console |
||||||
|
/// <remarks>Safe to call on non-UI threads</remarks> |
||||||
|
/// </summary> |
||||||
|
public void Post(ProcessOutput output) |
||||||
|
{ |
||||||
|
// If update task is running, send to buffer |
||||||
|
if (updateTask != null) |
||||||
|
{ |
||||||
|
buffer.Post(output); |
||||||
|
return; |
||||||
|
} |
||||||
|
// Otherwise, use manual update one |
||||||
|
Dispatcher.UIThread.Post(() => ConsoleUpdateOne(output)); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Posts an update to the console. |
||||||
|
/// Helper for calling Post(ProcessOutput) with strings |
||||||
|
/// </summary> |
||||||
|
public void Post(string text) |
||||||
|
{ |
||||||
|
Post(new ProcessOutput { Text = text }); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Posts an update to the console. |
||||||
|
/// Equivalent to Post(text + Environment.NewLine) |
||||||
|
/// </summary> |
||||||
|
public void PostLine(string text) |
||||||
|
{ |
||||||
|
Post(new ProcessOutput { Text = text + Environment.NewLine }); |
||||||
|
} |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
updateCts?.Cancel(); |
||||||
|
updateTask?.Dispose(); |
||||||
|
updateCts?.Dispose(); |
||||||
|
|
||||||
|
GC.SuppressFinalize(this); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
public partial class CivitFileViewModel : ObservableObject |
||||||
|
{ |
||||||
|
[ObservableProperty] private CivitFile civitFile; |
||||||
|
[ObservableProperty] private bool isInstalled; |
||||||
|
|
||||||
|
public CivitFileViewModel(ISettingsManager settingsManager, CivitFile civitFile) |
||||||
|
{ |
||||||
|
CivitFile = civitFile; |
||||||
|
|
||||||
|
var installedModelHashes = settingsManager.Settings.InstalledModelHashes; |
||||||
|
IsInstalled = CivitFile is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} && |
||||||
|
installedModelHashes.Contains(CivitFile.Hashes.BLAKE3); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
using System; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
public class ContentDialogViewModelBase : ViewModelBase |
||||||
|
{ |
||||||
|
// Events for button clicks |
||||||
|
public event EventHandler<ContentDialogResult>? PrimaryButtonClick; |
||||||
|
public event EventHandler<ContentDialogResult>? SecondaryButtonClick; |
||||||
|
public event EventHandler<ContentDialogResult>? CloseButtonClick; |
||||||
|
|
||||||
|
public virtual void OnPrimaryButtonClick() |
||||||
|
{ |
||||||
|
PrimaryButtonClick?.Invoke(this, ContentDialogResult.Primary); |
||||||
|
} |
||||||
|
|
||||||
|
public virtual void OnSecondaryButtonClick() |
||||||
|
{ |
||||||
|
SecondaryButtonClick?.Invoke(this, ContentDialogResult.Secondary); |
||||||
|
} |
||||||
|
|
||||||
|
public virtual void OnCloseButtonClick() |
||||||
|
{ |
||||||
|
CloseButtonClick?.Invoke(this, ContentDialogResult.None); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,16 @@ |
|||||||
|
using System; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
[View(typeof(ExceptionDialog))] |
||||||
|
public partial class ExceptionViewModel : ViewModelBase |
||||||
|
{ |
||||||
|
public Exception? Exception { get; set; } |
||||||
|
|
||||||
|
public string? Message => Exception?.Message; |
||||||
|
|
||||||
|
public string? ExceptionType => Exception?.GetType().Name ?? ""; |
||||||
|
} |
@ -0,0 +1,461 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using Avalonia.Layout; |
||||||
|
using Avalonia.Media; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Helper.Factory; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.Database; |
||||||
|
using StabilityMatrix.Core.Models.Packages; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Python; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
|
||||||
|
public partial class InstallerViewModel : ContentDialogViewModelBase |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
|
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly IPyRunner pyRunner; |
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
private readonly INotificationService notificationService; |
||||||
|
private readonly ISharedFolders sharedFolders; |
||||||
|
private readonly IPrerequisiteHelper prerequisiteHelper; |
||||||
|
|
||||||
|
[ObservableProperty] private BasePackage selectedPackage; |
||||||
|
[ObservableProperty] private PackageVersion? selectedVersion; |
||||||
|
|
||||||
|
[ObservableProperty] private IReadOnlyList<BasePackage>? availablePackages; |
||||||
|
[ObservableProperty] private ObservableCollection<GitCommit>? availableCommits; |
||||||
|
[ObservableProperty] private ObservableCollection<PackageVersion>? availableVersions; |
||||||
|
|
||||||
|
[ObservableProperty] private GitCommit? selectedCommit; |
||||||
|
|
||||||
|
[ObservableProperty] private string? releaseNotes; |
||||||
|
|
||||||
|
// Version types (release or commit) |
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(ReleaseLabelText), |
||||||
|
nameof(IsReleaseMode), nameof(SelectedVersion))] |
||||||
|
private PackageVersionType selectedVersionType = PackageVersionType.Commit; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(IsReleaseModeAvailable))] |
||||||
|
private PackageVersionType availableVersionTypes = |
||||||
|
PackageVersionType.GithubRelease | PackageVersionType.Commit; |
||||||
|
public string ReleaseLabelText => IsReleaseMode ? "Version" : "Branch"; |
||||||
|
public bool IsReleaseMode |
||||||
|
{ |
||||||
|
get => SelectedVersionType == PackageVersionType.GithubRelease; |
||||||
|
set => SelectedVersionType = value ? PackageVersionType.GithubRelease : PackageVersionType.Commit; |
||||||
|
} |
||||||
|
|
||||||
|
public bool IsReleaseModeAvailable => AvailableVersionTypes.HasFlag(PackageVersionType.GithubRelease); |
||||||
|
|
||||||
|
[ObservableProperty] private bool showDuplicateWarning; |
||||||
|
|
||||||
|
[ObservableProperty] private string? installName; |
||||||
|
|
||||||
|
public ProgressViewModel InstallProgress { get; } = new(); |
||||||
|
|
||||||
|
public InstallerViewModel( |
||||||
|
ISettingsManager settingsManager, |
||||||
|
IPackageFactory packageFactory, |
||||||
|
IPyRunner pyRunner, |
||||||
|
IDownloadService downloadService, INotificationService notificationService, |
||||||
|
ISharedFolders sharedFolders, |
||||||
|
IPrerequisiteHelper prerequisiteHelper) |
||||||
|
{ |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.pyRunner = pyRunner; |
||||||
|
this.downloadService = downloadService; |
||||||
|
this.notificationService = notificationService; |
||||||
|
this.sharedFolders = sharedFolders; |
||||||
|
this.prerequisiteHelper = prerequisiteHelper; |
||||||
|
|
||||||
|
// AvailablePackages and SelectedPackage |
||||||
|
AvailablePackages = new ObservableCollection<BasePackage>(packageFactory.GetAllAvailablePackages()); |
||||||
|
SelectedPackage = AvailablePackages[0]; |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnLoaded() |
||||||
|
{ |
||||||
|
if (AvailablePackages == null) return; |
||||||
|
SelectedPackage = AvailablePackages[0]; |
||||||
|
IsReleaseMode = !SelectedPackage.ShouldIgnoreReleases; |
||||||
|
} |
||||||
|
|
||||||
|
public override async Task OnLoadedAsync() |
||||||
|
{ |
||||||
|
if (Design.IsDesignMode) return; |
||||||
|
// Check for updates |
||||||
|
try |
||||||
|
{ |
||||||
|
if (IsReleaseMode) |
||||||
|
{ |
||||||
|
var versions = (await SelectedPackage.GetAllVersions()).ToList(); |
||||||
|
AvailableVersions = new ObservableCollection<PackageVersion>(versions); |
||||||
|
if (!AvailableVersions.Any()) return; |
||||||
|
|
||||||
|
SelectedVersion = AvailableVersions[0]; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
var branches = (await SelectedPackage.GetAllBranches()).ToList(); |
||||||
|
AvailableVersions = new ObservableCollection<PackageVersion>(branches.Select(b => |
||||||
|
new PackageVersion |
||||||
|
{ |
||||||
|
TagName = b.Name, |
||||||
|
ReleaseNotesMarkdown = b.Commit.Label |
||||||
|
})); |
||||||
|
UpdateSelectedVersionToLatestMain(); |
||||||
|
} |
||||||
|
|
||||||
|
ReleaseNotes = SelectedVersion?.ReleaseNotesMarkdown; |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
Logger.Warn("Error getting versions: {Exception}", e.ToString()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task Install() |
||||||
|
{ |
||||||
|
var result = await notificationService.TryAsync(ActuallyInstall(), "Could not install package"); |
||||||
|
if (result.IsSuccessful) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification( |
||||||
|
$"Package {SelectedPackage.Name} installed successfully!", |
||||||
|
"Success", NotificationType.Success)); |
||||||
|
OnPrimaryButtonClick(); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
var ex = result.Exception!; |
||||||
|
Logger.Error(ex, $"Error installing package: {ex}"); |
||||||
|
|
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
Title = "Error installing package", |
||||||
|
Content = ex.ToString(), |
||||||
|
CloseButtonText = "Close" |
||||||
|
}; |
||||||
|
await dialog.ShowAsync(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async Task ActuallyInstall() |
||||||
|
{ |
||||||
|
if (string.IsNullOrWhiteSpace(InstallName)) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification("Package name is empty", |
||||||
|
"Please enter a name for the package", NotificationType.Error)); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
await InstallGitIfNecessary(); |
||||||
|
|
||||||
|
SelectedPackage.InstallLocation = Path.Combine( |
||||||
|
settingsManager.LibraryDir, "Packages", InstallName); |
||||||
|
|
||||||
|
if (!PyRunner.PipInstalled || !PyRunner.VenvInstalled) |
||||||
|
{ |
||||||
|
InstallProgress.Text = "Installing dependencies..."; |
||||||
|
InstallProgress.IsIndeterminate = true; |
||||||
|
await pyRunner.Initialize(); |
||||||
|
|
||||||
|
if (!PyRunner.PipInstalled) |
||||||
|
{ |
||||||
|
await pyRunner.SetupPip(); |
||||||
|
} |
||||||
|
if (!PyRunner.VenvInstalled) |
||||||
|
{ |
||||||
|
await pyRunner.InstallPackage("virtualenv"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
string version; |
||||||
|
if (IsReleaseMode) |
||||||
|
{ |
||||||
|
version = SelectedVersion?.TagName ?? |
||||||
|
throw new NullReferenceException("Selected version is null"); |
||||||
|
|
||||||
|
await DownloadPackage(version, false); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
version = SelectedCommit?.Sha ?? |
||||||
|
throw new NullReferenceException("Selected commit is null"); |
||||||
|
|
||||||
|
await DownloadPackage(version, true); |
||||||
|
} |
||||||
|
|
||||||
|
await InstallPackage(); |
||||||
|
|
||||||
|
InstallProgress.Text = "Setting up shared folder links..."; |
||||||
|
sharedFolders.SetupLinksForPackage(SelectedPackage, SelectedPackage.InstallLocation); |
||||||
|
|
||||||
|
InstallProgress.Text = "Done"; |
||||||
|
InstallProgress.IsIndeterminate = false; |
||||||
|
InstallProgress.Value = 100; |
||||||
|
EventManager.Instance.OnGlobalProgressChanged(100); |
||||||
|
|
||||||
|
var branch = SelectedVersionType == PackageVersionType.GithubRelease ? |
||||||
|
null : SelectedVersion!.TagName; |
||||||
|
|
||||||
|
var package = new InstalledPackage |
||||||
|
{ |
||||||
|
DisplayName = InstallName, |
||||||
|
LibraryPath = Path.Combine("Packages", InstallName), |
||||||
|
Id = Guid.NewGuid(), |
||||||
|
PackageName = SelectedPackage.Name, |
||||||
|
PackageVersion = version, |
||||||
|
DisplayVersion = GetDisplayVersion(version, branch), |
||||||
|
InstalledBranch = branch, |
||||||
|
LaunchCommand = SelectedPackage.LaunchCommand, |
||||||
|
LastUpdateCheck = DateTimeOffset.Now |
||||||
|
}; |
||||||
|
await using var st = settingsManager.BeginTransaction(); |
||||||
|
st.Settings.InstalledPackages.Add(package); |
||||||
|
st.Settings.ActiveInstalledPackage = package.Id; |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
InstallProgress.Value = 0; |
||||||
|
InstallProgress.IsIndeterminate = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void UpdateSelectedVersionToLatestMain() |
||||||
|
{ |
||||||
|
if (AvailableVersions is null) |
||||||
|
{ |
||||||
|
SelectedVersion = null; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
// First try to find master |
||||||
|
var version = AvailableVersions.FirstOrDefault(x => x.TagName == "master"); |
||||||
|
// If not found, try main |
||||||
|
version ??= AvailableVersions.FirstOrDefault(x => x.TagName == "main"); |
||||||
|
|
||||||
|
// If still not found, just use the first one |
||||||
|
version ??= AvailableVersions[0]; |
||||||
|
|
||||||
|
SelectedVersion = version; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static string GetDisplayVersion(string version, string? branch) |
||||||
|
{ |
||||||
|
return branch == null ? version : $"{branch}@{version[..7]}"; |
||||||
|
} |
||||||
|
|
||||||
|
private Task<string> DownloadPackage(string version, bool isCommitHash) |
||||||
|
{ |
||||||
|
InstallProgress.Text = "Downloading package..."; |
||||||
|
|
||||||
|
var progress = new Progress<ProgressReport>(progress => |
||||||
|
{ |
||||||
|
InstallProgress.IsIndeterminate = progress.IsIndeterminate; |
||||||
|
InstallProgress.Value = progress.Percentage; |
||||||
|
EventManager.Instance.OnGlobalProgressChanged((int) progress.Percentage); |
||||||
|
}); |
||||||
|
|
||||||
|
return SelectedPackage.DownloadPackage(version, isCommitHash, progress); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task InstallPackage() |
||||||
|
{ |
||||||
|
InstallProgress.Text = "Installing package..."; |
||||||
|
SelectedPackage.ConsoleOutput += SelectedPackageOnConsoleOutput; |
||||||
|
try |
||||||
|
{ |
||||||
|
var progress = new Progress<ProgressReport>(progress => |
||||||
|
{ |
||||||
|
InstallProgress.IsIndeterminate = progress.IsIndeterminate; |
||||||
|
InstallProgress.Value = progress.Percentage; |
||||||
|
EventManager.Instance.OnGlobalProgressChanged((int) progress.Percentage); |
||||||
|
}); |
||||||
|
|
||||||
|
await SelectedPackage.InstallPackage(progress); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
SelectedPackage.ConsoleOutput -= SelectedPackageOnConsoleOutput; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void SelectedPackageOnConsoleOutput(object? sender, ProcessOutput e) |
||||||
|
{ |
||||||
|
InstallProgress.Description = e.Text; |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task ShowPreview() |
||||||
|
{ |
||||||
|
var url = SelectedPackage.PreviewImageUri.ToString(); |
||||||
|
var imageStream = await downloadService.GetImageStreamFromUrl(url); |
||||||
|
var bitmap = new Bitmap(imageStream); |
||||||
|
|
||||||
|
var dialog = new ContentDialog |
||||||
|
{ |
||||||
|
Title = "Test title", |
||||||
|
PrimaryButtonText = "Open in Browser", |
||||||
|
CloseButtonText = "Close", |
||||||
|
Content = new Image |
||||||
|
{ |
||||||
|
Source = bitmap, |
||||||
|
Stretch = Stretch.Uniform, |
||||||
|
MaxHeight = 500, |
||||||
|
HorizontalAlignment = HorizontalAlignment.Center |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
var result = await dialog.ShowAsync(); |
||||||
|
if (result == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
ProcessRunner.OpenUrl(url); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// When available version types change, reset selected version type if not compatible |
||||||
|
partial void OnAvailableVersionTypesChanged(PackageVersionType value) |
||||||
|
{ |
||||||
|
if (!value.HasFlag(SelectedVersionType)) |
||||||
|
{ |
||||||
|
SelectedVersionType = value; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// When changing branch / release modes, refresh |
||||||
|
// ReSharper disable once UnusedParameterInPartialMethod |
||||||
|
partial void OnSelectedVersionTypeChanged(PackageVersionType value) => OnSelectedPackageChanged(SelectedPackage); |
||||||
|
|
||||||
|
partial void OnSelectedPackageChanged(BasePackage value) |
||||||
|
{ |
||||||
|
ReleaseNotes = string.Empty; |
||||||
|
AvailableVersions?.Clear(); |
||||||
|
AvailableCommits?.Clear(); |
||||||
|
|
||||||
|
AvailableVersionTypes = SelectedPackage.ShouldIgnoreReleases |
||||||
|
? PackageVersionType.Commit |
||||||
|
: PackageVersionType.GithubRelease | PackageVersionType.Commit; |
||||||
|
|
||||||
|
if (Design.IsDesignMode) return; |
||||||
|
|
||||||
|
Dispatcher.UIThread.InvokeAsync(async () => |
||||||
|
{ |
||||||
|
Logger.Debug($"Release mode: {IsReleaseMode}"); |
||||||
|
var versions = (await value.GetAllVersions(IsReleaseMode)).ToList(); |
||||||
|
|
||||||
|
if (!versions.Any()) return; |
||||||
|
|
||||||
|
AvailableVersions = new ObservableCollection<PackageVersion>(versions); |
||||||
|
Logger.Debug($"Available versions: {string.Join(", ", AvailableVersions)}"); |
||||||
|
SelectedVersion = AvailableVersions[0]; |
||||||
|
ReleaseNotes = versions.First().ReleaseNotesMarkdown; |
||||||
|
Logger.Debug($"Loaded release notes for {ReleaseNotes}"); |
||||||
|
|
||||||
|
if (!IsReleaseMode) |
||||||
|
{ |
||||||
|
var commits = (await value.GetAllCommits(SelectedVersion.TagName))?.ToList(); |
||||||
|
if (commits is null || commits.Count == 0) return; |
||||||
|
|
||||||
|
AvailableCommits = new ObservableCollection<GitCommit>(commits); |
||||||
|
SelectedCommit = AvailableCommits[0]; |
||||||
|
UpdateSelectedVersionToLatestMain(); |
||||||
|
} |
||||||
|
|
||||||
|
InstallName = SelectedPackage.DisplayName; |
||||||
|
}).SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task InstallGitIfNecessary() |
||||||
|
{ |
||||||
|
var progressHandler = new Progress<ProgressReport>(progress => |
||||||
|
{ |
||||||
|
if (progress.Message != null && progress.Message.Contains("Downloading")) |
||||||
|
{ |
||||||
|
InstallProgress.Text = $"Downloading prerequisites... {progress.Percentage:N0}%"; |
||||||
|
} |
||||||
|
else if (progress.Type == ProgressType.Extract) |
||||||
|
{ |
||||||
|
InstallProgress.Text = $"Installing git... {progress.Percentage:N0}%"; |
||||||
|
} |
||||||
|
else if (progress.Title != null && progress.Title.Contains("Unpacking")) |
||||||
|
{ |
||||||
|
InstallProgress.Text = $"Unpacking resources... {progress.Percentage:N0}%"; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
InstallProgress.Text = progress.Message; |
||||||
|
} |
||||||
|
|
||||||
|
InstallProgress.IsIndeterminate = progress.IsIndeterminate; |
||||||
|
InstallProgress.Value = Convert.ToInt32(progress.Percentage); |
||||||
|
}); |
||||||
|
|
||||||
|
await prerequisiteHelper.InstallAllIfNecessary(progressHandler); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnInstallNameChanged(string? value) |
||||||
|
{ |
||||||
|
ShowDuplicateWarning = |
||||||
|
settingsManager.Settings.InstalledPackages.Any(p => |
||||||
|
p.LibraryPath == $"Packages{Path.DirectorySeparatorChar}{value}"); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedVersionChanged(PackageVersion? value) |
||||||
|
{ |
||||||
|
ReleaseNotes = value?.ReleaseNotesMarkdown ?? string.Empty; |
||||||
|
if (value == null) return; |
||||||
|
|
||||||
|
SelectedCommit = null; |
||||||
|
AvailableCommits?.Clear(); |
||||||
|
|
||||||
|
if (!IsReleaseMode) |
||||||
|
{ |
||||||
|
Task.Run(async () => |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
var hashes = await SelectedPackage.GetAllCommits(value.TagName); |
||||||
|
if (hashes is null) throw new Exception("No commits found"); |
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(() => |
||||||
|
{ |
||||||
|
AvailableCommits = new ObservableCollection<GitCommit>(hashes); |
||||||
|
SelectedCommit = AvailableCommits[0]; |
||||||
|
}); |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
Logger.Warn($"Error getting commits: {e.Message}"); |
||||||
|
} |
||||||
|
}).SafeFireAndForget(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,86 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.Immutable; |
||||||
|
using System.Linq; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using FuzzySharp; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Helper.Cache; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
[View(typeof(LaunchOptionsDialog))] |
||||||
|
public partial class LaunchOptionsViewModel : ContentDialogViewModelBase |
||||||
|
{ |
||||||
|
private readonly LRUCache<string, ImmutableList<LaunchOptionCard>> cache = new(100); |
||||||
|
|
||||||
|
[ObservableProperty] private string title = "Launch Options"; |
||||||
|
[ObservableProperty] private bool isSearchBoxEnabled = true; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(FilteredCards))] |
||||||
|
private string searchText = string.Empty; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private IReadOnlyList<LaunchOptionCard>? filteredCards; |
||||||
|
|
||||||
|
public IReadOnlyList<LaunchOptionCard>? Cards { get; set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Return cards that match the search text |
||||||
|
/// </summary> |
||||||
|
private IReadOnlyList<LaunchOptionCard>? GetFilteredCards() |
||||||
|
{ |
||||||
|
var text = SearchText; |
||||||
|
if (string.IsNullOrWhiteSpace(text) || text.Length < 2) |
||||||
|
{ |
||||||
|
return Cards; |
||||||
|
} |
||||||
|
// Try cache |
||||||
|
if (cache.Get(text, out var cachedCards)) |
||||||
|
{ |
||||||
|
return cachedCards!; |
||||||
|
} |
||||||
|
|
||||||
|
var searchCard = new LaunchOptionCard |
||||||
|
{ |
||||||
|
Title = text.ToLowerInvariant(), |
||||||
|
Type = LaunchOptionType.Bool, |
||||||
|
Options = Array.Empty<LaunchOption>() |
||||||
|
}; |
||||||
|
|
||||||
|
var extracted = Process |
||||||
|
.ExtractTop(searchCard, Cards, c => c.Title.ToLowerInvariant()); |
||||||
|
var results = extracted |
||||||
|
.Where(r => r.Score > 40) |
||||||
|
.Select(r => r.Value) |
||||||
|
.ToImmutableList(); |
||||||
|
cache.Add(text, results); |
||||||
|
return results; |
||||||
|
} |
||||||
|
|
||||||
|
public void UpdateFilterCards() => FilteredCards = GetFilteredCards(); |
||||||
|
|
||||||
|
public override void OnLoaded() |
||||||
|
{ |
||||||
|
base.OnLoaded(); |
||||||
|
UpdateFilterCards(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Export the current cards options to a list of strings |
||||||
|
/// </summary> |
||||||
|
public List<LaunchOption> AsLaunchArgs() |
||||||
|
{ |
||||||
|
var launchArgs = new List<LaunchOption>(); |
||||||
|
if (Cards is null) return launchArgs; |
||||||
|
|
||||||
|
foreach (var card in Cards) |
||||||
|
{ |
||||||
|
launchArgs.AddRange(card.Options); |
||||||
|
} |
||||||
|
return launchArgs; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,30 @@ |
|||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.Linq; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
public partial class ModelVersionViewModel : ObservableObject |
||||||
|
{ |
||||||
|
[ObservableProperty] private CivitModelVersion modelVersion; |
||||||
|
[ObservableProperty] private ObservableCollection<CivitFileViewModel> civitFileViewModels; |
||||||
|
[ObservableProperty] private bool isInstalled; |
||||||
|
|
||||||
|
public ModelVersionViewModel(ISettingsManager settingsManager, CivitModelVersion modelVersion) |
||||||
|
{ |
||||||
|
ModelVersion = modelVersion; |
||||||
|
|
||||||
|
var installedModelHashes = settingsManager.Settings.InstalledModelHashes; |
||||||
|
IsInstalled = ModelVersion.Files?.Any(file => |
||||||
|
file is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} && |
||||||
|
installedModelHashes.Contains(file.Hashes.BLAKE3)) ?? |
||||||
|
false; |
||||||
|
|
||||||
|
CivitFileViewModels = new ObservableCollection<CivitFileViewModel>( |
||||||
|
ModelVersion.Files?.Select(file => new CivitFileViewModel(settingsManager, file)) ?? |
||||||
|
new List<CivitFileViewModel>()); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,178 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.IO; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Helper.Factory; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.Packages; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Python; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
public partial class OneClickInstallViewModel : ViewModelBase |
||||||
|
{ |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly IPackageFactory packageFactory; |
||||||
|
private readonly IPrerequisiteHelper prerequisiteHelper; |
||||||
|
private readonly ILogger<OneClickInstallViewModel> logger; |
||||||
|
private readonly IPyRunner pyRunner; |
||||||
|
private readonly ISharedFolders sharedFolders; |
||||||
|
private const string DefaultPackageName = "stable-diffusion-webui"; |
||||||
|
|
||||||
|
[ObservableProperty] private string headerText; |
||||||
|
[ObservableProperty] private string subHeaderText; |
||||||
|
[ObservableProperty] private string subSubHeaderText = string.Empty; |
||||||
|
[ObservableProperty] private bool showInstallButton; |
||||||
|
[ObservableProperty] private bool isIndeterminate; |
||||||
|
[ObservableProperty] private ObservableCollection<BasePackage> allPackages; |
||||||
|
[ObservableProperty] private BasePackage selectedPackage; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(IsProgressBarVisible))] |
||||||
|
private int oneClickInstallProgress; |
||||||
|
|
||||||
|
public bool IsProgressBarVisible => OneClickInstallProgress > 0 || IsIndeterminate; |
||||||
|
|
||||||
|
public OneClickInstallViewModel(ISettingsManager settingsManager, IPackageFactory packageFactory, |
||||||
|
IPrerequisiteHelper prerequisiteHelper, ILogger<OneClickInstallViewModel> logger, IPyRunner pyRunner, |
||||||
|
ISharedFolders sharedFolders) |
||||||
|
{ |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.packageFactory = packageFactory; |
||||||
|
this.prerequisiteHelper = prerequisiteHelper; |
||||||
|
this.logger = logger; |
||||||
|
this.pyRunner = pyRunner; |
||||||
|
this.sharedFolders = sharedFolders; |
||||||
|
|
||||||
|
HeaderText = "Welcome to Stability Matrix!"; |
||||||
|
SubHeaderText = "Choose your preferred interface and click Install to get started!"; |
||||||
|
ShowInstallButton = true; |
||||||
|
AllPackages = |
||||||
|
new ObservableCollection<BasePackage>(this.packageFactory.GetAllAvailablePackages()); |
||||||
|
SelectedPackage = AllPackages[0]; |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task Install() |
||||||
|
{ |
||||||
|
ShowInstallButton = false; |
||||||
|
await DoInstall(); |
||||||
|
ShowInstallButton = true; |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private Task ToggleAdvancedMode() |
||||||
|
{ |
||||||
|
EventManager.Instance.OnOneClickInstallFinished(true); |
||||||
|
return Task.CompletedTask; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task DoInstall() |
||||||
|
{ |
||||||
|
HeaderText = $"Installing {SelectedPackage.DisplayName}"; |
||||||
|
|
||||||
|
var progressHandler = new Progress<ProgressReport>(progress => |
||||||
|
{ |
||||||
|
SubHeaderText = $"{progress.Title} {progress.Percentage:N0}%"; |
||||||
|
|
||||||
|
IsIndeterminate = progress.IsIndeterminate; |
||||||
|
OneClickInstallProgress = Convert.ToInt32(progress.Percentage); |
||||||
|
}); |
||||||
|
|
||||||
|
await prerequisiteHelper.InstallAllIfNecessary(progressHandler); |
||||||
|
|
||||||
|
SubHeaderText = "Installing prerequisites..."; |
||||||
|
IsIndeterminate = true; |
||||||
|
if (!PyRunner.PipInstalled) |
||||||
|
{ |
||||||
|
await pyRunner.SetupPip(); |
||||||
|
} |
||||||
|
|
||||||
|
if (!PyRunner.VenvInstalled) |
||||||
|
{ |
||||||
|
await pyRunner.InstallPackage("virtualenv"); |
||||||
|
} |
||||||
|
IsIndeterminate = false; |
||||||
|
|
||||||
|
var libraryDir = settingsManager.LibraryDir; |
||||||
|
|
||||||
|
// get latest version & download & install |
||||||
|
SubHeaderText = "Getting latest version..."; |
||||||
|
var latestVersion = await SelectedPackage.GetLatestVersion(); |
||||||
|
SelectedPackage.InstallLocation = |
||||||
|
Path.Combine(libraryDir, "Packages", SelectedPackage.DisplayName); |
||||||
|
SelectedPackage.ConsoleOutput += (_, output) => SubSubHeaderText = output.Text; |
||||||
|
|
||||||
|
await DownloadPackage(latestVersion); |
||||||
|
await InstallPackage(); |
||||||
|
|
||||||
|
SubHeaderText = "Setting up shared folder links..."; |
||||||
|
sharedFolders.SetupLinksForPackage(SelectedPackage, SelectedPackage.InstallLocation); |
||||||
|
|
||||||
|
var installedPackage = new InstalledPackage |
||||||
|
{ |
||||||
|
DisplayName = SelectedPackage.DisplayName, |
||||||
|
LibraryPath = Path.Combine("Packages", SelectedPackage.DisplayName), |
||||||
|
Id = Guid.NewGuid(), |
||||||
|
PackageName = SelectedPackage.Name, |
||||||
|
PackageVersion = latestVersion, |
||||||
|
DisplayVersion = latestVersion, |
||||||
|
LaunchCommand = SelectedPackage.LaunchCommand, |
||||||
|
LastUpdateCheck = DateTimeOffset.Now |
||||||
|
}; |
||||||
|
await using var st = settingsManager.BeginTransaction(); |
||||||
|
st.Settings.InstalledPackages.Add(installedPackage); |
||||||
|
st.Settings.ActiveInstalledPackage = installedPackage.Id; |
||||||
|
EventManager.Instance.OnInstalledPackagesChanged(); |
||||||
|
|
||||||
|
HeaderText = "Installation complete!"; |
||||||
|
SubSubHeaderText = string.Empty; |
||||||
|
OneClickInstallProgress = 100; |
||||||
|
SubHeaderText = "Proceeding to Launch page in 3 seconds..."; |
||||||
|
await Task.Delay(1000); |
||||||
|
SubHeaderText = "Proceeding to Launch page in 2 seconds..."; |
||||||
|
await Task.Delay(1000); |
||||||
|
SubHeaderText = "Proceeding to Launch page in 1 second..."; |
||||||
|
await Task.Delay(1000); |
||||||
|
|
||||||
|
// should close dialog |
||||||
|
EventManager.Instance.OnOneClickInstallFinished(false); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task DownloadPackage(string version) |
||||||
|
{ |
||||||
|
SubHeaderText = "Downloading package..."; |
||||||
|
|
||||||
|
var progress = new Progress<ProgressReport>(progress => |
||||||
|
{ |
||||||
|
IsIndeterminate = progress.IsIndeterminate; |
||||||
|
OneClickInstallProgress = Convert.ToInt32(progress.Percentage); |
||||||
|
EventManager.Instance.OnGlobalProgressChanged(OneClickInstallProgress); |
||||||
|
}); |
||||||
|
|
||||||
|
await SelectedPackage.DownloadPackage(version, false, progress); |
||||||
|
SubHeaderText = "Download Complete"; |
||||||
|
OneClickInstallProgress = 100; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task InstallPackage() |
||||||
|
{ |
||||||
|
SelectedPackage.ConsoleOutput += (_, output) => SubSubHeaderText = output.Text; |
||||||
|
SubHeaderText = "Downloading and installing package requirements..."; |
||||||
|
|
||||||
|
var progress = new Progress<ProgressReport>(progress => |
||||||
|
{ |
||||||
|
IsIndeterminate = progress.IsIndeterminate; |
||||||
|
OneClickInstallProgress = Convert.ToInt32(progress.Percentage); |
||||||
|
EventManager.Instance.OnGlobalProgressChanged(OneClickInstallProgress); |
||||||
|
}); |
||||||
|
|
||||||
|
await SelectedPackage.InstallPackage(progress); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,155 @@ |
|||||||
|
using System; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Text.Json; |
||||||
|
using System.Text.Json.Serialization; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Platform.Storage; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Models.Settings; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
[View(typeof(SelectDataDirectoryDialog))] |
||||||
|
public partial class SelectDataDirectoryViewModel : ContentDialogViewModelBase |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
public static string DefaultInstallLocation => Compat.AppDataHome; |
||||||
|
|
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
|
||||||
|
private const string ValidExistingDirectoryText = "Valid existing data directory found"; |
||||||
|
private const string InvalidDirectoryText = |
||||||
|
"Directory must be empty or have a valid settings.json file"; |
||||||
|
private const string NotEnoughFreeSpaceText = "Not enough free space on the selected drive"; |
||||||
|
|
||||||
|
[ObservableProperty] private string dataDirectory = DefaultInstallLocation; |
||||||
|
[ObservableProperty] private bool isPortableMode; |
||||||
|
|
||||||
|
[ObservableProperty] private string directoryStatusText = string.Empty; |
||||||
|
[ObservableProperty] private bool isStatusBadgeVisible; |
||||||
|
[ObservableProperty] private bool isDirectoryValid; |
||||||
|
|
||||||
|
public RefreshBadgeViewModel ValidatorRefreshBadge { get; } = new() |
||||||
|
{ |
||||||
|
State = ProgressState.Inactive, |
||||||
|
SuccessToolTipText = ValidExistingDirectoryText, |
||||||
|
FailToolTipText = InvalidDirectoryText |
||||||
|
}; |
||||||
|
|
||||||
|
public bool HasOldData => settingsManager.GetOldInstalledPackages().Any(); |
||||||
|
|
||||||
|
public SelectDataDirectoryViewModel(ISettingsManager settingsManager) |
||||||
|
{ |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
ValidatorRefreshBadge.RefreshFunc = ValidateDataDirectory; |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnLoaded() |
||||||
|
{ |
||||||
|
ValidatorRefreshBadge.RefreshCommand.ExecuteAsync(null).SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
// Revalidate on data directory change |
||||||
|
partial void OnDataDirectoryChanged(string value) |
||||||
|
{ |
||||||
|
ValidatorRefreshBadge.RefreshCommand.ExecuteAsync(null).SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<bool> ValidateDataDirectory() |
||||||
|
{ |
||||||
|
await using var delay = new MinimumDelay(100, 200); |
||||||
|
|
||||||
|
// Doesn't exist, this is fine as a new install, hide badge |
||||||
|
if (!Directory.Exists(DataDirectory)) |
||||||
|
{ |
||||||
|
IsStatusBadgeVisible = false; |
||||||
|
IsDirectoryValid = true; |
||||||
|
return true; |
||||||
|
} |
||||||
|
// Otherwise check that a settings.json exists |
||||||
|
var settingsPath = Path.Combine(DataDirectory, "settings.json"); |
||||||
|
|
||||||
|
// settings.json exists: Try deserializing it |
||||||
|
if (File.Exists(settingsPath)) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
var jsonText = await File.ReadAllTextAsync(settingsPath); |
||||||
|
var _ = JsonSerializer.Deserialize<Settings>(jsonText, new JsonSerializerOptions |
||||||
|
{ |
||||||
|
Converters = { new JsonStringEnumConverter() } |
||||||
|
}); |
||||||
|
// If successful, show existing badge |
||||||
|
IsStatusBadgeVisible = true; |
||||||
|
IsDirectoryValid = true; |
||||||
|
DirectoryStatusText = ValidExistingDirectoryText; |
||||||
|
return true; |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
Logger.Info("Failed to deserialize settings.json: {Msg}", e.Message); |
||||||
|
// If not, show error badge, and set directory to invalid to prevent continuing |
||||||
|
IsStatusBadgeVisible = true; |
||||||
|
IsDirectoryValid = false; |
||||||
|
DirectoryStatusText = InvalidDirectoryText; |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// No settings.json |
||||||
|
|
||||||
|
// Check if the directory is %APPDATA%\StabilityMatrix: hide badge and set directory valid |
||||||
|
if (DataDirectory == DefaultInstallLocation) |
||||||
|
{ |
||||||
|
IsStatusBadgeVisible = false; |
||||||
|
IsDirectoryValid = true; |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if the directory is empty: hide badge and set directory to valid |
||||||
|
var isEmpty = !Directory.EnumerateFileSystemEntries(DataDirectory).Any(); |
||||||
|
if (isEmpty) |
||||||
|
{ |
||||||
|
IsStatusBadgeVisible = false; |
||||||
|
IsDirectoryValid = true; |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
// Not empty and not appdata: show error badge, and set directory to invalid |
||||||
|
IsStatusBadgeVisible = true; |
||||||
|
IsDirectoryValid = false; |
||||||
|
DirectoryStatusText = InvalidDirectoryText; |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
private bool CanPickFolder => App.StorageProvider.CanPickFolder; |
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanPickFolder))] |
||||||
|
private async Task ShowFolderBrowserDialog() |
||||||
|
{ |
||||||
|
var provider = App.StorageProvider; |
||||||
|
var result = await provider.OpenFolderPickerAsync(new FolderPickerOpenOptions |
||||||
|
{ |
||||||
|
Title = "Select Data Folder", |
||||||
|
AllowMultiple = false |
||||||
|
}); |
||||||
|
|
||||||
|
if (result.Count != 1) return; |
||||||
|
|
||||||
|
DataDirectory = result[0].Path.LocalPath; |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnIsPortableModeChanged(bool value) |
||||||
|
{ |
||||||
|
DataDirectory = value ? Compat.AppCurrentDir + "Data" : DefaultInstallLocation; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,73 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
using Avalonia.Platform; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
public partial class SelectModelVersionViewModel : ContentDialogViewModelBase |
||||||
|
{ |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
|
||||||
|
public required ContentDialog Dialog { get; set; } |
||||||
|
public required IReadOnlyList<ModelVersionViewModel> Versions { get; set; } |
||||||
|
|
||||||
|
[ObservableProperty] private Bitmap? previewImage; |
||||||
|
[ObservableProperty] private ModelVersionViewModel? selectedVersionViewModel; |
||||||
|
[ObservableProperty] private CivitFileViewModel? selectedFile; |
||||||
|
[ObservableProperty] private bool isImportEnabled; |
||||||
|
|
||||||
|
public SelectModelVersionViewModel(ISettingsManager settingsManager, |
||||||
|
IDownloadService downloadService) |
||||||
|
{ |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.downloadService = downloadService; |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnLoaded() |
||||||
|
{ |
||||||
|
SelectedVersionViewModel = Versions[0]; |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedVersionViewModelChanged(ModelVersionViewModel? value) |
||||||
|
{ |
||||||
|
var nsfwEnabled = settingsManager.Settings.ModelBrowserNsfwEnabled; |
||||||
|
var firstImageUrl = value?.ModelVersion?.Images?.FirstOrDefault( |
||||||
|
img => nsfwEnabled || img.Nsfw == "None")?.Url; |
||||||
|
|
||||||
|
Dispatcher.UIThread.InvokeAsync(async |
||||||
|
() => await UpdateImage(firstImageUrl)); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedFileChanged(CivitFileViewModel? value) |
||||||
|
{ |
||||||
|
IsImportEnabled = value?.CivitFile != null; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task UpdateImage(string? url = null) |
||||||
|
{ |
||||||
|
var assetStream = string.IsNullOrWhiteSpace(url) |
||||||
|
? AssetLoader.Open(new Uri("avares://StabilityMatrix.Avalonia/Assets/noimage.png")) |
||||||
|
: await downloadService.GetImageStreamFromUrl(url); |
||||||
|
|
||||||
|
PreviewImage = new Bitmap(assetStream); |
||||||
|
} |
||||||
|
|
||||||
|
public void Cancel() |
||||||
|
{ |
||||||
|
Dialog.Hide(ContentDialogResult.Secondary); |
||||||
|
} |
||||||
|
|
||||||
|
public void Import() |
||||||
|
{ |
||||||
|
Dialog.Hide(ContentDialogResult.Primary); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,91 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.Net.Http; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Models.Update; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using StabilityMatrix.Core.Updater; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
[View(typeof(UpdateDialog))] |
||||||
|
public partial class UpdateViewModel : ContentDialogViewModelBase |
||||||
|
{ |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly IHttpClientFactory httpClientFactory; |
||||||
|
private readonly IUpdateHelper updateHelper; |
||||||
|
|
||||||
|
[ObservableProperty] private bool isUpdateAvailable; |
||||||
|
[ObservableProperty] private UpdateInfo? updateInfo; |
||||||
|
|
||||||
|
[ObservableProperty] private string? releaseNotes; |
||||||
|
[ObservableProperty] private string? updateText; |
||||||
|
[ObservableProperty] private int progressValue; |
||||||
|
[ObservableProperty] private bool showProgressBar; |
||||||
|
|
||||||
|
public UpdateViewModel( |
||||||
|
ISettingsManager settingsManager, |
||||||
|
IHttpClientFactory httpClientFactory, |
||||||
|
IUpdateHelper updateHelper) |
||||||
|
{ |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.httpClientFactory = httpClientFactory; |
||||||
|
this.updateHelper = updateHelper; |
||||||
|
|
||||||
|
EventManager.Instance.UpdateAvailable += (_, info) => |
||||||
|
{ |
||||||
|
IsUpdateAvailable = true; |
||||||
|
UpdateInfo = info; |
||||||
|
}; |
||||||
|
updateHelper.StartCheckingForUpdates().SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
public override async Task OnLoadedAsync() |
||||||
|
{ |
||||||
|
UpdateText = $"Stability Matrix v{UpdateInfo?.Version} is now available! You currently have v{Utilities.GetAppVersion()}. Would you like to update now?"; |
||||||
|
|
||||||
|
var client = httpClientFactory.CreateClient(); |
||||||
|
var response = await client.GetAsync(UpdateInfo?.ChangelogUrl); |
||||||
|
if (response.IsSuccessStatusCode) |
||||||
|
{ |
||||||
|
ReleaseNotes = await response.Content.ReadAsStringAsync(); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
ReleaseNotes = "## Unable to load release notes"; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task InstallUpdate() |
||||||
|
{ |
||||||
|
if (UpdateInfo == null) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
ShowProgressBar = true; |
||||||
|
UpdateText = $"Downloading update v{UpdateInfo.Version}..."; |
||||||
|
await updateHelper.DownloadUpdate(UpdateInfo, new Progress<ProgressReport>(report => |
||||||
|
{ |
||||||
|
ProgressValue = Convert.ToInt32(report.Percentage); |
||||||
|
})); |
||||||
|
|
||||||
|
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(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,61 @@ |
|||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using StabilityMatrix.Avalonia.Styles; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
[View(typeof(FirstLaunchSetupWindow))] |
||||||
|
public partial class FirstLaunchSetupViewModel : ViewModelBase |
||||||
|
{ |
||||||
|
[ObservableProperty] |
||||||
|
private bool eulaAccepted; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string gpuInfoText = string.Empty; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private RefreshBadgeViewModel checkHardwareBadge = new() |
||||||
|
{ |
||||||
|
WorkingToolTipText = "We're checking some hardware specifications to determine compatibility.", |
||||||
|
SuccessToolTipText = "Everything looks good!", |
||||||
|
FailToolTipText = "We recommend a GPU with CUDA support for the best experience. " + |
||||||
|
"You can continue without one, but some packages may not work, and inference may be slower.", |
||||||
|
FailColorBrush = ThemeColors.ThemeYellow, |
||||||
|
}; |
||||||
|
|
||||||
|
public FirstLaunchSetupViewModel() |
||||||
|
{ |
||||||
|
CheckHardwareBadge.RefreshFunc = SetGpuInfo; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<bool> SetGpuInfo() |
||||||
|
{ |
||||||
|
GpuInfo[] gpuInfo; |
||||||
|
await using (new MinimumDelay(800, 1200)) |
||||||
|
{ |
||||||
|
// Query GPU info |
||||||
|
gpuInfo = await Task.Run(() => HardwareHelper.IterGpuInfo().ToArray()); |
||||||
|
} |
||||||
|
// First Nvidia GPU |
||||||
|
var activeGpu = gpuInfo.FirstOrDefault(gpu => gpu.Name?.ToLowerInvariant().Contains("nvidia") ?? false); |
||||||
|
var isNvidia = activeGpu is not null; |
||||||
|
// Otherwise first GPU |
||||||
|
activeGpu ??= gpuInfo.FirstOrDefault(); |
||||||
|
GpuInfoText = activeGpu is null |
||||||
|
? "No GPU detected" |
||||||
|
: $"{activeGpu.Name} ({Size.FormatBytes(activeGpu.MemoryBytes)})"; |
||||||
|
|
||||||
|
return isNvidia; |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnLoaded() |
||||||
|
{ |
||||||
|
base.OnLoaded(); |
||||||
|
CheckHardwareBadge.RefreshCommand.ExecuteAsync(null).SafeFireAndForget(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,440 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Immutable; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.Linq; |
||||||
|
using System.Text.RegularExpressions; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls.Documents; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Helper.Factory; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.FileInterfaces; |
||||||
|
using StabilityMatrix.Core.Models.Packages; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Python; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
[View(typeof(LaunchPageView))] |
||||||
|
public partial class LaunchPageViewModel : PageViewModelBase, IDisposable |
||||||
|
{ |
||||||
|
private readonly ILogger<LaunchPageViewModel> logger; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly IPackageFactory packageFactory; |
||||||
|
private readonly IPyRunner pyRunner; |
||||||
|
private readonly INotificationService notificationService; |
||||||
|
private readonly ISharedFolders sharedFolders; |
||||||
|
private readonly ServiceManager<ViewModelBase> dialogFactory; |
||||||
|
|
||||||
|
// Regex to match if input contains a yes/no prompt, |
||||||
|
// i.e "Y/n", "yes/no". Case insensitive. |
||||||
|
// Separated by / or |. |
||||||
|
[GeneratedRegex(@"y(/|\|)n|yes(/|\|)no", RegexOptions.IgnoreCase)] |
||||||
|
private static partial Regex InputYesNoRegex(); |
||||||
|
|
||||||
|
public override string Title => "Launch"; |
||||||
|
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Rocket, IsFilled = true}; |
||||||
|
|
||||||
|
public ConsoleViewModel Console { get; } = new(); |
||||||
|
|
||||||
|
[ObservableProperty] private bool launchButtonVisibility; |
||||||
|
[ObservableProperty] private bool stopButtonVisibility; |
||||||
|
[ObservableProperty] private bool isLaunchTeachingTipsOpen; |
||||||
|
[ObservableProperty] private bool showWebUiButton; |
||||||
|
|
||||||
|
[ObservableProperty] private InstalledPackage? selectedPackage; |
||||||
|
[ObservableProperty] private ObservableCollection<InstalledPackage> installedPackages = new(); |
||||||
|
|
||||||
|
[ObservableProperty] private BasePackage? runningPackage; |
||||||
|
|
||||||
|
// private bool clearingPackages; |
||||||
|
private string webUiUrl = string.Empty; |
||||||
|
|
||||||
|
// Input info-bars |
||||||
|
[ObservableProperty] private bool showManualInputPrompt; |
||||||
|
[ObservableProperty] private bool showConfirmInputPrompt; |
||||||
|
|
||||||
|
public LaunchPageViewModel( |
||||||
|
ILogger<LaunchPageViewModel> logger, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
IPackageFactory packageFactory, |
||||||
|
IPyRunner pyRunner, |
||||||
|
INotificationService notificationService, |
||||||
|
ISharedFolders sharedFolders, |
||||||
|
ServiceManager<ViewModelBase> dialogFactory) |
||||||
|
{ |
||||||
|
this.logger = logger; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.packageFactory = packageFactory; |
||||||
|
this.pyRunner = pyRunner; |
||||||
|
this.notificationService = notificationService; |
||||||
|
this.sharedFolders = sharedFolders; |
||||||
|
this.dialogFactory = dialogFactory; |
||||||
|
|
||||||
|
EventManager.Instance.PackageLaunchRequested += OnPackageLaunchRequested; |
||||||
|
EventManager.Instance.OneClickInstallFinished += OnOneClickInstallFinished; |
||||||
|
EventManager.Instance.InstalledPackagesChanged += OnInstalledPackagesChanged; |
||||||
|
EventManager.Instance.TeachingTooltipNeeded += OnTeachingTooltipNeeded; |
||||||
|
// Handler for console input |
||||||
|
Console.ApcInput += (_, message) => |
||||||
|
{ |
||||||
|
if (InputYesNoRegex().IsMatch(message.Data)) |
||||||
|
{ |
||||||
|
ShowConfirmInputPrompt = true; |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
private void OnTeachingTooltipNeeded(object? sender, EventArgs e) => |
||||||
|
IsLaunchTeachingTipsOpen = true; |
||||||
|
|
||||||
|
private void OnInstalledPackagesChanged(object? sender, EventArgs e) => OnLoaded(); |
||||||
|
|
||||||
|
private void OnPackageLaunchRequested(object? sender, Guid e) |
||||||
|
{ |
||||||
|
if (RunningPackage is not null) |
||||||
|
{ |
||||||
|
notificationService.Show("A package is already running", |
||||||
|
"Please stop the current package before launching another.", |
||||||
|
NotificationType.Error); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
OnLoaded(); |
||||||
|
if (SelectedPackage is null) return; |
||||||
|
|
||||||
|
LaunchAsync().SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnLoaded() |
||||||
|
{ |
||||||
|
// Ensure active package either exists or is null |
||||||
|
settingsManager.Transaction(s => |
||||||
|
{ |
||||||
|
s.UpdateActiveInstalledPackage(); |
||||||
|
}, ignoreMissingLibraryDir: true); |
||||||
|
|
||||||
|
// Load installed packages |
||||||
|
InstalledPackages = |
||||||
|
new ObservableCollection<InstalledPackage>(settingsManager.Settings.InstalledPackages); |
||||||
|
|
||||||
|
// Load active package |
||||||
|
SelectedPackage = settingsManager.Settings.GetActiveInstalledPackage(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task LaunchAsync() |
||||||
|
{ |
||||||
|
IsLaunchTeachingTipsOpen = false; |
||||||
|
|
||||||
|
var activeInstall = SelectedPackage; |
||||||
|
if (activeInstall == null) |
||||||
|
{ |
||||||
|
// No selected package: error notification |
||||||
|
notificationService.Show(new Notification( |
||||||
|
message: "You must install and select a package before launching", |
||||||
|
title: "No package selected", |
||||||
|
type: NotificationType.Error)); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var activeInstallName = activeInstall.PackageName; |
||||||
|
var basePackage = string.IsNullOrWhiteSpace(activeInstallName) |
||||||
|
? null |
||||||
|
: packageFactory.FindPackageByName(activeInstallName); |
||||||
|
|
||||||
|
if (basePackage == null) |
||||||
|
{ |
||||||
|
logger.LogWarning( |
||||||
|
"During launch, package name '{PackageName}' did not match a definition", |
||||||
|
activeInstallName); |
||||||
|
|
||||||
|
notificationService.Show(new Notification("Package name invalid", |
||||||
|
"Install package name did not match a definition. Please reinstall and let us know about this issue.", |
||||||
|
NotificationType.Error)); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// If this is the first launch (LaunchArgs is null), |
||||||
|
// load and save a launch options dialog vm |
||||||
|
// so that dynamic initial values are saved. |
||||||
|
if (activeInstall.LaunchArgs == null) |
||||||
|
{ |
||||||
|
var definitions = basePackage.LaunchOptions; |
||||||
|
// Open a config page and save it |
||||||
|
var viewModel = dialogFactory.Get<LaunchOptionsViewModel>(); |
||||||
|
|
||||||
|
viewModel.Cards = LaunchOptionCard |
||||||
|
.FromDefinitions(definitions, Array.Empty<LaunchOption>()) |
||||||
|
.ToImmutableArray(); |
||||||
|
|
||||||
|
var args = viewModel.AsLaunchArgs(); |
||||||
|
|
||||||
|
logger.LogDebug("Setting initial launch args: {Args}", |
||||||
|
string.Join(", ", args.Select(o => o.ToArgString()?.ToRepr()))); |
||||||
|
|
||||||
|
settingsManager.SaveLaunchArgs(activeInstall.Id, args); |
||||||
|
} |
||||||
|
|
||||||
|
await pyRunner.Initialize(); |
||||||
|
|
||||||
|
// Get path from package |
||||||
|
var packagePath = new DirectoryPath(settingsManager.LibraryDir, activeInstall.LibraryPath!); |
||||||
|
|
||||||
|
// Unpack sitecustomize.py to venv |
||||||
|
await UnpackSiteCustomize(packagePath.JoinDir("venv")); |
||||||
|
|
||||||
|
basePackage.ConsoleOutput += OnProcessOutputReceived; |
||||||
|
basePackage.Exited += OnProcessExited; |
||||||
|
basePackage.StartupComplete += RunningPackageOnStartupComplete; |
||||||
|
|
||||||
|
// Clear console and start update processing |
||||||
|
await Console.StopUpdatesAsync(); |
||||||
|
await Console.Clear(); |
||||||
|
Console.StartUpdates(); |
||||||
|
|
||||||
|
// Update shared folder links (in case library paths changed) |
||||||
|
await sharedFolders.UpdateLinksForPackage(basePackage, packagePath); |
||||||
|
|
||||||
|
// Load user launch args from settings and convert to string |
||||||
|
var userArgs = settingsManager.GetLaunchArgs(activeInstall.Id); |
||||||
|
var userArgsString = string.Join(" ", userArgs.Select(opt => opt.ToArgString())); |
||||||
|
|
||||||
|
// Join with extras, if any |
||||||
|
userArgsString = string.Join(" ", userArgsString, basePackage.ExtraLaunchArguments); |
||||||
|
await basePackage.RunPackage(packagePath, userArgsString); |
||||||
|
RunningPackage = basePackage; |
||||||
|
} |
||||||
|
|
||||||
|
// Unpacks sitecustomize.py to the target venv |
||||||
|
private static async Task UnpackSiteCustomize(DirectoryPath venvPath) |
||||||
|
{ |
||||||
|
if (Compat.IsWindows) |
||||||
|
{ |
||||||
|
var file = venvPath.JoinFile("Lib", "site-packages", "sitecustomize.py"); |
||||||
|
await Assets.PyScriptSiteCustomize.ExtractTo(file, true); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
var file = venvPath.JoinFile("lib", "python3.10", "site-packages", "sitecustomize.py"); |
||||||
|
await Assets.PyScriptSiteCustomize.ExtractTo(file, true); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task Config() |
||||||
|
{ |
||||||
|
var activeInstall = SelectedPackage; |
||||||
|
var name = activeInstall?.PackageName; |
||||||
|
if (name == null || activeInstall == null) |
||||||
|
{ |
||||||
|
logger.LogWarning($"Selected package is null"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var package = packageFactory.FindPackageByName(name); |
||||||
|
if (package == null) |
||||||
|
{ |
||||||
|
logger.LogWarning("Package {Name} not found", name); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var definitions = package.LaunchOptions; |
||||||
|
// Check if package supports IArgParsable |
||||||
|
// Use dynamic parsed args over static |
||||||
|
/*if (package is IArgParsable parsable) |
||||||
|
{ |
||||||
|
var rootPath = activeInstall.FullPath!; |
||||||
|
var moduleName = parsable.RelativeArgsDefinitionScriptPath; |
||||||
|
var parser = new ArgParser(pyRunner, rootPath, moduleName); |
||||||
|
definitions = await parser.GetArgsAsync(); |
||||||
|
}*/ |
||||||
|
|
||||||
|
// Open a config page |
||||||
|
var userLaunchArgs = settingsManager.GetLaunchArgs(activeInstall.Id); |
||||||
|
var viewModel = dialogFactory.Get<LaunchOptionsViewModel>(); |
||||||
|
viewModel.Cards = LaunchOptionCard.FromDefinitions(definitions, userLaunchArgs) |
||||||
|
.ToImmutableArray(); |
||||||
|
|
||||||
|
logger.LogDebug("Launching config dialog with cards: {CardsCount}", |
||||||
|
viewModel.Cards.Count); |
||||||
|
|
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled, |
||||||
|
IsPrimaryButtonEnabled = true, |
||||||
|
PrimaryButtonText = "Save", |
||||||
|
CloseButtonText = "Cancel", |
||||||
|
DefaultButton = ContentDialogButton.Primary, |
||||||
|
Padding = new Thickness(0, 16), |
||||||
|
Content = new LaunchOptionsDialog |
||||||
|
{ |
||||||
|
DataContext = viewModel, |
||||||
|
} |
||||||
|
}; |
||||||
|
var result = await dialog.ShowAsync(); |
||||||
|
|
||||||
|
if (result == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
// Save config |
||||||
|
var args = viewModel.AsLaunchArgs(); |
||||||
|
settingsManager.SaveLaunchArgs(activeInstall.Id, args); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Send user input to running package |
||||||
|
public async Task SendInput(string input) |
||||||
|
{ |
||||||
|
if (RunningPackage is BaseGitPackage package) |
||||||
|
{ |
||||||
|
var venv = package.VenvRunner; |
||||||
|
var process = venv?.Process; |
||||||
|
if (process is not null) |
||||||
|
{ |
||||||
|
await process.StandardInput.WriteLineAsync(input); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
logger.LogWarning("Attempted to write input but Process is null"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task SendConfirmInput(bool value) |
||||||
|
{ |
||||||
|
// This must be on the UI thread |
||||||
|
Dispatcher.UIThread.CheckAccess(); |
||||||
|
// Also send input to our own console |
||||||
|
if (value) |
||||||
|
{ |
||||||
|
Console.Post("y\n"); |
||||||
|
await SendInput("y\n"); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
Console.Post("n\n"); |
||||||
|
await SendInput("n\n"); |
||||||
|
} |
||||||
|
|
||||||
|
ShowConfirmInputPrompt = false; |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task SendManualInput(string input) |
||||||
|
{ |
||||||
|
// Also send input to our own console |
||||||
|
Console.PostLine(input); |
||||||
|
await SendInput(input); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task Stop() |
||||||
|
{ |
||||||
|
if (RunningPackage is null) return; |
||||||
|
await RunningPackage.Shutdown(); |
||||||
|
|
||||||
|
RunningPackage = null; |
||||||
|
ShowWebUiButton = false; |
||||||
|
|
||||||
|
Console.PostLine($"{Environment.NewLine}Stopped process at {DateTimeOffset.Now}"); |
||||||
|
} |
||||||
|
|
||||||
|
public void OpenWebUi() |
||||||
|
{ |
||||||
|
if (string.IsNullOrEmpty(webUiUrl)) return; |
||||||
|
|
||||||
|
notificationService.TryAsync(Task.Run(() => ProcessRunner.OpenUrl(webUiUrl)), |
||||||
|
"Failed to open URL", $"{webUiUrl}"); |
||||||
|
} |
||||||
|
|
||||||
|
private void OnProcessExited(object? sender, int exitCode) |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.InvokeAsync(async () => |
||||||
|
{ |
||||||
|
logger.LogTrace("Process exited ({Code}) at {Time:g}", |
||||||
|
exitCode, DateTimeOffset.Now); |
||||||
|
|
||||||
|
// Need to wait for streams to finish before detaching handlers |
||||||
|
if (RunningPackage is BaseGitPackage {VenvRunner: not null} package) |
||||||
|
{ |
||||||
|
var process = package.VenvRunner.Process; |
||||||
|
if (process is not null) |
||||||
|
{ |
||||||
|
// Max 5 seconds |
||||||
|
var ct = new CancellationTokenSource(5000).Token; |
||||||
|
try |
||||||
|
{ |
||||||
|
await process.WaitUntilOutputEOF(ct); |
||||||
|
} |
||||||
|
catch (OperationCanceledException e) |
||||||
|
{ |
||||||
|
logger.LogWarning("Waiting for process EOF timed out: {Message}", e.Message); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Detach handlers |
||||||
|
if (sender is BasePackage basePackage) |
||||||
|
{ |
||||||
|
basePackage.ConsoleOutput -= OnProcessOutputReceived; |
||||||
|
basePackage.Exited -= OnProcessExited; |
||||||
|
basePackage.StartupComplete -= RunningPackageOnStartupComplete; |
||||||
|
} |
||||||
|
RunningPackage = null; |
||||||
|
ShowWebUiButton = false; |
||||||
|
|
||||||
|
await Console.StopUpdatesAsync(); |
||||||
|
|
||||||
|
// Need to reset cursor in case its in some weird position |
||||||
|
// from progress bars |
||||||
|
await Console.ResetWriteCursor(); |
||||||
|
Console.PostLine($"{Environment.NewLine}Process finished with exit code {exitCode}"); |
||||||
|
|
||||||
|
}).SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
// Callback for processes |
||||||
|
private void OnProcessOutputReceived(object? sender, ProcessOutput output) |
||||||
|
{ |
||||||
|
Console.Post(output); |
||||||
|
EventManager.Instance.OnScrollToBottomRequested(); |
||||||
|
} |
||||||
|
|
||||||
|
private void OnOneClickInstallFinished(object? sender, bool e) |
||||||
|
{ |
||||||
|
OnLoaded(); |
||||||
|
} |
||||||
|
|
||||||
|
private void RunningPackageOnStartupComplete(object? sender, string e) |
||||||
|
{ |
||||||
|
webUiUrl = e; |
||||||
|
ShowWebUiButton = !string.IsNullOrWhiteSpace(webUiUrl); |
||||||
|
} |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
Console.Dispose(); |
||||||
|
RunningPackage?.Shutdown(); |
||||||
|
GC.SuppressFinalize(this); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,190 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
[View(typeof(MainWindow))] |
||||||
|
public partial class MainWindowViewModel : ViewModelBase |
||||||
|
{ |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly ServiceManager<ViewModelBase> dialogFactory; |
||||||
|
public string Greeting => "Welcome to Avalonia!"; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private PageViewModelBase? currentPage; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private object? selectedCategory; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private List<PageViewModelBase> pages = new(); |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private List<PageViewModelBase> footerPages = new(); |
||||||
|
|
||||||
|
public ProgressManagerViewModel ProgressManagerViewModel { get; init; } |
||||||
|
public UpdateViewModel UpdateViewModel { get; init; } |
||||||
|
|
||||||
|
public MainWindowViewModel(ISettingsManager settingsManager, ServiceManager<ViewModelBase> dialogFactory) |
||||||
|
{ |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.dialogFactory = dialogFactory; |
||||||
|
|
||||||
|
ProgressManagerViewModel = dialogFactory.Get<ProgressManagerViewModel>(); |
||||||
|
UpdateViewModel = dialogFactory.Get<UpdateViewModel>(); |
||||||
|
} |
||||||
|
|
||||||
|
public override async Task OnLoadedAsync() |
||||||
|
{ |
||||||
|
// Set only if null, since this may be called again when content dialogs open |
||||||
|
CurrentPage ??= Pages.FirstOrDefault(); |
||||||
|
SelectedCategory ??= Pages.FirstOrDefault(); |
||||||
|
EventManager.Instance.PageChangeRequested += OnPageChangeRequested; |
||||||
|
|
||||||
|
if (!await EnsureDataDirectory()) |
||||||
|
{ |
||||||
|
// False if user exited dialog, shutdown app |
||||||
|
App.Shutdown(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Index checkpoints if we dont have |
||||||
|
settingsManager.IndexCheckpoints(); |
||||||
|
|
||||||
|
if (!settingsManager.Settings.InstalledPackages.Any()) |
||||||
|
{ |
||||||
|
var viewModel = dialogFactory.Get<OneClickInstallViewModel>(); |
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
IsPrimaryButtonEnabled = false, |
||||||
|
IsSecondaryButtonEnabled = false, |
||||||
|
IsFooterVisible = false, |
||||||
|
Content = new OneClickInstallDialog |
||||||
|
{ |
||||||
|
DataContext = viewModel |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
EventManager.Instance.OneClickInstallFinished += (_, skipped) => |
||||||
|
{ |
||||||
|
dialog.Hide(); |
||||||
|
if (skipped) return; |
||||||
|
|
||||||
|
EventManager.Instance.OnTeachingTooltipNeeded(); |
||||||
|
}; |
||||||
|
|
||||||
|
await dialog.ShowAsync(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Check if the data directory exists, if not, show the select data directory dialog. |
||||||
|
/// </summary> |
||||||
|
private async Task<bool> EnsureDataDirectory() |
||||||
|
{ |
||||||
|
// If we can't find library, show selection dialog |
||||||
|
if (!settingsManager.TryFindLibrary()) |
||||||
|
{ |
||||||
|
var result = await ShowSelectDataDirectoryDialog(); |
||||||
|
if (!result) return false; |
||||||
|
} |
||||||
|
|
||||||
|
// Try to find library again, should be found now |
||||||
|
if (!settingsManager.TryFindLibrary()) |
||||||
|
{ |
||||||
|
throw new Exception("Could not find library after setting path"); |
||||||
|
} |
||||||
|
|
||||||
|
// Tell LaunchPage to load any packages if they selected an existing directory |
||||||
|
EventManager.Instance.OnInstalledPackagesChanged(); |
||||||
|
|
||||||
|
// Check if there are old packages, if so show migration dialog |
||||||
|
// TODO: Migration dialog |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Shows the select data directory dialog. |
||||||
|
/// </summary> |
||||||
|
/// <returns>true if path set successfully, false if user exited dialog.</returns> |
||||||
|
private async Task<bool> ShowSelectDataDirectoryDialog() |
||||||
|
{ |
||||||
|
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); |
||||||
|
} |
||||||
|
// Indicate success |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task ShowUpdateDialog() |
||||||
|
{ |
||||||
|
var viewModel = dialogFactory.Get<UpdateViewModel>(); |
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled, |
||||||
|
DefaultButton = ContentDialogButton.Close, |
||||||
|
IsPrimaryButtonEnabled = false, |
||||||
|
IsSecondaryButtonEnabled = false, |
||||||
|
IsFooterVisible = false, |
||||||
|
Content = new UpdateDialog |
||||||
|
{ |
||||||
|
DataContext = viewModel |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
await dialog.ShowAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
private void OnPageChangeRequested(object? sender, Type e) |
||||||
|
{ |
||||||
|
CurrentPage = Pages.FirstOrDefault(p => p.GetType() == e); |
||||||
|
SelectedCategory = Pages.FirstOrDefault(p => p.GetType() == e); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedCategoryChanged(object? value) |
||||||
|
{ |
||||||
|
if (value is PageViewModelBase page) |
||||||
|
{ |
||||||
|
CurrentPage = page; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,375 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Immutable; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using Polly; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Helper.Factory; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.Packages; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// This is our ViewModel for the second page |
||||||
|
/// </summary> |
||||||
|
|
||||||
|
[View(typeof(PackageManagerPage))] |
||||||
|
public partial class PackageManagerViewModel : PageViewModelBase |
||||||
|
{ |
||||||
|
private readonly ILogger<PackageManagerViewModel> logger; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly IPackageFactory packageFactory; |
||||||
|
private readonly INotificationService notificationService; |
||||||
|
private readonly ServiceManager<ViewModelBase> dialogFactory; |
||||||
|
|
||||||
|
private const int MinutesToWaitForUpdateCheck = 60; |
||||||
|
|
||||||
|
public PackageManagerViewModel(ILogger<PackageManagerViewModel> logger, |
||||||
|
ISettingsManager settingsManager, IPackageFactory packageFactory, |
||||||
|
INotificationService notificationService, ServiceManager<ViewModelBase> dialogFactory) |
||||||
|
{ |
||||||
|
this.logger = logger; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.packageFactory = packageFactory; |
||||||
|
this.notificationService = notificationService; |
||||||
|
this.dialogFactory = dialogFactory; |
||||||
|
|
||||||
|
Packages = |
||||||
|
new ObservableCollection<InstalledPackage>(settingsManager.Settings.InstalledPackages); |
||||||
|
|
||||||
|
SelectedPackage = Packages.FirstOrDefault(); |
||||||
|
} |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private InstalledPackage? selectedPackage; |
||||||
|
|
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(ProgressBarVisibility))] |
||||||
|
private int progressValue; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string progressText = string.Empty; |
||||||
|
|
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(ProgressBarVisibility))] |
||||||
|
private bool isIndeterminate; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private bool isUninstalling; |
||||||
|
|
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(SelectedPackage))] |
||||||
|
private bool updateAvailable; |
||||||
|
|
||||||
|
[ObservableProperty] private string installButtonText; |
||||||
|
[ObservableProperty] private bool installButtonEnabled; |
||||||
|
|
||||||
|
public bool ProgressBarVisibility => ProgressValue > 0 || IsIndeterminate; |
||||||
|
public ObservableCollection<InstalledPackage> Packages { get; } |
||||||
|
|
||||||
|
public override bool CanNavigateNext { get; protected set; } = true; |
||||||
|
public override bool CanNavigatePrevious { get; protected set; } |
||||||
|
public override string Title => "Packages"; |
||||||
|
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true}; |
||||||
|
|
||||||
|
public override async Task OnLoadedAsync() |
||||||
|
{ |
||||||
|
if (!Design.IsDesignMode) |
||||||
|
{ |
||||||
|
await CheckUpdates(); |
||||||
|
} |
||||||
|
|
||||||
|
var installedPackages = settingsManager.Settings.InstalledPackages; |
||||||
|
|
||||||
|
SelectedPackage = installedPackages.FirstOrDefault(x => |
||||||
|
x.Id == settingsManager.Settings.ActiveInstalledPackage); |
||||||
|
SelectedPackage ??= installedPackages.FirstOrDefault(); |
||||||
|
|
||||||
|
InstallButtonEnabled = SelectedPackage != null; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task CheckUpdates() |
||||||
|
{ |
||||||
|
var installedPackages = settingsManager.Settings.InstalledPackages; |
||||||
|
Packages.Clear(); |
||||||
|
foreach (var packageToUpdate in installedPackages) |
||||||
|
{ |
||||||
|
var basePackage = packageFactory.FindPackageByName(packageToUpdate.PackageName); |
||||||
|
if (basePackage == null) continue; |
||||||
|
|
||||||
|
var canCheckUpdate = packageToUpdate.LastUpdateCheck == null || |
||||||
|
packageToUpdate.LastUpdateCheck.Value.AddMinutes(MinutesToWaitForUpdateCheck) < |
||||||
|
DateTimeOffset.Now; |
||||||
|
if (canCheckUpdate) |
||||||
|
{ |
||||||
|
var hasUpdate = await basePackage.CheckForUpdates(packageToUpdate); |
||||||
|
packageToUpdate.UpdateAvailable = hasUpdate; |
||||||
|
packageToUpdate.LastUpdateCheck = DateTimeOffset.Now; |
||||||
|
settingsManager.SetLastUpdateCheck(packageToUpdate); |
||||||
|
} |
||||||
|
|
||||||
|
Packages.Add(packageToUpdate); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public async Task Launch() |
||||||
|
{ |
||||||
|
if (SelectedPackage == null) return; |
||||||
|
if (InstallButtonText == "Launch") |
||||||
|
{ |
||||||
|
settingsManager.Transaction(s => s.ActiveInstalledPackage = SelectedPackage.Id); |
||||||
|
|
||||||
|
EventManager.Instance.RequestPageChange(typeof(LaunchPageViewModel)); |
||||||
|
EventManager.Instance.OnPackageLaunchRequested(SelectedPackage.Id); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
await UpdateSelectedPackage(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public async Task Uninstall() |
||||||
|
{ |
||||||
|
// In design mode, just remove the package from the list |
||||||
|
if (Design.IsDesignMode) |
||||||
|
{ |
||||||
|
if (SelectedPackage != null) |
||||||
|
{ |
||||||
|
Packages.Remove(SelectedPackage); |
||||||
|
} |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (SelectedPackage?.LibraryPath == null) |
||||||
|
{ |
||||||
|
logger.LogError("No package selected to uninstall"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var dialog = new ContentDialog |
||||||
|
{ |
||||||
|
Title = "Are you sure?", |
||||||
|
Content = "This will delete all folders in the package directory, including any generated images in that directory as well as any files you may have added.", |
||||||
|
PrimaryButtonText = "Yes, delete it", |
||||||
|
CloseButtonText = "No, keep it", |
||||||
|
DefaultButton = ContentDialogButton.Primary |
||||||
|
}; |
||||||
|
var result = await dialog.ShowAsync(); |
||||||
|
|
||||||
|
if (result == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
IsUninstalling = true; |
||||||
|
InstallButtonEnabled = false; |
||||||
|
var deleteTask = DeleteDirectoryAsync(Path.Combine(settingsManager.LibraryDir, |
||||||
|
SelectedPackage.LibraryPath)); |
||||||
|
var taskResult = await notificationService.TryAsync(deleteTask, |
||||||
|
"Some files could not be deleted. Please close any open files in the package directory and try again."); |
||||||
|
if (taskResult.IsSuccessful) |
||||||
|
{ |
||||||
|
notificationService.Show(new Notification("Success", |
||||||
|
$"Package {SelectedPackage.DisplayName} uninstalled", |
||||||
|
NotificationType.Success)); |
||||||
|
|
||||||
|
settingsManager.Transaction(settings => |
||||||
|
{ |
||||||
|
settings.RemoveInstalledPackageAndUpdateActive(SelectedPackage); |
||||||
|
}); |
||||||
|
} |
||||||
|
IsUninstalling = false; |
||||||
|
InstallButtonEnabled = true; |
||||||
|
|
||||||
|
await OnLoadedAsync(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedPackageChanged(InstalledPackage? value) |
||||||
|
{ |
||||||
|
if (value is null) return; |
||||||
|
|
||||||
|
UpdateAvailable = value.UpdateAvailable; |
||||||
|
InstallButtonText = value.UpdateAvailable ? "Update" : "Launch"; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Deletes a directory and all of its contents recursively. |
||||||
|
/// Uses Polly to retry the deletion if it fails, up to 5 times with an exponential backoff. |
||||||
|
/// </summary> |
||||||
|
/// <param name="targetDirectory"></param> |
||||||
|
private Task DeleteDirectoryAsync(string targetDirectory) |
||||||
|
{ |
||||||
|
var policy = Policy.Handle<IOException>() |
||||||
|
.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)), |
||||||
|
onRetry: (exception, calculatedWaitDuration) => |
||||||
|
{ |
||||||
|
logger.LogWarning( |
||||||
|
exception, |
||||||
|
"Deletion of {TargetDirectory} failed. Retrying in {CalculatedWaitDuration}", |
||||||
|
targetDirectory, calculatedWaitDuration); |
||||||
|
}); |
||||||
|
|
||||||
|
return policy.ExecuteAsync(async () => |
||||||
|
{ |
||||||
|
await Task.Run(() => |
||||||
|
{ |
||||||
|
DeleteDirectory(targetDirectory); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
private void DeleteDirectory(string targetDirectory) |
||||||
|
{ |
||||||
|
// Skip if directory does not exist |
||||||
|
if (!Directory.Exists(targetDirectory)) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
// For junction points, delete with recursive false |
||||||
|
if (new DirectoryInfo(targetDirectory).LinkTarget != null) |
||||||
|
{ |
||||||
|
logger.LogInformation("Removing junction point {TargetDirectory}", targetDirectory); |
||||||
|
try |
||||||
|
{ |
||||||
|
Directory.Delete(targetDirectory, false); |
||||||
|
return; |
||||||
|
} |
||||||
|
catch (IOException ex) |
||||||
|
{ |
||||||
|
throw new IOException($"Failed to delete junction point {targetDirectory}", ex); |
||||||
|
} |
||||||
|
} |
||||||
|
// Recursively delete all subdirectories |
||||||
|
var subdirectoryEntries = Directory.GetDirectories(targetDirectory); |
||||||
|
foreach (var subdirectoryPath in subdirectoryEntries) |
||||||
|
{ |
||||||
|
DeleteDirectory(subdirectoryPath); |
||||||
|
} |
||||||
|
// Delete all files in the directory |
||||||
|
var fileEntries = Directory.GetFiles(targetDirectory); |
||||||
|
foreach (var filePath in fileEntries) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
File.SetAttributes(filePath, FileAttributes.Normal); |
||||||
|
File.Delete(filePath); |
||||||
|
} |
||||||
|
catch (IOException ex) |
||||||
|
{ |
||||||
|
throw new IOException($"Failed to delete file {filePath}", ex); |
||||||
|
} |
||||||
|
} |
||||||
|
// Delete the target directory itself |
||||||
|
try |
||||||
|
{ |
||||||
|
Directory.Delete(targetDirectory, false); |
||||||
|
} |
||||||
|
catch (IOException ex) |
||||||
|
{ |
||||||
|
throw new IOException($"Failed to delete directory {targetDirectory}", ex); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async Task UpdateSelectedPackage() |
||||||
|
{ |
||||||
|
if (SelectedPackage == null) return; |
||||||
|
|
||||||
|
var package = packageFactory.FindPackageByName(SelectedPackage.PackageName); |
||||||
|
if (package == null) |
||||||
|
{ |
||||||
|
logger.LogError("Could not find package {SelectedPackagePackageName}", |
||||||
|
SelectedPackage.PackageName); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
ProgressText = $"Updating {SelectedPackage.DisplayName} to latest version..."; |
||||||
|
package.InstallLocation = SelectedPackage.FullPath!; |
||||||
|
var progress = new Progress<ProgressReport>(progress => |
||||||
|
{ |
||||||
|
var percent = Convert.ToInt32(progress.Percentage); |
||||||
|
|
||||||
|
ProgressValue = percent; |
||||||
|
IsIndeterminate = progress.IsIndeterminate; |
||||||
|
ProgressText = $"Updating {SelectedPackage.DisplayName} to latest version... {percent:N0}%"; |
||||||
|
|
||||||
|
EventManager.Instance.OnGlobalProgressChanged(percent); |
||||||
|
}); |
||||||
|
|
||||||
|
var updateResult = await package.Update(SelectedPackage, progress); |
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(updateResult)) |
||||||
|
{ |
||||||
|
var errorMsg = |
||||||
|
$"There was an error updating {SelectedPackage.DisplayName}. Please try again later."; |
||||||
|
|
||||||
|
if (SelectedPackage.PackageName == "automatic") |
||||||
|
{ |
||||||
|
errorMsg = errorMsg.Replace("Please try again later.", |
||||||
|
"Please stash any changes before updating, or manually update the package."); |
||||||
|
} |
||||||
|
|
||||||
|
// there was an error |
||||||
|
notificationService.Show(new Notification("Error updating package", |
||||||
|
errorMsg, NotificationType.Error)); |
||||||
|
} |
||||||
|
|
||||||
|
settingsManager.UpdatePackageVersionNumber(SelectedPackage.Id, updateResult); |
||||||
|
notificationService.Show("Update complete", |
||||||
|
$"{SelectedPackage.DisplayName} has been updated to the latest version.", |
||||||
|
NotificationType.Success); |
||||||
|
await OnLoadedAsync(); |
||||||
|
await DelayedClearProgress(TimeSpan.FromSeconds(3)); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task ShowInstallDialog() |
||||||
|
{ |
||||||
|
var viewModel = dialogFactory.Get<InstallerViewModel>(); |
||||||
|
viewModel.AvailablePackages = packageFactory.GetAllAvailablePackages().ToImmutableArray(); |
||||||
|
viewModel.SelectedPackage = viewModel.AvailablePackages[0]; |
||||||
|
|
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
MaxDialogWidth = 1100, |
||||||
|
DefaultButton = ContentDialogButton.Close, |
||||||
|
IsPrimaryButtonEnabled = false, |
||||||
|
IsSecondaryButtonEnabled = false, |
||||||
|
IsFooterVisible = false, |
||||||
|
Content = new InstallerDialog |
||||||
|
{ |
||||||
|
DataContext = viewModel |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
await dialog.ShowAsync(); |
||||||
|
await OnLoadedAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task DelayedClearProgress(TimeSpan delay) |
||||||
|
{ |
||||||
|
await Task.Delay(delay); |
||||||
|
|
||||||
|
ProgressText = string.Empty; |
||||||
|
ProgressValue = 0; |
||||||
|
IsIndeterminate = false; |
||||||
|
|
||||||
|
SelectedPackage.UpdateAvailable = false; |
||||||
|
UpdateAvailable = false; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// An abstract class for enabling page navigation. |
||||||
|
/// </summary> |
||||||
|
public abstract class PageViewModelBase : ViewModelBase |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Gets if the user can navigate to the next page |
||||||
|
/// </summary> |
||||||
|
public virtual bool CanNavigateNext { get; protected set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets if the user can navigate to the previous page |
||||||
|
/// </summary> |
||||||
|
public virtual bool CanNavigatePrevious { get; protected set; } |
||||||
|
|
||||||
|
public abstract string Title { get; } |
||||||
|
public abstract IconSource IconSource { get; } |
||||||
|
} |
@ -0,0 +1,60 @@ |
|||||||
|
using System; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
public partial class ProgressItemViewModel : ViewModelBase |
||||||
|
{ |
||||||
|
[ObservableProperty] private Guid id; |
||||||
|
[ObservableProperty] private string name; |
||||||
|
[ObservableProperty] private ProgressReport progress; |
||||||
|
[ObservableProperty] private bool failed; |
||||||
|
[ObservableProperty] private string? progressText; |
||||||
|
|
||||||
|
public ProgressItemViewModel(ProgressItem progressItem) |
||||||
|
{ |
||||||
|
Id = progressItem.ProgressId; |
||||||
|
Name = progressItem.Name; |
||||||
|
Progress = progressItem.Progress; |
||||||
|
Failed = progressItem.Failed; |
||||||
|
ProgressText = GetProgressText(Progress); |
||||||
|
|
||||||
|
EventManager.Instance.ProgressChanged += OnProgressChanged; |
||||||
|
} |
||||||
|
|
||||||
|
private void OnProgressChanged(object? sender, ProgressItem e) |
||||||
|
{ |
||||||
|
if (e.ProgressId != Id) |
||||||
|
return; |
||||||
|
|
||||||
|
Progress = e.Progress; |
||||||
|
Failed = e.Failed; |
||||||
|
ProgressText = GetProgressText(Progress); |
||||||
|
} |
||||||
|
|
||||||
|
private string GetProgressText(ProgressReport report) |
||||||
|
{ |
||||||
|
switch (report.Type) |
||||||
|
{ |
||||||
|
case ProgressType.Generic: |
||||||
|
break; |
||||||
|
case ProgressType.Download: |
||||||
|
return Failed ? "Download Failed" : "Downloading..."; |
||||||
|
case ProgressType.Extract: |
||||||
|
return Failed ? "Extraction Failed" : "Extracting..."; |
||||||
|
} |
||||||
|
|
||||||
|
if (Failed) |
||||||
|
{ |
||||||
|
return "Failed"; |
||||||
|
} |
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(report.Message) |
||||||
|
? string.IsNullOrWhiteSpace(report.Title) |
||||||
|
? string.Empty |
||||||
|
: report.Title |
||||||
|
: report.Message; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.Linq; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
[View(typeof(ProgressManagerPage))] |
||||||
|
public partial class ProgressManagerViewModel : PageViewModelBase |
||||||
|
{ |
||||||
|
public override string Title => "Download Manager"; |
||||||
|
public override IconSource IconSource => new SymbolIconSource {Symbol = Symbol.ArrowCircleDown, IsFilled = true}; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private ObservableCollection<ProgressItemViewModel> progressItems; |
||||||
|
|
||||||
|
public ProgressManagerViewModel() |
||||||
|
{ |
||||||
|
ProgressItems = new ObservableCollection<ProgressItemViewModel>(); |
||||||
|
} |
||||||
|
|
||||||
|
public void StartEventListener() |
||||||
|
{ |
||||||
|
EventManager.Instance.ProgressChanged += OnProgressChanged; |
||||||
|
} |
||||||
|
|
||||||
|
public void ClearDownloads() |
||||||
|
{ |
||||||
|
if (!ProgressItems.Any(p => Math.Abs(p.Progress.Percentage - 100) < 0.01f)) |
||||||
|
return; |
||||||
|
|
||||||
|
var itemsInProgress = ProgressItems |
||||||
|
.Where(p => p.Progress.Percentage < 100).ToList(); |
||||||
|
ProgressItems = new ObservableCollection<ProgressItemViewModel>(itemsInProgress); |
||||||
|
} |
||||||
|
|
||||||
|
private void OnProgressChanged(object? sender, ProgressItem e) |
||||||
|
{ |
||||||
|
if (ProgressItems.Any(x => x.Id == e.ProgressId)) |
||||||
|
return; |
||||||
|
|
||||||
|
ProgressItems.Add(new ProgressItemViewModel(e)); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,24 @@ |
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Generic view model for progress reporting. |
||||||
|
/// </summary> |
||||||
|
public partial class ProgressViewModel : ObservableObject |
||||||
|
{ |
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(IsTextVisible))] |
||||||
|
private string? text; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? description; |
||||||
|
|
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(IsProgressVisible))] |
||||||
|
private double value; |
||||||
|
|
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(IsProgressVisible))] |
||||||
|
private bool isIndeterminate; |
||||||
|
|
||||||
|
public virtual bool IsProgressVisible => Value > 0 || IsIndeterminate; |
||||||
|
public virtual bool IsTextVisible => !string.IsNullOrWhiteSpace(Text); |
||||||
|
} |
@ -0,0 +1,96 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia.Media; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Styles; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
[View(typeof(RefreshBadge))] |
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] |
||||||
|
public partial class RefreshBadgeViewModel : ViewModelBase |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
|
||||||
|
public string WorkingToolTipText { get; set; } = "Loading..."; |
||||||
|
public string SuccessToolTipText { get; set; } = "Success"; |
||||||
|
public string InactiveToolTipText { get; set; } = ""; |
||||||
|
public string FailToolTipText { get; set; } = "Failed"; |
||||||
|
|
||||||
|
public Symbol InactiveIcon { get; set; } = Symbol.Clear; |
||||||
|
public Symbol SuccessIcon { get; set; } = Symbol.Checkmark; |
||||||
|
public Symbol FailIcon { get; set; } = Symbol.AlertUrgent; |
||||||
|
|
||||||
|
public IBrush SuccessColorBrush { get; set; } = ThemeColors.ThemeGreen; |
||||||
|
public IBrush InactiveColorBrush { get; set; } = ThemeColors.ThemeYellow; |
||||||
|
public IBrush FailColorBrush { get; set; } = ThemeColors.ThemeYellow; |
||||||
|
|
||||||
|
public Func<Task<bool>>? RefreshFunc { get; set; } |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(IsWorking))] |
||||||
|
[NotifyPropertyChangedFor(nameof(ColorBrush))] |
||||||
|
[NotifyPropertyChangedFor(nameof(CurrentToolTip))] |
||||||
|
[NotifyPropertyChangedFor(nameof(Icon))] |
||||||
|
private ProgressState state; |
||||||
|
|
||||||
|
public bool IsWorking => State == ProgressState.Working; |
||||||
|
|
||||||
|
/*public ControlAppearance Appearance => State switch |
||||||
|
{ |
||||||
|
ProgressState.Working => ControlAppearance.Info, |
||||||
|
ProgressState.Success => ControlAppearance.Success, |
||||||
|
ProgressState.Failed => ControlAppearance.Danger, |
||||||
|
_ => ControlAppearance.Secondary |
||||||
|
};*/ |
||||||
|
|
||||||
|
public IBrush ColorBrush => State switch |
||||||
|
{ |
||||||
|
ProgressState.Success => SuccessColorBrush, |
||||||
|
ProgressState.Inactive => InactiveColorBrush, |
||||||
|
ProgressState.Failed => FailColorBrush, |
||||||
|
_ => Brushes.Gray |
||||||
|
}; |
||||||
|
|
||||||
|
public string CurrentToolTip => State switch |
||||||
|
{ |
||||||
|
ProgressState.Working => WorkingToolTipText, |
||||||
|
ProgressState.Success => SuccessToolTipText, |
||||||
|
ProgressState.Inactive => InactiveToolTipText, |
||||||
|
ProgressState.Failed => FailToolTipText, |
||||||
|
_ => "" |
||||||
|
}; |
||||||
|
|
||||||
|
public Symbol Icon => State switch |
||||||
|
{ |
||||||
|
ProgressState.Success => SuccessIcon, |
||||||
|
ProgressState.Failed => FailIcon, |
||||||
|
_ => InactiveIcon |
||||||
|
}; |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task Refresh() |
||||||
|
{ |
||||||
|
Logger.Info("Running refresh command..."); |
||||||
|
if (RefreshFunc == null) return; |
||||||
|
|
||||||
|
State = ProgressState.Working; |
||||||
|
try |
||||||
|
{ |
||||||
|
var result = await RefreshFunc.Invoke(); |
||||||
|
State = result ? ProgressState.Success : ProgressState.Failed; |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
State = ProgressState.Failed; |
||||||
|
Logger.Error(ex, "Refresh command failed: {Ex}", ex.Message); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,286 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Reflection; |
||||||
|
using System.Text; |
||||||
|
using System.Text.Json; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using Avalonia.Styling; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Avalonia.Models; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Python; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
[View(typeof(SettingsPage))] |
||||||
|
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; |
||||||
|
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}"; |
||||||
|
|
||||||
|
// Theme section |
||||||
|
[ObservableProperty] private string? selectedTheme; |
||||||
|
|
||||||
|
public IReadOnlyList<string> AvailableThemes { get; } = new[] |
||||||
|
{ |
||||||
|
"Light", |
||||||
|
"Dark", |
||||||
|
"System", |
||||||
|
}; |
||||||
|
|
||||||
|
// Shared folder options |
||||||
|
[ObservableProperty] private bool removeSymlinksOnShutdown; |
||||||
|
|
||||||
|
// Debug section |
||||||
|
[ObservableProperty] private string? debugPaths; |
||||||
|
[ObservableProperty] private string? debugCompatInfo; |
||||||
|
[ObservableProperty] private string? debugGpuInfo; |
||||||
|
|
||||||
|
// Info section |
||||||
|
private const int VersionTapCountThreshold = 7; |
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))] private int versionTapCount; |
||||||
|
[ObservableProperty] private bool isVersionTapTeachingTipOpen; |
||||||
|
public string VersionFlyoutText => $"You are {VersionTapCountThreshold - VersionTapCount} clicks away from enabling Debug options."; |
||||||
|
|
||||||
|
public SettingsViewModel( |
||||||
|
INotificationService notificationService, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
IPrerequisiteHelper prerequisiteHelper, |
||||||
|
IPyRunner pyRunner, |
||||||
|
SharedState sharedState) |
||||||
|
{ |
||||||
|
this.notificationService = notificationService; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.prerequisiteHelper = prerequisiteHelper; |
||||||
|
this.pyRunner = pyRunner; |
||||||
|
SharedState = sharedState; |
||||||
|
|
||||||
|
SelectedTheme = AvailableThemes[1]; |
||||||
|
RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown; |
||||||
|
} |
||||||
|
|
||||||
|
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 OnRemoveSymlinksOnShutdownChanged(bool value) |
||||||
|
{ |
||||||
|
settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value); |
||||||
|
} |
||||||
|
|
||||||
|
[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 = "Python version info", |
||||||
|
Content = result, |
||||||
|
PrimaryButtonText = "Ok", |
||||||
|
IsPrimaryButtonEnabled = true |
||||||
|
}; |
||||||
|
dialog.Title = "Python version info"; |
||||||
|
dialog.Content = result; |
||||||
|
dialog.PrimaryButtonText = "Ok"; |
||||||
|
await dialog.ShowAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
#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 = "OK", |
||||||
|
CloseButtonText = "Close" |
||||||
|
}; |
||||||
|
|
||||||
|
var result = await dialog.ShowAsync(); |
||||||
|
notificationService.Show(new Notification("Content dialog closed", |
||||||
|
$"Result: {result}")); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private void DebugThrowException() |
||||||
|
{ |
||||||
|
// Use try-catch to generate traceback information |
||||||
|
throw new OperationCanceledException("Example Message"); |
||||||
|
} |
||||||
|
#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 |
||||||
|
|
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
using System.Threading.Tasks; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
public class ViewModelBase : ObservableValidator |
||||||
|
{ |
||||||
|
public virtual void OnLoaded() |
||||||
|
{ |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
public virtual Task OnLoadedAsync() |
||||||
|
{ |
||||||
|
return Task.CompletedTask; |
||||||
|
} |
||||||
|
|
||||||
|
public virtual void OnUnloaded() |
||||||
|
{ |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
public virtual Task OnUnloadedAsync() |
||||||
|
{ |
||||||
|
return Task.CompletedTask; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,279 @@ |
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||||
|
xmlns:designData="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" |
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600" |
||||||
|
x:DataType="viewModels:CheckpointBrowserViewModel" |
||||||
|
d:DataContext="{x:Static designData:DesignData.CheckpointBrowserViewModel}" |
||||||
|
x:CompileBindings="True" |
||||||
|
x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage"> |
||||||
|
|
||||||
|
<UserControl.Resources> |
||||||
|
<DataTemplate DataType="{x:Type viewModels:CheckpointBrowserCardViewModel}" x:Key="CivitModelTemplate"> |
||||||
|
<controls:Card |
||||||
|
Margin="8" |
||||||
|
MaxHeight="450" |
||||||
|
CornerRadius="8" |
||||||
|
Name="ModelCard" |
||||||
|
Width="330"> |
||||||
|
|
||||||
|
<StackPanel Orientation="Vertical"> |
||||||
|
<TextBlock |
||||||
|
Foreground="{DynamicResource TextControlForeground}" |
||||||
|
Margin="0,0,0,0" |
||||||
|
Text="{Binding CivitModel.Name}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
<TextBlock |
||||||
|
FontSize="11" |
||||||
|
Foreground="{DynamicResource TextControlForeground}" |
||||||
|
Margin="0,2,0,0" |
||||||
|
Text="{Binding CivitModel.ModelVersions[0].Name, FallbackValue=''}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
<Grid> |
||||||
|
<controls:BetterAdvancedImage |
||||||
|
Margin="0,8,0,8" |
||||||
|
Height="300" |
||||||
|
StretchDirection="Both" |
||||||
|
CornerRadius="4" |
||||||
|
VerticalContentAlignment="Top" |
||||||
|
HorizontalContentAlignment="Center" |
||||||
|
Source="{Binding CardImage}" |
||||||
|
Stretch="UniformToFill" /> |
||||||
|
|
||||||
|
<Button |
||||||
|
Command="{Binding OpenModelCommand}" |
||||||
|
CommandParameter="{Binding CivitModel}" |
||||||
|
HorizontalAlignment="Right" |
||||||
|
Classes="transparent-info" |
||||||
|
Margin="0,16,8,0" |
||||||
|
VerticalAlignment="Top"> |
||||||
|
<Button.Content> |
||||||
|
<ui:SymbolIcon Symbol="Open" /> |
||||||
|
</Button.Content> |
||||||
|
</Button> |
||||||
|
<StackPanel Orientation="Horizontal"> |
||||||
|
<controls:Card |
||||||
|
Classes="info" |
||||||
|
Height="24" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Padding="4" |
||||||
|
Margin="4,16,0,0" |
||||||
|
VerticalAlignment="Top"> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
FontSize="10" |
||||||
|
FontWeight="Medium" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Text="{Binding CivitModel.Type}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
</controls:Card> |
||||||
|
|
||||||
|
<controls:Card |
||||||
|
Classes="success" |
||||||
|
Height="24" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Padding="4" |
||||||
|
IsVisible="{Binding ShowUpdateCard}" |
||||||
|
Margin="4,16,0,0" |
||||||
|
VerticalAlignment="Top"> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
FontSize="10" |
||||||
|
FontWeight="Medium" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Text="{Binding UpdateCardText}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
</controls:Card> |
||||||
|
</StackPanel> |
||||||
|
<Rectangle |
||||||
|
Fill="#DD000000" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
Margin="0,8,0,8" |
||||||
|
VerticalAlignment="Stretch" |
||||||
|
IsVisible="{Binding IsImporting}" /> |
||||||
|
<StackPanel |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
Orientation="Vertical" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding IsImporting}"> |
||||||
|
<controls:ProgressRing |
||||||
|
HorizontalAlignment="Center" |
||||||
|
IsIndeterminate="False" |
||||||
|
Width="120" |
||||||
|
Height="120" |
||||||
|
StartAngle="90" |
||||||
|
EndAngle="450" |
||||||
|
Value="{Binding Value}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
<TextBlock |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Margin="0,8,0,0" |
||||||
|
Text="{Binding Text, TargetNullValue=Importing...}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
</StackPanel> |
||||||
|
</Grid> |
||||||
|
<Grid> |
||||||
|
<Grid.ColumnDefinitions> |
||||||
|
<ColumnDefinition Width="1*" /> |
||||||
|
<ColumnDefinition Width="Auto" /> |
||||||
|
</Grid.ColumnDefinitions> |
||||||
|
<Button |
||||||
|
Classes="accent" |
||||||
|
Command="{Binding ImportCommand}" |
||||||
|
CommandParameter="{Binding CivitModel}" |
||||||
|
IsEnabled="{Binding !IsImporting}" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
Margin="0,8,0,0"> |
||||||
|
<Button.Content> |
||||||
|
<StackPanel Orientation="Horizontal"> |
||||||
|
<TextBlock Text="Import Latest -" /> |
||||||
|
<TextBlock Margin="4,0,0,0" |
||||||
|
Text="{Binding CivitModel.FullFilesSize}" /> |
||||||
|
</StackPanel> |
||||||
|
</Button.Content> |
||||||
|
</Button> |
||||||
|
|
||||||
|
<Button Grid.Column="1" |
||||||
|
Margin="8,8,0,0" |
||||||
|
Classes="accent" |
||||||
|
IsEnabled="{Binding !IsImporting}" |
||||||
|
Command="{Binding ShowVersionDialogCommand}" |
||||||
|
CommandParameter="{Binding CivitModel}" |
||||||
|
Content="All Versions" /> |
||||||
|
</Grid> |
||||||
|
</StackPanel> |
||||||
|
</controls:Card> |
||||||
|
</DataTemplate> |
||||||
|
</UserControl.Resources> |
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto" Margin="0,8,0,0"> |
||||||
|
|
||||||
|
<StackPanel Margin="8" Orientation="Vertical"> |
||||||
|
<Grid ColumnDefinitions="*,Auto"> |
||||||
|
|
||||||
|
<TextBox |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
Margin="8,0,0,0" |
||||||
|
Watermark="Search models, #tags, or @users" |
||||||
|
Text="{Binding SearchQuery, Mode=TwoWay}" /> |
||||||
|
|
||||||
|
<Button |
||||||
|
Classes="accent" |
||||||
|
Command="{Binding SearchModelsCommand}" |
||||||
|
Grid.Column="1" |
||||||
|
IsDefault="True" |
||||||
|
Margin="8,0,8,0" |
||||||
|
VerticalAlignment="Stretch" |
||||||
|
Width="80"> |
||||||
|
<Grid> |
||||||
|
<controls:ProgressRing |
||||||
|
MinHeight="16" |
||||||
|
IsIndeterminate="True" |
||||||
|
VerticalAlignment="Center" |
||||||
|
BorderThickness="4" |
||||||
|
IsVisible="{Binding SearchModelsCommand.IsRunning}" |
||||||
|
MinWidth="16" /> |
||||||
|
<TextBlock |
||||||
|
Text="Search" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding !SearchModelsCommand.IsRunning}" /> |
||||||
|
</Grid> |
||||||
|
</Button> |
||||||
|
</Grid> |
||||||
|
<DockPanel> |
||||||
|
<StackPanel Margin="8" Orientation="Vertical"> |
||||||
|
<Label Content="Sort" /> |
||||||
|
<ComboBox |
||||||
|
ItemsSource="{Binding AllSortModes}" |
||||||
|
MinWidth="100" |
||||||
|
SelectedItem="{Binding SortMode}" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<StackPanel Margin="0,8" Orientation="Vertical"> |
||||||
|
<Label Content="Period" /> |
||||||
|
<ComboBox |
||||||
|
ItemsSource="{Binding AllCivitPeriods}" |
||||||
|
MinWidth="100" |
||||||
|
SelectedItem="{Binding SelectedPeriod}" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<StackPanel Margin="8" Orientation="Vertical"> |
||||||
|
<Label Content="Model Type" /> |
||||||
|
<ComboBox |
||||||
|
ItemsSource="{Binding AllModelTypes}" |
||||||
|
MinWidth="100" |
||||||
|
SelectedItem="{Binding SelectedModelType}" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<CheckBox |
||||||
|
Content="Show NSFW Content" |
||||||
|
HorizontalAlignment="Right" |
||||||
|
IsChecked="{Binding ShowNsfw, Mode=TwoWay}" |
||||||
|
Margin="8,8,8,0" /> |
||||||
|
</DockPanel> |
||||||
|
|
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1"> |
||||||
|
<ItemsRepeater ItemTemplate="{StaticResource CivitModelTemplate}" |
||||||
|
ItemsSource="{Binding ModelCards}"> |
||||||
|
<ItemsRepeater.Layout> |
||||||
|
<UniformGridLayout Orientation="Horizontal" /> |
||||||
|
</ItemsRepeater.Layout> |
||||||
|
</ItemsRepeater> |
||||||
|
</ScrollViewer> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Grid.Row="2" |
||||||
|
Margin="16,8" |
||||||
|
Text="Data provided by CivitAI" |
||||||
|
VerticalAlignment="Bottom" /> |
||||||
|
|
||||||
|
<StackPanel |
||||||
|
Grid.Row="2" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Margin="8" |
||||||
|
Orientation="Vertical" |
||||||
|
IsVisible="{Binding HasSearched}"> |
||||||
|
<TextBlock Margin="0,0,4,4" TextAlignment="Center"> |
||||||
|
<Run Text="Page" /> |
||||||
|
<Run Text="{Binding CurrentPageNumber, FallbackValue=1}" /> |
||||||
|
<Run Text="/" /> |
||||||
|
<Run Text="{Binding TotalPages, FallbackValue=5}" /> |
||||||
|
</TextBlock> |
||||||
|
<StackPanel HorizontalAlignment="Center" Orientation="Horizontal"> |
||||||
|
<Button |
||||||
|
Command="{Binding PreviousPageCommand}" |
||||||
|
IsEnabled="{Binding CanGoToPreviousPage}" |
||||||
|
Margin="0,0,8,0"> |
||||||
|
<ui:SymbolIcon Symbol="PreviousFilled" /> |
||||||
|
</Button> |
||||||
|
<Button Command="{Binding NextPageCommand}" IsEnabled="{Binding CanGoToNextPage}"> |
||||||
|
<ui:SymbolIcon Symbol="NextFilled" /> |
||||||
|
</Button> |
||||||
|
</StackPanel> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Grid.Row="0" |
||||||
|
Grid.RowSpan="3" |
||||||
|
FontSize="20" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{Binding NoResultsText, FallbackValue=No results found}" |
||||||
|
IsVisible="{Binding NoResultsFound}" /> |
||||||
|
|
||||||
|
<controls:ProgressRing |
||||||
|
Grid.Row="1" |
||||||
|
IsIndeterminate="True" |
||||||
|
Width="128" |
||||||
|
Height="128" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
VerticalAlignment="Stretch" |
||||||
|
IsVisible="{Binding ShowMainLoadingSpinner, FallbackValue=False}" /> |
||||||
|
</Grid> |
||||||
|
</UserControl> |
@ -0,0 +1,17 @@ |
|||||||
|
using Avalonia.Markup.Xaml; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Views; |
||||||
|
|
||||||
|
public partial class CheckpointBrowserPage : UserControlBase |
||||||
|
{ |
||||||
|
public CheckpointBrowserPage() |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
} |
||||||
|
|
||||||
|
private void InitializeComponent() |
||||||
|
{ |
||||||
|
AvaloniaXamlLoader.Load(this); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,451 @@ |
|||||||
|
<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" |
||||||
|
d:DataContext="{x:Static mocks:DesignData.CheckpointsPageViewModel}" |
||||||
|
x:CompileBindings="True" |
||||||
|
x:DataType="vm:CheckpointsPageViewModel" |
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" |
||||||
|
x:Class="StabilityMatrix.Avalonia.Views.CheckpointsPage"> |
||||||
|
|
||||||
|
<controls:UserControlBase.DataTemplates> |
||||||
|
<!-- Template for dropdown category checkbox item --> |
||||||
|
<DataTemplate DataType="{x:Type vm:CheckpointFolder}"> |
||||||
|
<ui:ToggleMenuFlyoutItem |
||||||
|
Text="{Binding TitleWithFilesCount}" |
||||||
|
IsChecked="{Binding IsCategoryEnabled, Mode=TwoWay}"/> |
||||||
|
</DataTemplate> |
||||||
|
</controls:UserControlBase.DataTemplates> |
||||||
|
|
||||||
|
<controls:UserControlBase.Resources> |
||||||
|
<!--Direction="0" |
||||||
|
ShadowDepth="0"--> |
||||||
|
<DropShadowEffect |
||||||
|
BlurRadius="8" |
||||||
|
Color="#FF000000" |
||||||
|
Opacity="0.2" |
||||||
|
x:Key="TextDropShadowEffect" /> |
||||||
|
|
||||||
|
<!-- Template for a single badge --> |
||||||
|
<DataTemplate DataType="{x:Type system:String}" x:Key="BadgeTemplate"> |
||||||
|
<controls:Card |
||||||
|
Height="18" |
||||||
|
Margin="4,0,0,0" |
||||||
|
Padding="3" |
||||||
|
Width="40"> |
||||||
|
<TextBlock |
||||||
|
FontSize="10" |
||||||
|
FontWeight="Medium" |
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Text="{Binding}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
</controls:Card> |
||||||
|
</DataTemplate> |
||||||
|
|
||||||
|
<!-- Checkpoint File Card --> |
||||||
|
<DataTemplate DataType="{x:Type vm:CheckpointFile}" x:Key="CheckpointFileDataTemplate"> |
||||||
|
<Border |
||||||
|
DragDrop.AllowDrop="False" |
||||||
|
Background="Transparent" |
||||||
|
BorderThickness="2" |
||||||
|
Margin="8"> |
||||||
|
|
||||||
|
<controls:Card Width="260"> |
||||||
|
<!-- Right click menu for a checkpoint file --> |
||||||
|
<controls:Card.ContextFlyout> |
||||||
|
<ui:FAMenuFlyout> |
||||||
|
<ui:MenuFlyoutItem Command="{Binding RenameCommand}" |
||||||
|
Text="Rename" IconSource="Rename" /> |
||||||
|
<ui:MenuFlyoutItem Command="{Binding DeleteCommand}" |
||||||
|
Text="Delete" IconSource="Delete" /> |
||||||
|
<ui:MenuFlyoutItem Command="{Binding OpenOnCivitAiCommand}" |
||||||
|
Text="Open on CivitAI" IconSource="Link" |
||||||
|
IsVisible="{Binding IsConnectedModel}"/> |
||||||
|
</ui:FAMenuFlyout> |
||||||
|
</controls:Card.ContextFlyout> |
||||||
|
<Grid> |
||||||
|
<!-- Main contents, hidden when IsLoading is true --> |
||||||
|
<StackPanel MinHeight="70"> |
||||||
|
<Grid ColumnDefinitions="*,*,*,*,*" RowDefinitions="*,0.1*" IsVisible="{Binding !IsLoading}"> |
||||||
|
<StackPanel |
||||||
|
Grid.ColumnSpan="4" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
IsEnabled="True" |
||||||
|
Margin="4" |
||||||
|
Orientation="Vertical"> |
||||||
|
<!-- Tooltip for title and version --> |
||||||
|
<ToolTip.Tip> |
||||||
|
<StackPanel> |
||||||
|
<!-- Title --> |
||||||
|
<TextBlock |
||||||
|
FontSize="14" |
||||||
|
FontWeight="Medium" |
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Margin="0,0,0,4" |
||||||
|
Text="{Binding Title}" |
||||||
|
TextWrapping="WrapWithOverflow" /> |
||||||
|
<!-- Version --> |
||||||
|
<TextBlock |
||||||
|
FontSize="13" |
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Margin="0,0,0,4" |
||||||
|
Text="{Binding ConnectedModel.VersionName, FallbackValue=''}" |
||||||
|
TextWrapping="WrapWithOverflow" |
||||||
|
IsVisible="{Binding IsConnectedModel}" /> |
||||||
|
<!-- Image --> |
||||||
|
<Image |
||||||
|
Margin="0,0,0,4" |
||||||
|
Source="{Binding PreviewImage}" |
||||||
|
Stretch="Uniform" |
||||||
|
IsVisible="{Binding IsConnectedModel}" /> |
||||||
|
</StackPanel> |
||||||
|
</ToolTip.Tip> |
||||||
|
<TextBlock |
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
IsEnabled="True" |
||||||
|
MaxWidth="150" |
||||||
|
Text="{Binding Title}" |
||||||
|
TextTrimming="CharacterEllipsis" |
||||||
|
TextWrapping="NoWrap" /> |
||||||
|
<TextBlock |
||||||
|
FontSize="13" |
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
IsEnabled="True" |
||||||
|
Text="{Binding ConnectedModel.VersionName, FallbackValue=''}" |
||||||
|
TextWrapping="WrapWithOverflow" |
||||||
|
IsVisible="{Binding IsConnectedModel}" /> |
||||||
|
</StackPanel> |
||||||
|
<Grid Grid.Column="4" Grid.Row="0"> |
||||||
|
<Button |
||||||
|
Background="Transparent" |
||||||
|
BorderBrush="Transparent" |
||||||
|
FontSize="20" |
||||||
|
HorizontalAlignment="Right" |
||||||
|
IsEnabled="True" |
||||||
|
MaxHeight="48" |
||||||
|
MaxWidth="64" |
||||||
|
Padding="0" |
||||||
|
ToolTip.Tip="Connected Model" |
||||||
|
VerticalAlignment="Top" |
||||||
|
IsVisible="{Binding IsConnectedModel, Mode=OneWay}"> |
||||||
|
<ui:SymbolIcon |
||||||
|
Foreground="LightGreen" |
||||||
|
Symbol="CloudSyncComplete" |
||||||
|
ToolTip.Tip="Connected Model" /> |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
Background="Transparent" |
||||||
|
BorderBrush="Transparent" |
||||||
|
FontSize="20" |
||||||
|
HorizontalAlignment="Right" |
||||||
|
IsEnabled="True" |
||||||
|
MaxHeight="48" |
||||||
|
MaxWidth="64" |
||||||
|
Padding="0" |
||||||
|
ToolTip.Tip="Local Model" |
||||||
|
VerticalAlignment="Top" |
||||||
|
IsVisible="{Binding !IsConnectedModel, Mode=OneWay}"> |
||||||
|
<ui:SymbolIcon |
||||||
|
Foreground="DimGray" |
||||||
|
Symbol="CloudOffline" |
||||||
|
ToolTip.Tip="Local Model" /> |
||||||
|
</Button> |
||||||
|
</Grid> |
||||||
|
<TextBlock |
||||||
|
FontSize="11" |
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}" |
||||||
|
Grid.Column="0" |
||||||
|
Grid.ColumnSpan="3" |
||||||
|
Grid.Row="1" |
||||||
|
IsEnabled="True" |
||||||
|
Margin="4,4" |
||||||
|
Text="{Binding FileName}" |
||||||
|
TextTrimming="CharacterEllipsis" |
||||||
|
ToolTip.Tip="{Binding FileName}" |
||||||
|
VerticalAlignment="Bottom" /> |
||||||
|
<ItemsControl |
||||||
|
Grid.Column="3" |
||||||
|
Grid.ColumnSpan="2" |
||||||
|
Grid.Row="1" |
||||||
|
ItemTemplate="{StaticResource BadgeTemplate}" |
||||||
|
ItemsSource="{Binding Badges}" |
||||||
|
VerticalAlignment="Bottom"> |
||||||
|
<ItemsControl.ItemsPanel> |
||||||
|
<ItemsPanelTemplate> |
||||||
|
<StackPanel |
||||||
|
HorizontalAlignment="Right" |
||||||
|
Orientation="Horizontal" |
||||||
|
VerticalAlignment="Bottom" /> |
||||||
|
</ItemsPanelTemplate> |
||||||
|
</ItemsControl.ItemsPanel> |
||||||
|
</ItemsControl> |
||||||
|
</Grid> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<!-- Progress ring --> |
||||||
|
<controls:ProgressRing |
||||||
|
Grid.Row="0" |
||||||
|
Height="32" |
||||||
|
Width="32" |
||||||
|
Foreground="{DynamicResource ThemeRedColor}" |
||||||
|
BorderThickness="4" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
IsIndeterminate="True" |
||||||
|
Padding="0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsEnabled="{Binding IsLoading}" |
||||||
|
IsVisible="{Binding IsLoading}" /> |
||||||
|
</Grid> |
||||||
|
</controls:Card> |
||||||
|
</Border> |
||||||
|
</DataTemplate> |
||||||
|
|
||||||
|
<!-- Checkpoint Folder Expander --> |
||||||
|
<DataTemplate DataType="{x:Type vm:CheckpointFolder}" x:Key="CheckpointFolderGridDataTemplate"> |
||||||
|
<Expander |
||||||
|
IsExpanded="{Binding IsExpanded}" |
||||||
|
Margin="8" |
||||||
|
Padding="8,8,8,8" |
||||||
|
IsVisible="{Binding IsCategoryEnabled, FallbackValue=True}"> |
||||||
|
|
||||||
|
<!-- Right click menu for a checkpoint folder --> |
||||||
|
<Expander.ContextFlyout> |
||||||
|
<ui:FAMenuFlyout> |
||||||
|
<ui:MenuFlyoutItem Text="Show in Explorer" IconSource="Open" |
||||||
|
Command="{Binding ShowInExplorerCommand}" |
||||||
|
CommandParameter="{Binding DirectoryPath}"/> |
||||||
|
<ui:MenuFlyoutSeparator/> |
||||||
|
<ui:MenuFlyoutSubItem Text="New" IconSource="Add"> |
||||||
|
<ui:MenuFlyoutSubItem.Items> |
||||||
|
<ui:MenuFlyoutItem Text="Folder" IconSource="OpenFolder" |
||||||
|
Command="{Binding CreateSubFolderCommand}"/> |
||||||
|
</ui:MenuFlyoutSubItem.Items> |
||||||
|
</ui:MenuFlyoutSubItem> |
||||||
|
</ui:FAMenuFlyout> |
||||||
|
</Expander.ContextFlyout> |
||||||
|
|
||||||
|
<!-- Editable header --> |
||||||
|
<Expander.Header> |
||||||
|
<Grid> |
||||||
|
<TextBlock Text="{Binding Title}" VerticalAlignment="Center"/> |
||||||
|
</Grid> |
||||||
|
</Expander.Header> |
||||||
|
|
||||||
|
<StackPanel Orientation="Vertical"> |
||||||
|
<!-- Subfolders --> |
||||||
|
<StackPanel Orientation="Vertical"> |
||||||
|
<ItemsControl |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
ItemTemplate="{DynamicResource CheckpointFolderGridDataTemplate}" |
||||||
|
ItemsSource="{Binding SubFolders, Mode=OneWay}" |
||||||
|
Margin="8,0,8,8" /> |
||||||
|
</StackPanel> |
||||||
|
<!-- Files Grid --> |
||||||
|
<Grid Name="FilesGrid" |
||||||
|
Background="Transparent" |
||||||
|
DragDrop.AllowDrop="True"> |
||||||
|
<ItemsRepeater |
||||||
|
Classes="ItemsDragAndDrop" |
||||||
|
ItemTemplate="{StaticResource CheckpointFileDataTemplate}" |
||||||
|
ItemsSource="{Binding DisplayedCheckpointFiles}"> |
||||||
|
<ItemsRepeater.Layout> |
||||||
|
<UniformGridLayout Orientation="Horizontal"/> |
||||||
|
</ItemsRepeater.Layout> |
||||||
|
<TextBlock Text="Hi"/> |
||||||
|
</ItemsRepeater> |
||||||
|
<!-- Blurred background for drag and drop --> |
||||||
|
<Border |
||||||
|
CornerRadius="8" |
||||||
|
Grid.RowSpan="4" |
||||||
|
IsEnabled="False" |
||||||
|
IsVisible="{Binding IsDragBlurEnabled}"> |
||||||
|
<Border.Background> |
||||||
|
<SolidColorBrush Color="#EE202020" /> |
||||||
|
</Border.Background> |
||||||
|
</Border> |
||||||
|
<!-- Drag prompt text --> |
||||||
|
<TextBlock |
||||||
|
Effect="{StaticResource TextDropShadowEffect}" |
||||||
|
FontSize="24" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Text="Drop a file here to import" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding IsCurrentDragTarget}" /> |
||||||
|
<StackPanel |
||||||
|
Margin="0,8" |
||||||
|
Orientation="Vertical" |
||||||
|
DataContext="{Binding Progress}" |
||||||
|
VerticalAlignment="Center"> |
||||||
|
<!-- Import progress --> |
||||||
|
<TextBlock |
||||||
|
Effect="{StaticResource TextDropShadowEffect}" |
||||||
|
FontSize="18" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Text="{Binding Text}" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding IsTextVisible}" /> |
||||||
|
<ProgressBar |
||||||
|
Effect="{StaticResource TextDropShadowEffect}" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
IsIndeterminate="{Binding IsIndeterminate, FallbackValue=False}" |
||||||
|
Margin="64,8" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding IsProgressVisible}" |
||||||
|
Value="{Binding Value, FallbackValue=20}" /> |
||||||
|
</StackPanel> |
||||||
|
</Grid> |
||||||
|
</StackPanel> |
||||||
|
</Expander> |
||||||
|
</DataTemplate> |
||||||
|
|
||||||
|
<!-- Template for dropdown box --> |
||||||
|
<ControlTemplate x:Key="CategoryDropDownTemplate"> |
||||||
|
<Grid> |
||||||
|
<Border |
||||||
|
CornerRadius="{TemplateBinding Border.CornerRadius}" |
||||||
|
Margin="{TemplateBinding Margin}" |
||||||
|
Name="ContentBorder" |
||||||
|
Padding="0" |
||||||
|
VerticalAlignment="Stretch"> |
||||||
|
<Grid> |
||||||
|
<Rectangle |
||||||
|
Height="16" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Margin="0" |
||||||
|
RadiusX="2" |
||||||
|
RadiusY="2" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="False" |
||||||
|
Width="3" |
||||||
|
x:Name="ActiveRectangle"> |
||||||
|
<Rectangle.Fill> |
||||||
|
<SolidColorBrush Color="{DynamicResource SystemAccentColorSecondary}" /> |
||||||
|
</Rectangle.Fill> |
||||||
|
</Rectangle> |
||||||
|
<Grid ColumnDefinitions="*,Auto"> |
||||||
|
<!-- Setup grid rows --> |
||||||
|
<StackPanel |
||||||
|
Grid.Column="0" |
||||||
|
Margin="10,0,0,0" |
||||||
|
VerticalAlignment="Top"> |
||||||
|
<!--<CheckBox |
||||||
|
Content="{Binding TitleWithFilesCount}" |
||||||
|
IsChecked="{Binding IsCategoryEnabled, Mode=TwoWay}" />--> |
||||||
|
</StackPanel> |
||||||
|
</Grid> |
||||||
|
</Grid> |
||||||
|
</Border> |
||||||
|
</Grid> |
||||||
|
</ControlTemplate> |
||||||
|
|
||||||
|
</controls:UserControlBase.Resources> |
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*" RowDefinitions="0.2*,*" Margin="4, 0"> |
||||||
|
<!-- Top settings bar --> |
||||||
|
<ToggleSwitch |
||||||
|
OffContent="Import as Connected" |
||||||
|
OnContent="Import as Connected" |
||||||
|
Grid.Column="0" |
||||||
|
IsChecked="{Binding IsImportAsConnected}" |
||||||
|
Margin="24,8" |
||||||
|
ToolTip.Tip="Search for connected metadata on new local imports" /> |
||||||
|
<StackPanel |
||||||
|
Grid.Column="1" |
||||||
|
Grid.Row="0" |
||||||
|
Spacing="8" |
||||||
|
HorizontalAlignment="Right" |
||||||
|
Margin="16,0" |
||||||
|
Orientation="Horizontal"> |
||||||
|
<TextBox Margin="16, 16" |
||||||
|
Watermark="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="Indexing..." FontSize="11" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding IsIndexing}"/> |
||||||
|
<Button |
||||||
|
Command="{Binding OpenModelsFolderCommand}"> |
||||||
|
<TextBlock Text="Models Folder"/> |
||||||
|
</Button> |
||||||
|
<DropDownButton |
||||||
|
Content="Categories" |
||||||
|
Margin="8" |
||||||
|
VerticalAlignment="Center" |
||||||
|
HorizontalAlignment="Right"> |
||||||
|
<DropDownButton.Flyout> |
||||||
|
<ui:FAMenuFlyout |
||||||
|
ItemsSource="{Binding DisplayedCheckpointFolders}"/> |
||||||
|
</DropDownButton.Flyout> |
||||||
|
</DropDownButton> |
||||||
|
<!--<ComboBox |
||||||
|
VerticalAlignment="Center" |
||||||
|
HorizontalAlignment="Right" |
||||||
|
ItemsSource="{Binding CheckpointFolders}" |
||||||
|
MinWidth="180" |
||||||
|
SelectedIndex="0"> |
||||||
|
|
||||||
|
<ComboBox.ItemContainerStyle> |
||||||
|
<Style TargetType="ComboBoxItem"> |
||||||
|
<Setter Property="Template" Value="{StaticResource CategoryDropDownStyle}" /> |
||||||
|
</Style> |
||||||
|
</ComboBox.ItemContainerStyle> |
||||||
|
|
||||||
|
<ComboBox.ItemTemplate> |
||||||
|
<DataTemplate DataType="{x:Type vm:CheckpointFolder}"> |
||||||
|
<StackPanel Margin="10,0,0,0" VerticalAlignment="Top"> |
||||||
|
<TextBlock Margin="0,5,0,5" Text="Categories" /> |
||||||
|
</StackPanel> |
||||||
|
</DataTemplate> |
||||||
|
</ComboBox.ItemTemplate> |
||||||
|
</ComboBox>--> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<!-- Main view with model cards --> |
||||||
|
<ScrollViewer |
||||||
|
Grid.Column="0" |
||||||
|
Grid.ColumnSpan="2" |
||||||
|
Grid.Row="1" |
||||||
|
HorizontalScrollBarVisibility="Disabled" |
||||||
|
VerticalScrollBarVisibility="Auto"> |
||||||
|
<Grid> |
||||||
|
<controls:ProgressRing |
||||||
|
Width="128" |
||||||
|
Height="128" |
||||||
|
BorderThickness="8" |
||||||
|
IsIndeterminate="True" |
||||||
|
IsVisible="{Binding IsLoading}" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
VerticalAlignment="Center"/> |
||||||
|
<StackPanel Orientation="Vertical"> |
||||||
|
<ItemsControl |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
ItemTemplate="{StaticResource CheckpointFolderGridDataTemplate}" |
||||||
|
ItemsSource="{Binding DisplayedCheckpointFolders, Mode=OneWay}" |
||||||
|
Margin="8,0,8,8" /> |
||||||
|
</StackPanel> |
||||||
|
</Grid> |
||||||
|
</ScrollViewer> |
||||||
|
|
||||||
|
<!-- Overlay for draggable file panels --> |
||||||
|
<Panel Name="OverlayPanel" |
||||||
|
Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" /> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
</controls:UserControlBase> |
@ -0,0 +1,60 @@ |
|||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Input; |
||||||
|
using Avalonia.Markup.Xaml; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Views; |
||||||
|
|
||||||
|
public partial class CheckpointsPage : UserControlBase |
||||||
|
{ |
||||||
|
public CheckpointsPage() |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
|
||||||
|
AddHandler(DragDrop.DragEnterEvent, OnDragEnter); |
||||||
|
AddHandler(DragDrop.DragLeaveEvent, OnDragExit); |
||||||
|
AddHandler(DragDrop.DropEvent, OnDrop); |
||||||
|
} |
||||||
|
|
||||||
|
private void InitializeComponent() |
||||||
|
{ |
||||||
|
AvaloniaXamlLoader.Load(this); |
||||||
|
} |
||||||
|
private static async void OnDrop(object? sender, DragEventArgs e) |
||||||
|
{ |
||||||
|
var sourceDataContext = (e.Source as Control)?.DataContext; |
||||||
|
if (sourceDataContext is CheckpointFolder folder) |
||||||
|
{ |
||||||
|
await folder.OnDrop(e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static void OnDragExit(object? sender, DragEventArgs e) |
||||||
|
{ |
||||||
|
var sourceDataContext = (e.Source as Control)?.DataContext; |
||||||
|
if (sourceDataContext is CheckpointFolder folder) |
||||||
|
{ |
||||||
|
folder.IsCurrentDragTarget = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static void OnDragEnter(object? sender, DragEventArgs e) |
||||||
|
{ |
||||||
|
// Only allow Copy or Link as Drop Operations. |
||||||
|
e.DragEffects &= DragDropEffects.Copy | DragDropEffects.Link; |
||||||
|
|
||||||
|
// Only allow if the dragged data contains text or filenames. |
||||||
|
if (!e.Data.Contains(DataFormats.Text) && !e.Data.Contains(DataFormats.Files)) |
||||||
|
{ |
||||||
|
e.DragEffects = DragDropEffects.None; |
||||||
|
} |
||||||
|
|
||||||
|
// Forward to view model |
||||||
|
var sourceDataContext = (e.Source as Control)?.DataContext; |
||||||
|
if (sourceDataContext is CheckpointFolder folder) |
||||||
|
{ |
||||||
|
folder.IsCurrentDragTarget = true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue