first commit
27
Nitrox.Launcher/App.axaml
Normal file
@@ -0,0 +1,27 @@
|
||||
<Application x:Class="Nitrox.Launcher.App"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Name="Nitrox">
|
||||
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<MergeResourceInclude Source="/Models/Styles/Palette/Nitrox.axaml" />
|
||||
<MergeResourceInclude Source="/Models/Controls/CustomTitlebar.axaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="/Models/Styles/Nitrox.axaml" />
|
||||
</Application.Styles>
|
||||
|
||||
<!-- Menu that is used on macOS and some Linux distributions -->
|
||||
<NativeMenu.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="You are the best captain on this planet" />
|
||||
</NativeMenu>
|
||||
</NativeMenu.Menu>
|
||||
|
||||
</Application>
|
219
Nitrox.Launcher/App.axaml.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using ConsoleAppFramework;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Nitrox.Launcher.Models.Utils;
|
||||
using Nitrox.Launcher.Models.Validators;
|
||||
using Nitrox.Launcher.ViewModels;
|
||||
using Nitrox.Launcher.Views;
|
||||
using NitroxModel.Helper;
|
||||
using NitroxModel.Logger;
|
||||
using NitroxModel.Platforms.OS.Shared;
|
||||
|
||||
namespace Nitrox.Launcher;
|
||||
|
||||
public class App : Application
|
||||
{
|
||||
internal const string CRASH_REPORT_FILE_NAME = "Nitrox.Launcher-crash.txt";
|
||||
internal static Func<Window> StartupWindowFactory;
|
||||
internal static InstantLaunchData InstantLaunch;
|
||||
internal static bool IsCrashReport;
|
||||
|
||||
/// <summary>
|
||||
/// If true, allows duplicate instances of the app to be active.
|
||||
/// </summary>
|
||||
internal static bool AllowInstances;
|
||||
|
||||
internal static X11RenderingMode? PreferredRenderingMode;
|
||||
|
||||
public Window AppWindow
|
||||
{
|
||||
set
|
||||
{
|
||||
switch (ApplicationLifetime)
|
||||
{
|
||||
case IClassicDesktopStyleApplicationLifetime desktop:
|
||||
desktop.MainWindow = value;
|
||||
break;
|
||||
case ISingleViewApplicationLifetime singleViewPlatform:
|
||||
singleViewPlatform.MainView = value;
|
||||
break;
|
||||
case null when Design.IsDesignMode:
|
||||
Log.Info("Running in design previewer!");
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Current platform '{ApplicationLifetime?.GetType().Name}' is not supported by {nameof(Nitrox)}.{nameof(Launcher)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder Create()
|
||||
{
|
||||
CultureManager.ConfigureCultureInfo();
|
||||
Log.Setup();
|
||||
|
||||
// Handle command line arguments.
|
||||
ConsoleApp.ConsoleAppBuilder cliParser = ConsoleApp.Create();
|
||||
cliParser.Add("", (bool crashReport, X11RenderingMode? rendering = null, bool allowInstances = false) =>
|
||||
{
|
||||
IsCrashReport = crashReport;
|
||||
PreferredRenderingMode = rendering;
|
||||
AllowInstances = allowInstances;
|
||||
|
||||
if (IsCrashReport)
|
||||
{
|
||||
string executableRootPath = Path.GetDirectoryName(Environment.ProcessPath ?? NitroxUser.ExecutableRootPath);
|
||||
if (executableRootPath != null)
|
||||
{
|
||||
string crashReportContent = File.ReadAllText(Path.Combine(executableRootPath, CRASH_REPORT_FILE_NAME));
|
||||
StartupWindowFactory = () => new CrashWindow { DataContext = new CrashWindowViewModel { Title = $"Nitrox {NitroxEnvironment.Version} - Crash Report", Message = crashReportContent } };
|
||||
}
|
||||
}
|
||||
});
|
||||
cliParser.Add("instantlaunch", ([SaveName] string save, [MinLength(1)] params string[] players) =>
|
||||
{
|
||||
InstantLaunch = new InstantLaunchData(save, players);
|
||||
});
|
||||
cliParser.Run(Environment.GetCommandLineArgs().Skip(1).ToArray());
|
||||
|
||||
// Fallback to normal startup.
|
||||
if (StartupWindowFactory == null)
|
||||
{
|
||||
if (!AllowInstances)
|
||||
{
|
||||
CheckForRunningInstance();
|
||||
}
|
||||
ServiceProvider services = new ServiceCollection().AddAppServices().BuildServiceProvider();
|
||||
StartupWindowFactory = () => new MainWindow { DataContext = services.GetRequiredService<MainWindowViewModel>() };
|
||||
}
|
||||
|
||||
AppBuilder builder = AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace()
|
||||
.With(new SkiaOptions { UseOpacitySaveLayer = true });
|
||||
builder = WithRenderingMode(builder, PreferredRenderingMode);
|
||||
return builder;
|
||||
|
||||
static AppBuilder WithRenderingMode(AppBuilder builder, X11RenderingMode? rendering)
|
||||
{
|
||||
if (rendering.HasValue)
|
||||
{
|
||||
return builder.With(new X11PlatformOptions { RenderingMode = [rendering.Value] });
|
||||
}
|
||||
// The Wayland+GPU is not supported by Avalonia, but Xwayland should work.
|
||||
if (Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is not null)
|
||||
{
|
||||
if (!ProcessEx.ProcessExists("Xwayland"))
|
||||
{
|
||||
return builder.With(new X11PlatformOptions { RenderingMode = [X11RenderingMode.Software] });
|
||||
}
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
FixAvaloniaPlugins();
|
||||
ApplyAppDefaults();
|
||||
|
||||
Dispatcher.UIThread.UnhandledException += (_, eventArgs) => HandleUnhandledException(eventArgs.Exception);
|
||||
AppWindow = StartupWindowFactory();
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
internal static void HandleUnhandledException(Exception ex)
|
||||
{
|
||||
if (IsCrashReport)
|
||||
{
|
||||
Log.Error(ex, "Error while trying to show crash report");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Error(ex, "!!!Nitrox Launcher Crash!!!");
|
||||
|
||||
// Write crash report if we're not reporting one right now.
|
||||
try
|
||||
{
|
||||
string executableFilePath = NitroxUser.ExecutableFilePath ?? Environment.ProcessPath;
|
||||
string executableRoot = Path.GetDirectoryName(executableFilePath);
|
||||
if (executableFilePath != null && executableRoot != null)
|
||||
{
|
||||
string crashReportFile = Path.Combine(executableRoot, CRASH_REPORT_FILE_NAME);
|
||||
File.WriteAllText(crashReportFile, ex.ToString());
|
||||
ProcessUtils.StartSelf("--crash-report");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Error(ex, "Unable to get executable file path for writing crash report.");
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine(exception);
|
||||
}
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
private static void CheckForRunningInstance()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using ProcessEx process = ProcessEx.GetFirstProcess("Nitrox.Launcher", process => process.Id != Environment.ProcessId && process.IsRunning);
|
||||
if (process is not null)
|
||||
{
|
||||
process.SetForegroundWindowAndRestore();
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables Avalonia plugins which are replaced by MVVM Toolkit.
|
||||
/// </summary>
|
||||
private void FixAvaloniaPlugins()
|
||||
{
|
||||
for (int i = BindingPlugins.DataValidators.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (BindingPlugins.DataValidators[i] is DataAnnotationsValidationPlugin)
|
||||
{
|
||||
BindingPlugins.DataValidators.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAppDefaults() => RequestedThemeVariant = ThemeVariant.Dark;
|
||||
|
||||
internal class InstantLaunchData
|
||||
{
|
||||
public string SaveName { get; private set; }
|
||||
public string[] PlayerNames { get; private set; }
|
||||
|
||||
public InstantLaunchData(string saveName, string[] playerNames)
|
||||
{
|
||||
SaveName = saveName;
|
||||
PlayerNames = playerNames;
|
||||
}
|
||||
}
|
||||
}
|
57
Nitrox.Launcher/AppViewLocator.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using HanumanInstitute.MvvmDialogs;
|
||||
using HanumanInstitute.MvvmDialogs.Avalonia;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Nitrox.Launcher.Models.Design;
|
||||
using Nitrox.Launcher.ViewModels;
|
||||
using Nitrox.Launcher.Views;
|
||||
|
||||
namespace Nitrox.Launcher;
|
||||
|
||||
public sealed class AppViewLocator : ViewLocatorBase
|
||||
{
|
||||
private static IServiceProvider serviceProvider;
|
||||
|
||||
public AppViewLocator(IServiceProvider serviceProvider)
|
||||
{
|
||||
AppViewLocator.serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
private static MainWindow mainWindow;
|
||||
public static MainWindow MainWindow
|
||||
{
|
||||
get
|
||||
{
|
||||
if (mainWindow != null)
|
||||
{
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
if (Application.Current?.ApplicationLifetime is ClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return mainWindow = (MainWindow)desktop.MainWindow;
|
||||
}
|
||||
throw new NotSupportedException("This Avalonia application is only supported on desktop environments.");
|
||||
}
|
||||
}
|
||||
|
||||
public static IRoutingScreen HostScreen => serviceProvider.GetRequiredService<IRoutingScreen>();
|
||||
|
||||
public override ViewDefinition Locate(object viewModel)
|
||||
{
|
||||
// Only dialogs need to be mapped here. Other views are handled in MainWindow.axaml.
|
||||
static Type GetViewType(object viewModel) => viewModel switch
|
||||
{
|
||||
CreateServerViewModel => typeof(CreateServerModal),
|
||||
DialogBoxViewModel => typeof(DialogBoxModal),
|
||||
ObjectPropertyEditorViewModel => typeof(ObjectPropertyEditorModal),
|
||||
BackupRestoreViewModel => typeof(BackupRestoreModal),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(viewModel), viewModel, null)
|
||||
};
|
||||
|
||||
Type newView = GetViewType(viewModel);
|
||||
return new ViewDefinition(newView, () => serviceProvider.GetRequiredService(newView));
|
||||
}
|
||||
}
|
3
Nitrox.Launcher/Assets/Icons/brand/bluesky.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 568 500">
|
||||
<path class="st0" fill="#fff" d="M123 30c65 49 135 148 161 201 26-53 96-152 161-201 47-36 123-63 123 24 0 17-10 146-16 167-20 72-94 90-160 79 115 20 144 85 81 149-120 123-172-31-185-70-3-7-4-11-4-8 0-3-1 1-4 8-13 39-65 193-185 70-63-64-34-129 81-149-66 11-140-7-160-79C10 200 0 71 0 54 0-33 76-6 123 30z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 381 B |
3
Nitrox.Launcher/Assets/Icons/brand/discord.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15.97 12.2">
|
||||
<path class="st0" d="M13.57 1.1a13.2 13.2 0 0 0-3.2-1.1l-.5.9a12.2 12.2 0 0 0-3.6 0 8 8 0 0 0-.4-.9c-1.2.2-2.3.5-3.3 1a12.9 12.9 0 0 0-2.5 9.1 13.3 13.3 0 0 0 4 2l.9-1.3a9 9 0 0 1-1.3-.6v-.1l.2-.2a9.5 9.5 0 0 0 8.1 0l.3.2a8 8 0 0 1-1.2.7l.8 1.4a13.2 13.2 0 0 0 4-2c.4-3.5-.5-6.5-2.3-9.2m-8.3 7.3c-.7 0-1.4-.7-1.4-1.6 0-.9.7-1.6 1.5-1.6.9 0 1.5.7 1.5 1.6 0 .9-.7 1.6-1.5 1.6m5.4 0c-.8 0-1.5-.7-1.5-1.6 0-.9.8-1.6 1.5-1.6s1.4.7 1.4 1.6c0 .9-.6 1.6-1.4 1.6" style="fill:#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 551 B |
3
Nitrox.Launcher/Assets/Icons/brand/github.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22.94 22.43">
|
||||
<path class="st0" fill="#fff" d="M11.5 0a11.5 11.5 0 0 0-3.64 22.41c.58.1.8-.24.8-.55l-.02-2.14c-2.89.53-3.64-.7-3.87-1.35-.13-.33-.69-1.35-1.18-1.62-.4-.22-.97-.75 0-.77.9 0 1.54.84 1.76 1.18 1.03 1.74 2.7 1.25 3.35.95.1-.75.4-1.25.73-1.54-2.56-.28-5.23-1.28-5.23-5.67 0-1.25.44-2.29 1.18-3.1a4.14 4.14 0 0 1 .11-3.04s.96-.3 3.16 1.18a10.66 10.66 0 0 1 5.75 0c2.2-1.5 3.17-1.18 3.17-1.18.63 1.58.23 2.76.11 3.05.73.8 1.18 1.82 1.18 3.09 0 4.4-2.69 5.39-5.25 5.67.42.36.78 1.05.78 2.13l-.02 3.16c0 .3.22.67.8.55A11.52 11.52 0 0 0 11.5 0Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 618 B |
3
Nitrox.Launcher/Assets/Icons/brand/reddit.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60.3 53.2">
|
||||
<path class="st0" d="M60.2 27a6.6 6.6 0 0 0-11-4.5A32 32 0 0 0 31.8 17l2.9-14 9.6 2a4.5 4.5 0 1 0 .6-2.8L33.9 0c-.8-.2-1.5.3-1.7 1l-3.3 15.7c-6.3 0-12.4 2-17.6 5.5A6.6 6.6 0 1 0 4.2 33v2c0 10 11.8 18.2 26.2 18.2 14.5 0 26.3-8 26.3-18.2v-2a6.6 6.6 0 0 0 3.6-6zm-45 4.5a4.5 4.5 0 0 1 9 0 4.5 4.5 0 0 1-9 0zm26.2 12.6V44a17.3 17.3 0 0 1-11.2 3.4c-4 .2-8-1-11-3.4-.5-.5-.4-1.3 0-1.7.5-.4 1.2-.4 1.6 0 2.7 2 6 3 9.4 2.8a15 15 0 0 0 9.4-2.7c.5-.5 1.3-.5 1.8 0s.4 1.3 0 1.8zm-.7-7.8h-.2a4.5 4.5 0 0 1 0-9c2.5 0 4.6 2 4.6 4.4a4.5 4.5 0 0 1-4.4 4.6z" style="fill:#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 637 B |
3
Nitrox.Launcher/Assets/Icons/brand/twitter.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 48 48">
|
||||
<path class="st0" fill="#fff" d="M48 4.74a19.2 19.2 0 0 1-5.65 1.58A10.08 10.08 0 0 0 46.68.74c-1.9 1.16-4.01 2-6.26 2.45A9.71 9.71 0 0 0 33.23 0a9.97 9.97 0 0 0-9.84 10.1c0 .79.08 1.56.25 2.3A27.73 27.73 0 0 1 3.34 1.84 10.28 10.28 0 0 0 6.4 15.33a9.67 9.67 0 0 1-4.46-1.26v.12c0 4.9 3.4 8.98 7.9 9.9a9.39 9.39 0 0 1-4.45.18 9.89 9.89 0 0 0 9.2 7.01A19.44 19.44 0 0 1 0 35.47 27.42 27.42 0 0 0 15.1 40c18.1 0 28-15.38 28-28.73 0-.44 0-.88-.02-1.3A20.45 20.45 0 0 0 48 4.73"/>
|
||||
</svg>
|
After Width: | Height: | Size: 549 B |
3
Nitrox.Launcher/Assets/Icons/close.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 199 198" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke="black" style="stroke-width:20;stroke-linejoin:round" d="M11 187 188 11Zm177 0-88-88-89-88 89 88Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 187 B |
4
Nitrox.Launcher/Assets/Icons/error.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88">
|
||||
<path stroke="black" d="m28 61 32-33Zm32 0L44 45 28 28l16 17Z" style="stroke-width:7;stroke-linejoin:round"/>
|
||||
<circle stroke="black" cx="44.1" cy="44.1" r="40.6" style="fill:none;stroke-width:7"/>
|
||||
</svg>
|
After Width: | Height: | Size: 272 B |
5
Nitrox.Launcher/Assets/Icons/info.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88">
|
||||
<circle stroke="black" cx="44.1" cy="44.1" r="40.6" style="fill:none;stroke-width:7"/>
|
||||
<path stroke="black" d="M38 35h6zm6 0c0 66 0 0 0 0z" style="fill:none;stroke-width:7;stroke-linejoin:round;stroke-dasharray:none"/>
|
||||
<circle fill="black" cx="43" cy="20.3" r="5.1" style="fill-opacity:1;stroke:none;stroke-width:34.4577;stroke-linecap:butt;stroke-linejoin:bevel;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
</svg>
|
After Width: | Height: | Size: 484 B |
4
Nitrox.Launcher/Assets/Icons/loading.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
||||
<path d="M12 1a11 11 0 1 0 11 11A11 11 0 0 0 12 1zm0 19a8 8 0 1 1 8-8 8 8 0 0 1-8 8z" opacity=".25" />
|
||||
<path d="M10.14 1.16a11 11 0 0 0-9 8.92A1.59 1.59 0 0 0 2.46 12a1.52 1.52 0 0 0 1.65-1.3 8 8 0 0 1 6.66-6.61A1.42 1.42 0 0 0 12 2.69h0a1.57 1.57 0 0 0-1.86-1.53z" />
|
||||
</svg>
|
After Width: | Height: | Size: 347 B |
3
Nitrox.Launcher/Assets/Icons/maximize.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 132">
|
||||
<path class="st0" d="M0 1h130v130H0z" stroke="black" style="fill:none;stroke-width:15"/>
|
||||
</svg>
|
After Width: | Height: | Size: 162 B |
3
Nitrox.Launcher/Assets/Icons/minimize.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 132">
|
||||
<path fill="black" d="M0 61h130v10H0Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 115 B |
3
Nitrox.Launcher/Assets/Icons/restore.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 132">
|
||||
<path stroke="black" d="M30 7v29h62v64h31V7Zm62 93V36H7v89h85Z" style="fill:none;stroke-width:8;"/>
|
||||
</svg>
|
After Width: | Height: | Size: 173 B |
3
Nitrox.Launcher/Assets/Icons/send.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 132">
|
||||
<path fill="black" d="M0 0v51l107 16L0 81v51l130-65Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 129 B |
4
Nitrox.Launcher/Assets/Icons/success.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88">
|
||||
<circle stroke="black" cx="44.1" cy="44.1" r="40.6" style="fill:none;stroke-width:7"/>
|
||||
<path stroke="black" d="m23 47 15 13 26-27-26 27Z" style="stroke-width:7;stroke-linejoin:round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 260 B |
5
Nitrox.Launcher/Assets/Icons/warning.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 63.3 63.5">
|
||||
<circle stroke="black" cx="31.6" cy="48" r="4.3" />
|
||||
<path stroke="black" d="M31.6 25.8v14.4z" style="stroke-width:7"/>
|
||||
<path stroke="black" d="M85.3 66H18.5l16.7-29L52 8.1l16.7 29Z" style="fill:none;stroke-width:5;stroke-linejoin:round" transform="matrix(.88 0 0 1 -14 -5.5)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 361 B |
191
Nitrox.Launcher/Assets/Images/LICENSE.txt
Normal file
@@ -0,0 +1,191 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024 Nitrox
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
BIN
Nitrox.Launcher/Assets/Images/banners/community.png
Normal file
After Width: | Height: | Size: 281 KiB |
BIN
Nitrox.Launcher/Assets/Images/banners/home.png
Normal file
After Width: | Height: | Size: 724 KiB |
BIN
Nitrox.Launcher/Assets/Images/blog/vines.png
Normal file
After Width: | Height: | Size: 651 KiB |
BIN
Nitrox.Launcher/Assets/Images/gallery/image-1.png
Normal file
After Width: | Height: | Size: 596 KiB |
BIN
Nitrox.Launcher/Assets/Images/gallery/image-2.png
Normal file
After Width: | Height: | Size: 456 KiB |
BIN
Nitrox.Launcher/Assets/Images/gallery/image-3.png
Normal file
After Width: | Height: | Size: 683 KiB |
BIN
Nitrox.Launcher/Assets/Images/gallery/image-4.png
Normal file
After Width: | Height: | Size: 678 KiB |
BIN
Nitrox.Launcher/Assets/Images/nitrox-icon.ico
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
Nitrox.Launcher/Assets/Images/store-icons/discord.png
Normal file
After Width: | Height: | Size: 596 B |
BIN
Nitrox.Launcher/Assets/Images/store-icons/epic.png
Normal file
After Width: | Height: | Size: 814 B |
BIN
Nitrox.Launcher/Assets/Images/store-icons/missing.png
Normal file
After Width: | Height: | Size: 620 B |
BIN
Nitrox.Launcher/Assets/Images/store-icons/pirated.png
Normal file
After Width: | Height: | Size: 521 B |
BIN
Nitrox.Launcher/Assets/Images/store-icons/steam.png
Normal file
After Width: | Height: | Size: 782 B |
BIN
Nitrox.Launcher/Assets/Images/store-icons/xbox.png
Normal file
After Width: | Height: | Size: 748 B |
BIN
Nitrox.Launcher/Assets/Images/subnautica-bz-icon.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
Nitrox.Launcher/Assets/Images/subnautica-icon.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
Nitrox.Launcher/Assets/Images/subnautica-name.png
Normal file
After Width: | Height: | Size: 341 KiB |
BIN
Nitrox.Launcher/Assets/Images/tabs-icons/blog.png
Normal file
After Width: | Height: | Size: 227 B |
BIN
Nitrox.Launcher/Assets/Images/tabs-icons/community.png
Normal file
After Width: | Height: | Size: 254 B |
BIN
Nitrox.Launcher/Assets/Images/tabs-icons/options.png
Normal file
After Width: | Height: | Size: 373 B |
BIN
Nitrox.Launcher/Assets/Images/tabs-icons/play.png
Normal file
After Width: | Height: | Size: 307 B |
BIN
Nitrox.Launcher/Assets/Images/tabs-icons/server.png
Normal file
After Width: | Height: | Size: 275 B |
BIN
Nitrox.Launcher/Assets/Images/tabs-icons/update-available.png
Normal file
After Width: | Height: | Size: 451 B |
BIN
Nitrox.Launcher/Assets/Images/tabs-icons/update.png
Normal file
After Width: | Height: | Size: 258 B |
BIN
Nitrox.Launcher/Assets/Images/world-manager/back.png
Normal file
After Width: | Height: | Size: 209 B |
BIN
Nitrox.Launcher/Assets/Images/world-manager/cog.png
Normal file
After Width: | Height: | Size: 661 B |
BIN
Nitrox.Launcher/Assets/Images/world-manager/delete.png
Normal file
After Width: | Height: | Size: 279 B |
BIN
Nitrox.Launcher/Assets/Images/world-manager/export.png
Normal file
After Width: | Height: | Size: 405 B |
BIN
Nitrox.Launcher/Assets/Images/world-manager/import.png
Normal file
After Width: | Height: | Size: 379 B |
BIN
Nitrox.Launcher/Assets/Images/world-manager/plus.png
Normal file
After Width: | Height: | Size: 221 B |
BIN
Nitrox.Launcher/Assets/Images/world-manager/reload.png
Normal file
After Width: | Height: | Size: 516 B |
BIN
Nitrox.Launcher/Assets/Images/world-manager/server.png
Normal file
After Width: | Height: | Size: 7.3 KiB |
BIN
Nitrox.Launcher/Assets/Images/world-manager/window.png
Normal file
After Width: | Height: | Size: 424 B |
18
Nitrox.Launcher/Assets/MacOS/Entitlements.plist
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>SecTaskAccess</key>
|
||||
<array>
|
||||
<string>allowed</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
BIN
Nitrox.Launcher/Assets/MacOS/Resources/Nitrox.icns
Normal file
2
Nitrox.Launcher/GlobalUsings.cs
Normal file
@@ -0,0 +1,2 @@
|
||||
global using NitroxModel;
|
||||
global using Nitrox.Launcher.Models.Extensions;
|
43
Nitrox.Launcher/Models/Behaviors/FocusOnViewShowBehavior.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Xaml.Interactivity;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Focuses the <see cref="Behavior.AssociatedObject" /> when its parent view is shown.
|
||||
/// </summary>
|
||||
public class FocusOnViewShowBehavior : Behavior<Control>
|
||||
{
|
||||
protected override void OnAttached()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Register<ViewShownMessage>(this, static (obj, _) => (obj as FocusOnViewShowBehavior)?.Focus());
|
||||
base.OnAttached();
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
WeakReferenceMessenger.Default.UnregisterAll(this);
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
protected override void OnAttachedToVisualTree() => Focus();
|
||||
|
||||
private void Focus()
|
||||
{
|
||||
if (AssociatedObject == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!AssociatedObject.IsEffectivelyVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AssociatedObject.Focus();
|
||||
if (AssociatedObject is TextBox textBox)
|
||||
{
|
||||
textBox.SelectAll();
|
||||
}
|
||||
}
|
||||
}
|
133
Nitrox.Launcher/Models/Behaviors/SmoothScrollBehavior.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Reactive;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Behaviors;
|
||||
|
||||
public abstract class SmoothScrollBehavior
|
||||
{
|
||||
private static CancellationTokenSource animationTokenSource;
|
||||
private static readonly Easing smoothScrollEasing = new ExponentialEaseOut();
|
||||
private static readonly Animation animation = new()
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(250),
|
||||
Easing = smoothScrollEasing,
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Cue = new Cue(0),
|
||||
Setters = { new Setter(ScrollViewer.OffsetProperty, 0) }
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Cue = new Cue(1),
|
||||
Setters = { new Setter(ScrollViewer.OffsetProperty, 0) }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly AttachedProperty<bool> SmoothScrollProperty =
|
||||
AvaloniaProperty.RegisterAttached<SmoothScrollBehavior, ScrollViewer, bool>("SmoothScroll");
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the target offset which was last used as smooth scrolling target.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// lastOffsetProperty is needed here since the ScrollViewer.Offset property is already set to the target offset when
|
||||
/// the PointerWheelChanged event is raised
|
||||
/// </remarks>
|
||||
private static readonly AttachedProperty<Vector> lastOffsetProperty =
|
||||
AvaloniaProperty.RegisterAttached<SmoothScrollBehavior, ScrollViewer, Vector>("LastOffset", new Vector(0, 0));
|
||||
|
||||
static SmoothScrollBehavior()
|
||||
{
|
||||
SmoothScrollProperty.Changed.Subscribe(new AnonymousObserver<AvaloniaPropertyChangedEventArgs<bool>>(OnEnableSmoothScrollingChanged));
|
||||
ScrollViewer.OffsetProperty.Changed.Subscribe(new AnonymousObserver<AvaloniaPropertyChangedEventArgs<Vector>>(OnScrollOffsetChanged));
|
||||
}
|
||||
|
||||
private static void OnScrollOffsetChanged(AvaloniaPropertyChangedEventArgs<Vector> args)
|
||||
{
|
||||
if (args.Sender is not ScrollViewer scrollViewer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// This keeps LastOffset in sync with programmatic changes to offset so there won't be any huge and ugly scroll jumps.
|
||||
if (animationTokenSource is null or { IsCancellationRequested: true })
|
||||
{
|
||||
SetLastOffset(scrollViewer, args.OldValue.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetSmoothScroll(ScrollViewer element) => element.GetValue(SmoothScrollProperty);
|
||||
|
||||
public static void SetSmoothScroll(ScrollViewer element, bool value) => element.SetValue(SmoothScrollProperty, value);
|
||||
|
||||
private static Vector GetLastOffset(ScrollViewer element) => element.GetValue(lastOffsetProperty);
|
||||
|
||||
private static void SetLastOffset(ScrollViewer element, Vector value) => element.SetValue(lastOffsetProperty, value);
|
||||
|
||||
private static void OnEnableSmoothScrollingChanged(AvaloniaPropertyChangedEventArgs<bool> args)
|
||||
{
|
||||
if (args.Sender is not ScrollViewer scrollViewer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.NewValue.GetValueOrDefault())
|
||||
{
|
||||
scrollViewer.AddHandler(InputElement.PointerWheelChangedEvent, OnPointerWheelChanged, handledEventsToo: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
scrollViewer.RemoveHandler(InputElement.PointerWheelChangedEvent, OnPointerWheelChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPointerWheelChanged(object sender, PointerWheelEventArgs e)
|
||||
{
|
||||
if (sender is not ScrollViewer scrollViewer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel ongoing animations
|
||||
if (animationTokenSource is { IsCancellationRequested: false })
|
||||
{
|
||||
animationTokenSource.Cancel();
|
||||
animationTokenSource.Dispose();
|
||||
}
|
||||
|
||||
// Get new offset (already set on each ScrollViewer as attached property)
|
||||
Vector lastOffset = GetLastOffset(scrollViewer);
|
||||
Vector newOffset = scrollViewer.Offset;
|
||||
if (lastOffset != newOffset)
|
||||
{
|
||||
animationTokenSource = new CancellationTokenSource();
|
||||
SetLastOffset(scrollViewer, newOffset);
|
||||
AnimateScrollToTargetAsync(scrollViewer, lastOffset, newOffset, animationTokenSource.Token).ContinueWithHandleError();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AnimateScrollToTargetAsync(ScrollViewer scrollViewer, Vector previousOffset, Vector targetOffset, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
((Setter)animation.Children[0].Setters[0]).Value = previousOffset;
|
||||
((Setter)animation.Children[1].Setters[0]).Value = targetOffset;
|
||||
await animation.RunAsync(scrollViewer, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
94
Nitrox.Launcher/Models/Controls/BlurControl.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Rendering.SceneGraph;
|
||||
using Avalonia.Skia;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Draws a blur filter over the already rendered content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Based off of GrayscaleControl
|
||||
/// </remarks>
|
||||
public sealed class BlurControl : Decorator
|
||||
{
|
||||
public static readonly StyledProperty<float> BlurStrengthProperty =
|
||||
AvaloniaProperty.Register<BlurControl, float>(nameof(BlurStrength), 5);
|
||||
|
||||
/// <summary>
|
||||
/// Sets or gets how strong the blur should be. Defaults to 5.
|
||||
/// </summary>
|
||||
public float BlurStrength
|
||||
{
|
||||
get => GetValue(BlurStrengthProperty);
|
||||
set => SetValue(BlurStrengthProperty, value);
|
||||
}
|
||||
|
||||
static BlurControl()
|
||||
{
|
||||
ClipToBoundsProperty.OverrideDefaultValue<BlurControl>(true);
|
||||
AffectsRender<BlurControl>(OpacityProperty);
|
||||
AffectsRender<BlurControl>(BlurStrengthProperty);
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
context.Custom(new BlurBehindRenderOperation((byte)Math.Round(byte.MaxValue * Opacity), BlurStrength, new Rect(default, Bounds.Size)));
|
||||
}
|
||||
|
||||
private sealed record BlurBehindRenderOperation : ICustomDrawOperation
|
||||
{
|
||||
private readonly Rect bounds;
|
||||
private readonly byte opacity;
|
||||
private readonly float strength;
|
||||
|
||||
public Rect Bounds => bounds;
|
||||
|
||||
public BlurBehindRenderOperation(byte opacity, float strength, Rect bounds)
|
||||
{
|
||||
this.opacity = opacity;
|
||||
this.strength = strength;
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public bool HitTest(Point p) => bounds.Contains(p);
|
||||
|
||||
public void Render(ImmediateDrawingContext context)
|
||||
{
|
||||
ISkiaSharpApiLeaseFeature leaseFeature = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
|
||||
if (leaseFeature == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
using ISkiaSharpApiLease skia = leaseFeature.Lease();
|
||||
if (!skia.SkCanvas.TotalMatrix.TryInvert(out SKMatrix currentInvertedTransform))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (skia.SkSurface == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using SKImage backgroundSnapshot = skia.SkSurface.Snapshot();
|
||||
using SKShader backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, currentInvertedTransform);
|
||||
using SKImageFilter blurFilter = SKImageFilter.CreateBlur(strength, strength);
|
||||
using SKPaint paint = new();
|
||||
paint.Shader = backdropShader;
|
||||
paint.ImageFilter = blurFilter;
|
||||
paint.Color = new SKColor(0, 0, 0, opacity);
|
||||
skia.SkCanvas.DrawRect(0, 0, (float)bounds.Width, (float)bounds.Height, paint);
|
||||
}
|
||||
|
||||
public bool Equals(ICustomDrawOperation other) => Equals(other as BlurBehindRenderOperation);
|
||||
}
|
||||
}
|
121
Nitrox.Launcher/Models/Controls/CustomTitlebar.axaml
Normal file
@@ -0,0 +1,121 @@
|
||||
<ResourceDictionary
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:Nitrox.Launcher.Models.Controls"
|
||||
xmlns:converters="clr-namespace:Nitrox.Launcher.Models.Converters">
|
||||
<Design.PreviewWith>
|
||||
<StackPanel Width="200">
|
||||
<controls:CustomTitlebar
|
||||
Background="IndianRed"
|
||||
CanMaximize="False"
|
||||
CanMinimize="False" />
|
||||
<controls:CustomTitlebar
|
||||
Background="ForestGreen"
|
||||
CanMaximize="True"
|
||||
CanMinimize="False" />
|
||||
<controls:CustomTitlebar Background="CornflowerBlue" CanMaximize="False" />
|
||||
<controls:CustomTitlebar Background="Violet" />
|
||||
</StackPanel>
|
||||
</Design.PreviewWith>
|
||||
|
||||
<ControlTheme x:Key="{x:Type controls:CustomTitlebar}" TargetType="controls:CustomTitlebar">
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Panel Background="Transparent">
|
||||
<Border
|
||||
Padding="5,2"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{TemplateBinding ShowTitle}">
|
||||
<TextBlock
|
||||
VerticalAlignment="Stretch"
|
||||
Text="{Binding $parent[Window].Title}"
|
||||
TextAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel>
|
||||
<Button
|
||||
x:Name="PART_MinimizeButton"
|
||||
Classes.leftOff1="{x:True}"
|
||||
Classes.leftOff2="{x:True}"
|
||||
Command="{Binding MinimizeCommand, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
IsVisible="{TemplateBinding CanMinimize}">
|
||||
<Svg Classes="theme" Path="/Assets/Icons/minimize.svg" />
|
||||
</Button>
|
||||
<Button
|
||||
x:Name="PART_MaximizeButton"
|
||||
Classes.leftOff1="{Binding !#PART_MinimizeButton.IsVisible}"
|
||||
Classes.leftOff2="{x:True}"
|
||||
Command="{Binding ToggleMaximizeCommand, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<Button.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<TemplateBinding Property="CanMaximize" />
|
||||
<Binding Path="$parent[Window].CanResize" />
|
||||
</MultiBinding>
|
||||
</Button.IsVisible>
|
||||
<Svg
|
||||
Classes="theme"
|
||||
Classes.maximize="{Binding $parent[Window].WindowState, Converter={converters:EqualityConverter}, ConverterParameter={x:Static WindowState.Normal}}"
|
||||
Classes.restore="{Binding $parent[Window].WindowState, Converter={converters:EqualityConverter}, ConverterParameter={x:Static WindowState.Maximized}}" />
|
||||
</Button>
|
||||
<Button
|
||||
x:Name="PART_CloseButton"
|
||||
Classes="close"
|
||||
Classes.leftOff1="{Binding !#PART_MinimizeButton.IsVisible}"
|
||||
Classes.leftOff2="{Binding !#PART_MaximizeButton.IsVisible}"
|
||||
Command="{Binding CloseCommand, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<Svg Classes="theme" Path="/Assets/Icons/close.svg" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
|
||||
<!-- Default template values -->
|
||||
<Setter Property="Background" Value="Black" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="ZIndex" Value="100" />
|
||||
<Setter Property="Height" Value="28" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
<Setter Property="ShowTitle" Value="False" />
|
||||
|
||||
<Style Selector="^ /template/ StackPanel">
|
||||
<Setter Property="Orientation" Value="Horizontal" />
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
|
||||
<Style Selector="^ > Button">
|
||||
<Setter Property="Width" Value="46" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
|
||||
<!-- This selector force overrides button style. TODO: fix ButtonStyle.axaml so this isn't necessary and move setters out of this selector. -->
|
||||
<Style Selector="^:nth-child(1n)">
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Background" Value="{TemplateBinding Background}" />
|
||||
</Style>
|
||||
|
||||
<!-- Button[IsVisible=True]:nth-child(1) doesn't work to filter only visible buttons. Here, leftOff1 is used to check if previous button is on and leftOff2 is the next one over that. -->
|
||||
<Style Selector="^.leftOff1.leftOff2">
|
||||
<Setter Property="CornerRadius" Value="0 0 0 5" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^ > :is(Control)">
|
||||
<Setter Property="Height" Value="11" />
|
||||
<Setter Property="IsHitTestVisible" Value="False" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^.close:pointerover">
|
||||
<Setter Property="Background" Value="Red" />
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<Style Selector="^ Svg.maximize">
|
||||
<Setter Property="Path" Value="/Assets/Icons/maximize.svg" />
|
||||
</Style>
|
||||
<Style Selector="^ Svg.restore">
|
||||
<Setter Property="Path" Value="/Assets/Icons/restore.svg" />
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
</ControlTheme>
|
||||
</ResourceDictionary>
|
113
Nitrox.Launcher/Models/Controls/CustomTitlebar.axaml.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
public partial class CustomTitlebar : TemplatedControl
|
||||
{
|
||||
public static readonly DirectProperty<CustomTitlebar, bool> ShowTitleProperty =
|
||||
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
|
||||
nameof(showTitle),
|
||||
o => o.showTitle,
|
||||
(o, v) => o.showTitle = v, true);
|
||||
|
||||
public static readonly DirectProperty<CustomTitlebar, bool> CanMaximizeProperty =
|
||||
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
|
||||
nameof(CanMaximize),
|
||||
o => o.CanMaximize,
|
||||
(o, v) => o.CanMaximize = v, true);
|
||||
|
||||
public static readonly DirectProperty<CustomTitlebar, bool> CanMinimizeProperty =
|
||||
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
|
||||
nameof(CanMinimize),
|
||||
o => o.CanMinimize,
|
||||
(o, v) => o.CanMinimize = v, true);
|
||||
|
||||
private bool showTitle = true;
|
||||
private bool canMaximize = true;
|
||||
private bool canMinimize = true;
|
||||
|
||||
public bool ShowTitle
|
||||
{
|
||||
get => showTitle;
|
||||
set => SetAndRaise(ShowTitleProperty, ref showTitle, value);
|
||||
}
|
||||
|
||||
public bool CanMaximize
|
||||
{
|
||||
get => canMaximize;
|
||||
set => SetAndRaise(CanMaximizeProperty, ref canMaximize, value);
|
||||
}
|
||||
|
||||
public bool CanMinimize
|
||||
{
|
||||
get => canMinimize;
|
||||
set => SetAndRaise(CanMinimizeProperty, ref canMinimize, value);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void Minimize()
|
||||
{
|
||||
if (!CanMinimize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (this.GetWindow() is not { } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.WindowState = WindowState.Minimized;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void ToggleMaximize()
|
||||
{
|
||||
if (!CanMaximize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (this.GetWindow() is not { } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.WindowState = window.WindowState == WindowState.Normal ? WindowState.Maximized : WindowState.Normal;
|
||||
}
|
||||
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
PointerPressed += OnPointerPressed;
|
||||
DoubleTapped += OnDoubleTapped;
|
||||
base.OnLoaded(e);
|
||||
}
|
||||
|
||||
protected override void OnUnloaded(RoutedEventArgs e)
|
||||
{
|
||||
PointerPressed -= OnPointerPressed;
|
||||
DoubleTapped -= OnDoubleTapped;
|
||||
base.OnUnloaded(e);
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.Source is Visual element && element.GetWindow() is { } window)
|
||||
{
|
||||
window.BeginMoveDrag(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDoubleTapped(object sender, TappedEventArgs e) => ToggleMaximize();
|
||||
|
||||
[RelayCommand]
|
||||
private void Close()
|
||||
{
|
||||
if (this.GetWindow() is not { } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.CloseByUser();
|
||||
}
|
||||
}
|
189
Nitrox.Launcher/Models/Controls/FittingWrapPanel.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Utilities;
|
||||
using static System.Math;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Panel that arranges stretchable child controls to fit min width, up to the limit of <see cref="MinItemWidth" />.
|
||||
/// Code inspired by Avalonia's WrapPanel
|
||||
/// (https://github.com/AvaloniaUI/Avalonia/blob/master/src/Avalonia.Controls/WrapPanel.cs).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Looks similar to YouTube video layout.
|
||||
/// </remarks>
|
||||
public class FittingWrapPanel : Panel, INavigableContainer
|
||||
{
|
||||
public static readonly StyledProperty<double> MinItemWidthProperty =
|
||||
AvaloniaProperty.Register<WrapPanel, double>(nameof(MinItemWidth), 100);
|
||||
|
||||
public double MinItemWidth
|
||||
{
|
||||
get => GetValue(MinItemWidthProperty);
|
||||
set => SetValue(MinItemWidthProperty, value);
|
||||
}
|
||||
|
||||
static FittingWrapPanel()
|
||||
{
|
||||
AffectsMeasure<WrapPanel>(MinItemWidthProperty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Size MeasureOverride(Size constraint)
|
||||
{
|
||||
UVSize curLineSize = new();
|
||||
UVSize panelSize = new();
|
||||
UVSize uvConstraint = new(constraint.Width, constraint.Height);
|
||||
|
||||
int itemsPerRow = (int)Min(constraint.Width / MinItemWidth, Max(Children.Count, 1));
|
||||
double adjustedWidth = constraint.Width / itemsPerRow;
|
||||
|
||||
for (int i = 0, count = Children.Count; i < count; i++)
|
||||
{
|
||||
Control child = Children[i];
|
||||
child.Measure(new Size(adjustedWidth, constraint.Height));
|
||||
|
||||
UVSize sz = new(adjustedWidth, child.DesiredSize.Height);
|
||||
|
||||
if (MathUtilities.GreaterThan(curLineSize.Width + sz.Width, uvConstraint.Width)) // Need to switch to another line
|
||||
{
|
||||
panelSize = new UVSize { Width = Max(curLineSize.Width, panelSize.Width), Height = panelSize.Height + curLineSize.Height };
|
||||
curLineSize = sz;
|
||||
|
||||
if (MathUtilities.GreaterThan(sz.Width, uvConstraint.Width)) // The element is wider then the constraint - give it a separate line
|
||||
{
|
||||
panelSize = new UVSize { Width = Max(sz.Width, panelSize.Width), Height = panelSize.Height + sz.Height };
|
||||
curLineSize = new UVSize();
|
||||
}
|
||||
}
|
||||
else // Continue to accumulate a line
|
||||
{
|
||||
curLineSize = new UVSize { Width = curLineSize.Width + sz.Width, Height = Max(sz.Height, curLineSize.Height) };
|
||||
}
|
||||
}
|
||||
|
||||
panelSize = new UVSize { Width = Max(curLineSize.Width, panelSize.Width), Height = panelSize.Height + curLineSize.Height };
|
||||
|
||||
return new Size(panelSize.Width, panelSize.Height);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Size ArrangeOverride(Size finalSize)
|
||||
{
|
||||
int firstInLine = 0;
|
||||
double accumulatedV = 0;
|
||||
UVSize curLineSize = new();
|
||||
UVSize uvFinalSize = new(finalSize.Width, finalSize.Height);
|
||||
|
||||
int itemsPerRow = (int)Min(finalSize.Width / MinItemWidth, Max(Children.Count, 1));
|
||||
double adjustedWidth = finalSize.Width / itemsPerRow;
|
||||
|
||||
for (int i = 0; i < Children.Count; i++)
|
||||
{
|
||||
Control child = Children[i];
|
||||
UVSize sz = new(adjustedWidth, child.DesiredSize.Height);
|
||||
|
||||
if (MathUtilities.GreaterThan(curLineSize.Width + sz.Width, uvFinalSize.Width)) // Need to switch to another line
|
||||
{
|
||||
ArrangeLine(accumulatedV, curLineSize.Height, firstInLine, i, adjustedWidth);
|
||||
|
||||
accumulatedV += curLineSize.Height;
|
||||
curLineSize = sz;
|
||||
|
||||
if (MathUtilities.GreaterThan(sz.Width, uvFinalSize.Width)) // The element is wider then the constraint - give it a separate line
|
||||
{
|
||||
ArrangeLine(accumulatedV, sz.Height, i, ++i, adjustedWidth);
|
||||
|
||||
accumulatedV += sz.Height;
|
||||
curLineSize = new UVSize();
|
||||
}
|
||||
firstInLine = i;
|
||||
}
|
||||
else // Continue to accumulate a line
|
||||
{
|
||||
curLineSize = new UVSize { Width = curLineSize.Width + sz.Width, Height = Max(sz.Height, curLineSize.Height) };
|
||||
}
|
||||
}
|
||||
|
||||
if (firstInLine < Children.Count)
|
||||
{
|
||||
ArrangeLine(accumulatedV, curLineSize.Height, firstInLine, Children.Count, adjustedWidth);
|
||||
}
|
||||
|
||||
return finalSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next control in the specified direction.
|
||||
/// </summary>
|
||||
/// <param name="direction">The movement direction.</param>
|
||||
/// <param name="from">The control from which movement begins.</param>
|
||||
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
|
||||
/// <returns>The control.</returns>
|
||||
IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
|
||||
{
|
||||
Avalonia.Controls.Controls children = Children;
|
||||
int index = from is not null ? Children.IndexOf((Control)from) : -1;
|
||||
|
||||
switch (direction)
|
||||
{
|
||||
case NavigationDirection.First:
|
||||
index = 0;
|
||||
break;
|
||||
case NavigationDirection.Last:
|
||||
index = children.Count - 1;
|
||||
break;
|
||||
case NavigationDirection.Next:
|
||||
++index;
|
||||
break;
|
||||
case NavigationDirection.Previous:
|
||||
--index;
|
||||
break;
|
||||
case NavigationDirection.Left:
|
||||
index -= 1;
|
||||
break;
|
||||
case NavigationDirection.Right:
|
||||
index += 1;
|
||||
break;
|
||||
case NavigationDirection.Up:
|
||||
case NavigationDirection.Down:
|
||||
index = -1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (index >= 0 && index < children.Count)
|
||||
{
|
||||
return children[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ArrangeLine(double v, double lineV, int start, int end, double itemU)
|
||||
{
|
||||
Avalonia.Controls.Controls children = Children;
|
||||
double u = 0;
|
||||
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
Control child = children[i];
|
||||
child.Arrange(new Rect(u, v, itemU, lineV));
|
||||
u += itemU;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct UVSize
|
||||
{
|
||||
|
||||
internal UVSize(double width, double height)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
}
|
||||
|
||||
public double Width { get; init; }
|
||||
|
||||
internal double Height { get; init; }
|
||||
}
|
||||
}
|
93
Nitrox.Launcher/Models/Controls/GrayscaleControl.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
extern alias JB;
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Rendering.SceneGraph;
|
||||
using Avalonia.Skia;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Draws a grayscale filter over the already rendered content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Code from:<br/>
|
||||
/// - Draw-on-top logic: https://gist.github.com/kekekeks/ac06098a74fe87d49a9ff9ea37fa67bc <br/>
|
||||
/// - Grayscale logic: https://learn.microsoft.com/en-us/xamarin/xamarin-forms/user-interface/graphics/skiasharp/effects/color-filters <br/>
|
||||
/// </remarks>
|
||||
public class GrayscaleControl : Decorator
|
||||
{
|
||||
static GrayscaleControl()
|
||||
{
|
||||
AffectsRender<GrayscaleControl>(OpacityProperty);
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
context.Custom(new GrayscaleBehindRenderOperation((byte)Math.Round(byte.MaxValue * Opacity), new Rect(default, Bounds.Size)));
|
||||
}
|
||||
|
||||
private class GrayscaleBehindRenderOperation : ICustomDrawOperation
|
||||
{
|
||||
private static readonly float[] grayscaleColorFilterMatrix =
|
||||
{
|
||||
0.21f, 0.72f, 0.07f, 0, 0,
|
||||
0.21f, 0.72f, 0.07f, 0, 0,
|
||||
0.21f, 0.72f, 0.07f, 0, 0,
|
||||
0, 0, 0, 1, 0
|
||||
};
|
||||
|
||||
private readonly byte opacity;
|
||||
private readonly Rect bounds;
|
||||
|
||||
public Rect Bounds => bounds;
|
||||
|
||||
public GrayscaleBehindRenderOperation(byte opacity, Rect bounds)
|
||||
{
|
||||
this.opacity = opacity;
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public bool HitTest(Point p) => bounds.Contains(p);
|
||||
|
||||
public void Render(ImmediateDrawingContext context)
|
||||
{
|
||||
ISkiaSharpApiLeaseFeature leaseFeature = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
|
||||
if (leaseFeature == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
using ISkiaSharpApiLease skia = leaseFeature.Lease();
|
||||
if (!skia.SkCanvas.TotalMatrix.TryInvert(out SKMatrix currentInvertedTransform))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (skia.SkSurface == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using SKImage backgroundSnapshot = skia.SkSurface.Snapshot();
|
||||
using SKShader backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, currentInvertedTransform);
|
||||
using SKImageFilter grayscaleFilter = SKImageFilter.CreateColorFilter(CreateGrayscaleColorFilter());
|
||||
using SKPaint paint = new()
|
||||
{
|
||||
Shader = backdropShader,
|
||||
ImageFilter = grayscaleFilter,
|
||||
Color = new SKColor(0, 0, 0, opacity)
|
||||
};
|
||||
skia.SkCanvas.DrawRect(0, 0, (float)bounds.Width, (float)bounds.Height, paint);
|
||||
}
|
||||
|
||||
public bool Equals(ICustomDrawOperation other) => other is GrayscaleBehindRenderOperation op && op.bounds == bounds;
|
||||
|
||||
private static SKColorFilter CreateGrayscaleColorFilter() => SKColorFilter.CreateColorMatrix(grayscaleColorFilterMatrix);
|
||||
}
|
||||
}
|
51
Nitrox.Launcher/Models/Controls/RadioButtonGroup.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
public class RadioButtonGroup : ItemsControl
|
||||
{
|
||||
public static readonly DirectProperty<RadioButtonGroup, Type> EnumProperty = AvaloniaProperty.RegisterDirect<RadioButtonGroup, Type>(nameof(Enum), o => o.Enum, (o, v) => o.Enum = v);
|
||||
public static readonly StyledProperty<object> SelectedItemProperty = AvaloniaProperty.Register<RadioButtonGroup, object>(nameof(SelectedItem));
|
||||
|
||||
public static readonly DirectProperty<RadioButtonGroup, RelayCommand<Button>> ItemClickCommandProperty = AvaloniaProperty.RegisterDirect<RadioButtonGroup, RelayCommand<Button>>(nameof(ItemClickCommand), o => o.ItemClickCommand, (o, v) => o.ItemClickCommand = v);
|
||||
|
||||
private Type @enum;
|
||||
private RelayCommand<Button> itemClickCommand;
|
||||
|
||||
public Type Enum
|
||||
{
|
||||
get => @enum;
|
||||
set
|
||||
{
|
||||
if (value is not { IsEnum: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ItemsSource = System.Enum.GetValues(value);
|
||||
SetAndRaise(EnumProperty, ref @enum, value);
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand<Button> ItemClickCommand
|
||||
{
|
||||
get => itemClickCommand;
|
||||
private set => SetAndRaise(ItemClickCommandProperty, ref itemClickCommand, value);
|
||||
}
|
||||
|
||||
public object SelectedItem
|
||||
{
|
||||
get => GetValue(SelectedItemProperty);
|
||||
set => SetValue(SelectedItemProperty, value);
|
||||
}
|
||||
|
||||
public RadioButtonGroup()
|
||||
{
|
||||
itemClickCommand = new RelayCommand<Button>(param => SelectedItem = param.Tag);
|
||||
}
|
||||
|
||||
protected override Type StyleKeyOverride { get; } = typeof(ItemsControl);
|
||||
}
|
149
Nitrox.Launcher/Models/Controls/RichTextBlock.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A basic Rich Textbox. Supports bold, italic, underline, colors and hyperlinks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tag legend:<br />
|
||||
/// [b][/b] - Bold <br />
|
||||
/// [i][/i] - Italicize <br />
|
||||
/// [u][/u] - Underline <br />
|
||||
/// [#colorHex][/#colorHex] - Change text color <br />
|
||||
/// [Flavor text](example.com) <br />
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// [b]Text[/b] => <b>Text</b> <br />
|
||||
/// [i]Text[/i] => <i>Text</i> <br />
|
||||
/// [u]Text[/u] => <u>Text</u> <br />
|
||||
/// [#0000FF]Text[/#0000FF] => Text (with blue foreground) <br />
|
||||
/// <a href="https://example.com">Flavor text</a> <br />
|
||||
/// </example>
|
||||
public partial class RichTextBlock : TextBlock
|
||||
{
|
||||
private static readonly TextDecorationCollection underlineTextDecoration = [new() { Location = TextDecorationLocation.Underline }];
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
if (change.Property == TextProperty)
|
||||
{
|
||||
Inlines?.Clear();
|
||||
ParseTextAndAddInlines(Text ?? "", Inlines);
|
||||
// If all text was just tags, set Text to empty. Otherwise, it will be displayed as fallback by Avalonia.
|
||||
if (Inlines?.Count < 1)
|
||||
{
|
||||
Text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\[\/?([^]]+)\](?:\(([^\)]*)\))?")]
|
||||
private static partial Regex TagParserRegex { get; }
|
||||
|
||||
public static void ParseTextAndAddInlines(ReadOnlySpan<char> text, InlineCollection inlines)
|
||||
{
|
||||
if (inlines == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Regex.ValueMatchEnumerator matchEnumerator = TagParserRegex.EnumerateMatches(text);
|
||||
if (!matchEnumerator.MoveNext())
|
||||
{
|
||||
inlines.Add(new Run(text.ToString()));
|
||||
return;
|
||||
}
|
||||
|
||||
ValueMatch lastRange = default;
|
||||
Dictionary<string, Action<Run, string>> activeTags = new(4);
|
||||
do
|
||||
{
|
||||
ValueMatch range = matchEnumerator.Current;
|
||||
|
||||
// Handle text in-between previous and current tag.
|
||||
ReadOnlySpan<char> textPart = text[(lastRange.Index + lastRange.Length)..range.Index];
|
||||
if (!textPart.IsEmpty)
|
||||
{
|
||||
inlines.Add(CreateRunWithTags(textPart.ToString(), activeTags));
|
||||
}
|
||||
|
||||
// Handle current tag (this tracks state of active tags at current text position)
|
||||
ReadOnlySpan<char> match = text.Slice(range.Index, range.Length);
|
||||
switch (match)
|
||||
{
|
||||
case ['[', '/', ..]:
|
||||
activeTags.Remove(match[2..^1].ToString());
|
||||
break;
|
||||
case "[b]":
|
||||
activeTags["b"] = static (run, _) => run.FontWeight = FontWeight.Bold;
|
||||
break;
|
||||
case "[u]":
|
||||
activeTags["u"] = static (run, _) => run.TextDecorations = underlineTextDecoration;
|
||||
break;
|
||||
case "[i]":
|
||||
activeTags["i"] = static (run, _) => run.FontStyle = FontStyle.Italic;
|
||||
break;
|
||||
case ['[', ..] when match.IndexOf("](", StringComparison.OrdinalIgnoreCase) > -1:
|
||||
TextBlock textBlock = new();
|
||||
textBlock.Classes.Add("link");
|
||||
textBlock.Text = match[1..match.IndexOfAny("]")].ToString();
|
||||
textBlock.Tag = match[(match.IndexOfAny("(")+1)..match.IndexOfAny(")")].ToString();
|
||||
inlines.Add(textBlock);
|
||||
break;
|
||||
case ['[', '#', ..]:
|
||||
ReadOnlySpan<char> colorCode = match[1..match.IndexOfAny("]")];
|
||||
if (!Color.TryParse(colorCode, out Color _))
|
||||
{
|
||||
goto default;
|
||||
}
|
||||
activeTags[colorCode.ToString()] = static (run, tag) => run.Foreground = new SolidColorBrush(Color.Parse(tag));
|
||||
break;
|
||||
default:
|
||||
// Unknown tag, let's handle as normal text (issue is likely due to input text not knowing about this RichTextBox format)
|
||||
inlines.Add(CreateRunWithTags(match.ToString(), activeTags));
|
||||
break;
|
||||
}
|
||||
|
||||
lastRange = range;
|
||||
} while (matchEnumerator.MoveNext());
|
||||
|
||||
// Handle any final text (after the last tag).
|
||||
ReadOnlySpan<char> lastPart = text[(lastRange.Index + lastRange.Length)..];
|
||||
if (!lastPart.IsEmpty)
|
||||
{
|
||||
inlines.Add(CreateRunWithTags(lastPart.ToString(), activeTags));
|
||||
}
|
||||
}
|
||||
|
||||
private static Run CreateRunWithTags(string text, Dictionary<string, Action<Run, string>> tags)
|
||||
{
|
||||
Run run = new(text);
|
||||
KeyValuePair<string, Action<Run, string>>? lastColorTag = null;
|
||||
foreach (KeyValuePair<string, Action<Run, string>> pair in tags)
|
||||
{
|
||||
switch (pair.Key)
|
||||
{
|
||||
case ['#', ..]:
|
||||
// Optimization: only the last color needs to be applied for the current run, ignore all others.
|
||||
lastColorTag = pair;
|
||||
break;
|
||||
default:
|
||||
pair.Value(run, pair.Key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lastColorTag?.Value(run, lastColorTag.Value.Key);
|
||||
return run;
|
||||
}
|
||||
|
||||
protected override Type StyleKeyOverride { get; } = typeof(TextBlock);
|
||||
}
|
27
Nitrox.Launcher/Models/Controls/SelectableRichTextBlock.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <inheritdoc cref="RichTextBlock"/>
|
||||
public class SelectableRichTextBlock : SelectableTextBlock
|
||||
{
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
if (change.Property == TextProperty)
|
||||
{
|
||||
Inlines?.Clear();
|
||||
RichTextBlock.ParseTextAndAddInlines(Text ?? "", Inlines);
|
||||
// If all text was just tags, set Text to empty. Otherwise, it will be displayed as fallback by Avalonia.
|
||||
if (Inlines?.Count < 1)
|
||||
{
|
||||
Text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override Type StyleKeyOverride { get; } = typeof(SelectableTextBlock);
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Nitrox.Launcher.Models.Utils;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
public class BitmapAssetValueConverter : Converter<BitmapAssetValueConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
value switch
|
||||
{
|
||||
null => null,
|
||||
Bitmap when targetType.IsAssignableFrom(typeof(Bitmap)) => value,
|
||||
string s when targetType.IsAssignableFrom(typeof(Bitmap)) => AssetHelper.GetAssetFromStream(s, static stream => new Bitmap(stream)),
|
||||
_ => throw new NotSupportedException()
|
||||
};
|
||||
}
|
45
Nitrox.Launcher/Models/Converters/BoolToIconConverter.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Nitrox.Launcher.Models.Utils;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
public sealed class BoolToIconConverter : MarkupExtension, IValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// String that will be outputted if the input boolean value is <c>true</c>
|
||||
/// </summary>
|
||||
public string True { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// String that will be outputted if the input boolean value is <c>false</c>
|
||||
/// </summary>
|
||||
public string False { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Decides if the converter will inverse the input boolean value before computing the output
|
||||
/// </summary>
|
||||
public bool Invert { get; set; }
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not bool @bool)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Invert)
|
||||
{
|
||||
@bool = !@bool;
|
||||
}
|
||||
|
||||
return AssetHelper.GetAssetFromStream(@bool ? True : False, static stream => new Bitmap(stream));
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException();
|
||||
|
||||
public override object ProvideValue(IServiceProvider serviceProvider) => this;
|
||||
}
|
21
Nitrox.Launcher/Models/Converters/Converter.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// A converter base class that provides itself as value to the XAML compiler.
|
||||
/// </summary>
|
||||
public abstract class Converter<TSelf> : MarkupExtension, IValueConverter
|
||||
where TSelf : Converter<TSelf>, new()
|
||||
{
|
||||
private static TSelf Instance { get; } = new();
|
||||
|
||||
public sealed override object ProvideValue(IServiceProvider serviceProvider) => Instance;
|
||||
|
||||
public abstract object Convert(object value, Type targetType, object parameter, CultureInfo culture);
|
||||
|
||||
public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException();
|
||||
}
|
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the bound value as a relative date string from a DateTime value.
|
||||
/// </summary>
|
||||
public class DateToRelativeDateConverter : Converter<DateToRelativeDateConverter>
|
||||
{
|
||||
private const float DAYS_IN_YEAR = 365.2425f;
|
||||
private const float MEAN_DAYS_IN_MONTH = DAYS_IN_YEAR / 12f;
|
||||
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
DateTimeOffset date = value switch
|
||||
{
|
||||
DateTime dateTime => dateTime,
|
||||
DateTimeOffset dateTimeOffset => dateTimeOffset,
|
||||
DateOnly dateOnly => dateOnly.ToDateTime(TimeOnly.MinValue),
|
||||
string text when DateTimeOffset.TryParse(text, out DateTimeOffset offset) => offset,
|
||||
_ => throw new ArgumentException($"Value must be a {nameof(DateTime)} or {nameof(DateTimeOffset)}", nameof(value))
|
||||
};
|
||||
|
||||
TimeSpan delta = DateTimeOffset.UtcNow - date.UtcDateTime;
|
||||
|
||||
return delta switch
|
||||
{
|
||||
{ TotalSeconds: < 1 } => "just now",
|
||||
{ TotalSeconds: < 2 } => "a second ago",
|
||||
{ TotalMinutes: < 1 } => $"{(int)delta.TotalSeconds} seconds ago",
|
||||
{ TotalMinutes: < 2 } => "a minute ago",
|
||||
{ TotalMinutes: < 45 } => $"{(int)delta.TotalMinutes} minutes ago",
|
||||
{ TotalHours: < 1.5 } => "an hour ago",
|
||||
{ TotalDays: < 1 } => $"{(int)delta.TotalHours} hours ago",
|
||||
{ TotalDays: < 2 } => "yesterday",
|
||||
{ TotalDays: < MEAN_DAYS_IN_MONTH } => $"{(int)delta.TotalDays} days ago",
|
||||
{ TotalDays: < MEAN_DAYS_IN_MONTH * 2 } => "a month ago",
|
||||
{ TotalDays: < DAYS_IN_YEAR } => $"{(int)(delta.TotalDays / MEAN_DAYS_IN_MONTH)} months ago",
|
||||
{ TotalDays: < DAYS_IN_YEAR * 2 } => "a year ago",
|
||||
_ => $"{(int)(delta.TotalDays / DAYS_IN_YEAR)} years ago"
|
||||
};
|
||||
}
|
||||
}
|
22
Nitrox.Launcher/Models/Converters/DeduplicateConverter.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Removes duplicates by non-unique ToString values of the given list.
|
||||
/// </summary>
|
||||
public class DeduplicateConverter : Converter<DeduplicateConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not IEnumerable<object> list)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return list.DistinctBy(i => i.ToString());
|
||||
}
|
||||
}
|
36
Nitrox.Launcher/Models/Converters/EqualityConverter.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if values are equal to each other.
|
||||
/// Or if value is singular, if parameter is equal to the value.
|
||||
/// </summary>
|
||||
public class EqualityConverter : Converter<EqualityConverter>, IMultiValueConverter
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) => Equals(value, parameter);
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException();
|
||||
|
||||
public object Convert(IList<object> values, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
foreach (object val1 in values)
|
||||
{
|
||||
foreach (object val2 in values)
|
||||
{
|
||||
if (ReferenceEquals(val1, val2))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!Equals(val1, val2))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
43
Nitrox.Launcher/Models/Converters/IntToStringConverter.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the bound value as a string from an integer.
|
||||
/// </summary>
|
||||
public partial class IntToStringConverter : Converter<IntToStringConverter>
|
||||
{
|
||||
[GeneratedRegex("[^0-9]")]
|
||||
private static partial Regex DigitReplaceRegex { get; }
|
||||
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
return value?.ToString() ?? "";
|
||||
}
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value is not string str)
|
||||
{
|
||||
str = value.ToString();
|
||||
if (str is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
str = DigitReplaceRegex.Replace(str, "");
|
||||
if (int.TryParse(str, out int result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
34
Nitrox.Launcher/Models/Converters/IsTypeConverter.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if value is of the type as given by parameter (or any if parameter is a collection of types).
|
||||
/// </summary>
|
||||
public class IsTypeConverter : Converter<IsTypeConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
switch (parameter)
|
||||
{
|
||||
case Type typeParameter:
|
||||
return typeParameter.IsInstanceOfType(value);
|
||||
case IEnumerable<Type> typeParameters:
|
||||
{
|
||||
foreach (Type type in typeParameters)
|
||||
{
|
||||
if (type.IsInstanceOfType(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return new BindingNotification(new ArgumentException($"Expected {nameof(parameter)} to be a {typeof(Type).FullName}"), BindingErrorType.Error);
|
||||
}
|
||||
}
|
||||
}
|
24
Nitrox.Launcher/Models/Converters/PlatformToIconConverter.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Nitrox.Launcher.Models.Utils;
|
||||
using NitroxModel.Discovery.Models;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
public class PlatformToIconConverter : Converter<PlatformToIconConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
return AssetHelper.GetAssetFromStream(GetIconPathForPlatform(value as Platform?), static stream => new Bitmap(stream));
|
||||
}
|
||||
|
||||
private static string GetIconPathForPlatform(Platform? platform) => platform switch
|
||||
{
|
||||
Platform.EPIC => "/Assets/Images/store-icons/epic.png",
|
||||
Platform.STEAM => "/Assets/Images/store-icons/steam.png",
|
||||
Platform.MICROSOFT => "/Assets/Images/store-icons/xbox.png",
|
||||
Platform.DISCORD => "/Assets/Images/store-icons/discord.png",
|
||||
_ => "/Assets/Images/store-icons/missing.png",
|
||||
};
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the bound value as "0 BoundValue 0 BoundValue" Margin from a Padding, used for TextBox styling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This converter is used to solve a niche issue with the styling of TextBoxes.
|
||||
/// </remarks>
|
||||
public class TextBoxPaddingToMarginConverter : Converter<TextBoxPaddingToMarginConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not Thickness padding)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
bool isNegative = parameter != null && bool.TryParse(parameter.ToString(), out bool result) && result;
|
||||
double top = isNegative ? -padding.Top : padding.Top;
|
||||
double bottom = isNegative ? -padding.Bottom : padding.Bottom;
|
||||
return new Thickness(0, top, 0, bottom);
|
||||
}
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
30
Nitrox.Launcher/Models/Converters/ToIntConverter.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
public class ToIntConverter : Converter<ToIntConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
try
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
int i => i,
|
||||
string valueStr when int.TryParse(valueStr, out int result) => result,
|
||||
ICollection list => list.Count,
|
||||
IEnumerable enumerable => enumerable.Cast<object>().Count(),
|
||||
_ => System.Convert.ToInt32(value)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value;
|
||||
}
|
45
Nitrox.Launcher/Models/Converters/ToStringConverter.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data;
|
||||
using NitroxModel.Helper;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the bound value as a string using a specific formatting style.
|
||||
/// </summary>
|
||||
public class ToStringConverter : Converter<ToStringConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.GetType().IsEnum)
|
||||
{
|
||||
value = (value as Enum)?.GetAttribute<DescriptionAttribute>()?.Description ?? value.ToString();
|
||||
}
|
||||
|
||||
if (value is not string sourceText)
|
||||
{
|
||||
sourceText = value?.ToString();
|
||||
}
|
||||
|
||||
if (!targetType.IsAssignableTo(typeof(string)) || sourceText == null)
|
||||
{
|
||||
return new BindingNotification(new InvalidCastException(), BindingErrorType.Error);
|
||||
}
|
||||
|
||||
return parameter switch
|
||||
{
|
||||
"upper" => sourceText.ToUpperInvariant(),
|
||||
"lower" => sourceText.ToLowerInvariant(),
|
||||
_ => CultureManager.CultureInfo.TextInfo.ToTitleCase(sourceText.ToLower().Replace("_", " ")),
|
||||
};
|
||||
}
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value;
|
||||
}
|
67
Nitrox.Launcher/Models/Converters/TrimConverter.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Trims the value when retrieved by code but keeps the spaces in the input field intact for improved UX.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This converter is unconventional (inverted converter) in that the value is converted for the backend.
|
||||
/// The user wants to be able to input spaces while they're typing, but we don't want to save those spaces.
|
||||
/// </remarks>
|
||||
public class TrimConverter : Converter<TrimConverter>
|
||||
{
|
||||
private readonly Lock inOutCacheLock = new();
|
||||
/// <summary>
|
||||
/// Cache to remember the last known untrimmed value (here, the value) for trimmed values (here, the key).
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, string> inOutCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Converts trimmed value back to last known untrimmed value.
|
||||
/// </summary>
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string strValue)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
lock (inOutCacheLock)
|
||||
{
|
||||
if (inOutCache.TryGetValue(strValue.Trim(), out string untrimmedValue))
|
||||
{
|
||||
strValue = untrimmedValue;
|
||||
}
|
||||
}
|
||||
return strValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts untrimmed value back to trimmed value.
|
||||
/// </summary>
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string strValue)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
if (!strValue.StartsWith(' ') && !strValue.EndsWith(' '))
|
||||
{
|
||||
// It's safe to reset cache now.
|
||||
lock (inOutCacheLock)
|
||||
{
|
||||
inOutCache.Clear();
|
||||
}
|
||||
return strValue;
|
||||
}
|
||||
string trim = strValue.Trim();
|
||||
lock (inOutCacheLock)
|
||||
{
|
||||
inOutCache[trim] = strValue;
|
||||
}
|
||||
return trim;
|
||||
}
|
||||
}
|
87
Nitrox.Launcher/Models/Design/AsyncCommandButtonTagger.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Reactive;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Listens for async command changes on buttons to add the chosen classname to, for use with styling.
|
||||
/// </summary>
|
||||
public class AsyncCommandButtonTagger : IDisposable
|
||||
{
|
||||
public string ClassName { get; init; }
|
||||
private readonly ConcurrentDictionary<ICommand, BusyState> states = [];
|
||||
private readonly IDisposable commandChangeSubscription;
|
||||
|
||||
public AsyncCommandButtonTagger(string className)
|
||||
{
|
||||
ClassName = className;
|
||||
commandChangeSubscription = Button.CommandProperty.Changed.Subscribe(new AnonymousObserver<AvaloniaPropertyChangedEventArgs<ICommand>>(ButtonCommandChangedOnNext));
|
||||
|
||||
void ButtonCommandChangedOnNext(AvaloniaPropertyChangedEventArgs<ICommand> args)
|
||||
{
|
||||
if (args.Sender is not Button button)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (args.OldValue.Value is { } oldCommand && states.TryRemove(oldCommand, out BusyState oldState))
|
||||
{
|
||||
oldState.Dispose();
|
||||
}
|
||||
if (args.NewValue.Value is { } newCommand)
|
||||
{
|
||||
states.TryAdd(newCommand, new BusyState(ClassName, newCommand, button));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BusyState : IDisposable
|
||||
{
|
||||
public string ClassName { get; }
|
||||
private ICommand Command { get; }
|
||||
private Button Button { get; }
|
||||
|
||||
public BusyState(string className, ICommand command, Button button)
|
||||
{
|
||||
ClassName = className;
|
||||
Command = command;
|
||||
Button = button;
|
||||
Command.CanExecuteChanged += CommandOnCanExecuteChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Command.CanExecuteChanged -= CommandOnCanExecuteChanged;
|
||||
Button.Classes.Set(ClassName, false);
|
||||
}
|
||||
|
||||
private void CommandOnCanExecuteChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (sender is IAsyncRelayCommand asyncCommand)
|
||||
{
|
||||
Button.Classes.Set(ClassName, asyncCommand.IsRunning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
commandChangeSubscription.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the busy states of buttons.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach ((ICommand _, BusyState value) in states)
|
||||
{
|
||||
value.Dispose();
|
||||
}
|
||||
states.Clear();
|
||||
}
|
||||
}
|
5
Nitrox.Launcher/Models/Design/BackupItem.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public record BackupItem(DateTime BackupDate, string BackupFileName);
|
34
Nitrox.Launcher/Models/Design/EditorField.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Reflection;
|
||||
using Avalonia.Collections;
|
||||
using NitroxModel.Serialization;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public record EditorField
|
||||
{
|
||||
public object Value { get; set; }
|
||||
|
||||
public PropertyInfo PropertyInfo { get; init; }
|
||||
|
||||
public AvaloniaList<object> PossibleValues { get; set; }
|
||||
|
||||
public string Description
|
||||
{
|
||||
get
|
||||
{
|
||||
string description = PropertyInfo.GetCustomAttribute<PropertyDescriptionAttribute>()?.Description;
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
description = null;
|
||||
}
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
public EditorField(PropertyInfo propertyInfo, object value, AvaloniaList<object> possibleValues)
|
||||
{
|
||||
PropertyInfo = propertyInfo;
|
||||
Value = value;
|
||||
PossibleValues = possibleValues;
|
||||
}
|
||||
}
|
6
Nitrox.Launcher/Models/Design/IRoutingScreen.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public interface IRoutingScreen
|
||||
{
|
||||
object ActiveViewModel { get; set; }
|
||||
}
|
9
Nitrox.Launcher/Models/Design/KnownGame.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using NitroxModel.Discovery.Models;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public class KnownGame
|
||||
{
|
||||
public string PathToGame { get; init; }
|
||||
public Platform Platform { get; init; }
|
||||
}
|
59
Nitrox.Launcher/Models/Design/MultiDataTemplate.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
extern alias JB;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Markup.Xaml.Templates;
|
||||
using Avalonia.Metadata;
|
||||
using JB::JetBrains.Annotations;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Selects a <see cref="DataTemplate" /> based on its <see cref="DataTemplate.DataType" />.
|
||||
/// </summary>
|
||||
public class MultiDataTemplate : AvaloniaList<DataTemplate>, IRecyclingDataTemplate
|
||||
{
|
||||
[Content]
|
||||
[UsedImplicitly]
|
||||
public List<DataTemplate> Content { get; set; } = new();
|
||||
|
||||
private readonly Dictionary<Type, Control> typeToControlCache = [];
|
||||
|
||||
public bool Match(object data) => GetTemplateForType(data?.GetType()) != null;
|
||||
|
||||
public Control Build(object data, Control existing)
|
||||
{
|
||||
Type type = data?.GetType();
|
||||
if (type != null && typeToControlCache.TryGetValue(type, out Control control))
|
||||
{
|
||||
return control;
|
||||
}
|
||||
Control build = GetTemplateForType(type)?.Build(data);
|
||||
if (type != null && build != null)
|
||||
{
|
||||
typeToControlCache[type] = build;
|
||||
}
|
||||
|
||||
return build ?? existing;
|
||||
}
|
||||
|
||||
public Control Build(object data) => GetTemplateForType(data.GetType())?.Build(data) ?? new TextBlock { Text = "" };
|
||||
|
||||
private IDataTemplate GetTemplateForType(Type type)
|
||||
{
|
||||
if (type == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
foreach (DataTemplate template in Content)
|
||||
{
|
||||
if (template.DataType?.IsAssignableTo(type) ?? false)
|
||||
{
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
167
Nitrox.Launcher/Models/Design/NitroxAttached.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Reactive;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Container class for our attached properties.
|
||||
/// </summary>
|
||||
public class NitroxAttached : AvaloniaObject
|
||||
{
|
||||
public static readonly AttachedProperty<bool> SelectedProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, Interactive, bool>("Selected");
|
||||
public static readonly AttachedProperty<bool> AutoScrollToHomeProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, ScrollViewer, bool>("AutoScrollToHome");
|
||||
public static readonly AttachedProperty<Orientation> PrimaryScrollWheelDirectionProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, ScrollViewer, Orientation>("PrimaryScrollWheelDirection", Orientation.Vertical);
|
||||
public static readonly AttachedProperty<bool> IsNumericInputProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, InputElement, bool>("IsNumericInput");
|
||||
public static readonly AttachedProperty<bool> HasUserInteractedProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, InputElement, bool>("HasUserInteracted");
|
||||
public static readonly AttachedProperty<bool> UseCustomTitleBarProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, Window, bool>("UseCustomTitleBar", true);
|
||||
internal static readonly AsyncCommandButtonTagger AsyncCommandButtonTagger;
|
||||
|
||||
static NitroxAttached()
|
||||
{
|
||||
InputElement.LostFocusEvent.Raised.Subscribe(new AnonymousObserver<(object, RoutedEventArgs)>(HasUserInteractedOnNext));
|
||||
InputElement.TextInputEvent.Raised.Subscribe(new AnonymousObserver<(object, RoutedEventArgs)>(HasUserInteractedOnNext));
|
||||
AsyncCommandButtonTagger = new AsyncCommandButtonTagger("busy");
|
||||
|
||||
void HasUserInteractedOnNext((object Sender, RoutedEventArgs EventArgs) args)
|
||||
{
|
||||
if (args.Sender is InputElement element)
|
||||
{
|
||||
SetHasUserInteracted(element, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetSelected(AvaloniaObject element) => element.GetValue(SelectedProperty);
|
||||
|
||||
public static void SetSelected(AvaloniaObject obj, bool value) => obj.SetValue(SelectedProperty, value);
|
||||
|
||||
public static void SetAutoScrollToHome(AvaloniaObject obj, bool value)
|
||||
{
|
||||
static void VisualAttached(object sender, VisualTreeAttachmentEventArgs e) => (sender as ScrollViewer)?.ScrollToHome();
|
||||
|
||||
obj.SetValue(AutoScrollToHomeProperty, value);
|
||||
if (obj is not Visual visual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (value)
|
||||
{
|
||||
visual.AttachedToVisualTree += VisualAttached;
|
||||
}
|
||||
else
|
||||
{
|
||||
visual.AttachedToVisualTree -= VisualAttached;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetAutoScrollToHome(AvaloniaObject element) => element.GetValue(AutoScrollToHomeProperty);
|
||||
|
||||
public static Orientation GetPrimaryScrollWheelDirection(AvaloniaObject obj) => obj.GetValue(PrimaryScrollWheelDirectionProperty);
|
||||
|
||||
/// <summary>
|
||||
/// Changes scroll wheel input to move scroll viewer left and right if set to <see cref="Orientation.Horizontal"/>.
|
||||
/// </summary>
|
||||
public static void SetPrimaryScrollWheelDirection(AvaloniaObject obj, Orientation orientation)
|
||||
{
|
||||
static void RotatedOrientationWheelHandler(object sender, PointerWheelEventArgs e)
|
||||
{
|
||||
ScrollViewer scrollViewer = sender as ScrollViewer;
|
||||
if (scrollViewer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (GetPrimaryScrollWheelDirection(scrollViewer) == Orientation.Vertical)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Delta.Y < 0)
|
||||
{
|
||||
for (int i = 0; i <= -e.Delta.Y; i++)
|
||||
{
|
||||
scrollViewer.LineRight();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i <= e.Delta.Y; i++)
|
||||
{
|
||||
scrollViewer.LineLeft();
|
||||
}
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
obj.SetValue(PrimaryScrollWheelDirectionProperty, orientation);
|
||||
if (obj is not ScrollViewer scrollViewer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (orientation)
|
||||
{
|
||||
case Orientation.Horizontal:
|
||||
scrollViewer.PointerWheelChanged += RotatedOrientationWheelHandler;
|
||||
break;
|
||||
case Orientation.Vertical:
|
||||
scrollViewer.PointerWheelChanged -= RotatedOrientationWheelHandler;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetIsNumericInput(AvaloniaObject obj, bool value)
|
||||
{
|
||||
static void OnKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Up:
|
||||
case Key.Down:
|
||||
if (sender is not TextBox textBox)
|
||||
{
|
||||
throw new NotSupportedException($"{sender.GetType()} is not supported by property {nameof(IsNumericInputProperty)}");
|
||||
}
|
||||
|
||||
string previousText = textBox.Text;
|
||||
if (int.TryParse(textBox.Text, out int val))
|
||||
{
|
||||
val += e.Key == Key.Up ? 1 : -1;
|
||||
}
|
||||
textBox.Text = Math.Clamp(val, 0, int.MaxValue).ToString();
|
||||
if (textBox.Text.Length > textBox.MaxLength)
|
||||
{
|
||||
textBox.Text = previousText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (obj is not InputElement inputElement)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (value)
|
||||
{
|
||||
inputElement.KeyDown += OnKeyDown;
|
||||
}
|
||||
else
|
||||
{
|
||||
inputElement.KeyDown -= OnKeyDown;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetHasUserInteracted(InputElement input) => input.GetValue(HasUserInteractedProperty);
|
||||
|
||||
public static void SetHasUserInteracted(InputElement input, bool value) => input.SetValue(HasUserInteractedProperty, value);
|
||||
|
||||
public static bool GetUseCustomTitleBar(Window window) => window.GetValue(UseCustomTitleBarProperty);
|
||||
|
||||
public static void SetUseCustomTitleBar(Window window, bool value) => window.SetValue(UseCustomTitleBarProperty, value);
|
||||
}
|
12
Nitrox.Launcher/Models/Design/NitroxBlog.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public sealed record NitroxBlog(string Title, DateOnly Date, string Url, Bitmap Image)
|
||||
{
|
||||
public NitroxBlog() : this("", default, "", null)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
6
Nitrox.Launcher/Models/Design/NitroxChangelog.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
[Serializable]
|
||||
public record NitroxChangelog(string Version, DateTime Released, string PatchNotes);
|
28
Nitrox.Launcher/Models/Design/NotificationItem.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Controls.Notifications;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public partial class NotificationItem : ObservableObject
|
||||
{
|
||||
public string Message { get; }
|
||||
public NotificationType Type { get; }
|
||||
public ICommand CloseCommand { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool dismissed;
|
||||
|
||||
public NotificationItem()
|
||||
{
|
||||
}
|
||||
|
||||
public NotificationItem(string message, NotificationType type = NotificationType.Information, ICommand closeCommand = null)
|
||||
{
|
||||
Message = message;
|
||||
Type = type;
|
||||
CloseCommand = closeCommand ?? new RelayCommand(() => WeakReferenceMessenger.Default.Send(new NotificationCloseMessage(this)));
|
||||
}
|
||||
}
|
8
Nitrox.Launcher/Models/Design/OutputLine.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public record OutputLine
|
||||
{
|
||||
public string Timestamp { get; init; }
|
||||
public string LogText { get; init; }
|
||||
public OutputLineType Type { get; init; }
|
||||
}
|
10
Nitrox.Launcher/Models/Design/OutputLineType.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public enum OutputLineType
|
||||
{
|
||||
INFO_LOG,
|
||||
DEBUG_LOG,
|
||||
WARNING_LOG,
|
||||
ERROR_LOG,
|
||||
COMMAND
|
||||
}
|
20
Nitrox.Launcher/Models/Design/RoutingScreen.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public partial class RoutingScreen : ObservableObject, IRoutingScreen
|
||||
{
|
||||
[ObservableProperty]
|
||||
private object activeViewModel;
|
||||
|
||||
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ActiveViewModel))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new ViewShownMessage(ActiveViewModel));
|
||||
}
|
||||
base.OnPropertyChanged(e);
|
||||
}
|
||||
}
|
413
Nitrox.Launcher/Models/Design/ServerEntry.cs
Normal file
@@ -0,0 +1,413 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Nitrox.Launcher.Models.Exceptions;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.Helper;
|
||||
using NitroxModel.Logger;
|
||||
using NitroxModel.Serialization;
|
||||
using NitroxModel.Server;
|
||||
using NitroxServer.Serialization;
|
||||
using NitroxServer.Serialization.World;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Manager object for a server. Used to start/stop a server and change its settings.
|
||||
/// </summary>
|
||||
public partial class ServerEntry : ObservableObject
|
||||
{
|
||||
public const string DEFAULT_SERVER_ICON_NAME = "servericon.png";
|
||||
public const string DEFAULT_SERVER_CONFIG_NAME = "server.cfg";
|
||||
|
||||
private static readonly SubnauticaServerConfig serverDefaults = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private bool allowCommands = !serverDefaults.DisableConsole;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool allowLanDiscovery = serverDefaults.LANDiscoveryEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool autoPortForward = serverDefaults.AutoPortForward;
|
||||
|
||||
[ObservableProperty]
|
||||
private int autoSaveInterval = serverDefaults.SaveInterval / 1000;
|
||||
|
||||
[ObservableProperty]
|
||||
private NitroxGameMode gameMode = serverDefaults.GameMode;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isEmbedded;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isNewServer = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isOnline;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime lastAccessedTime = DateTime.Now;
|
||||
|
||||
[ObservableProperty]
|
||||
private int maxPlayers = serverDefaults.MaxConnections;
|
||||
|
||||
[ObservableProperty]
|
||||
private string name;
|
||||
|
||||
[ObservableProperty]
|
||||
private string password;
|
||||
|
||||
[ObservableProperty]
|
||||
private Perms playerPermissions = serverDefaults.DefaultPlayerPerm;
|
||||
|
||||
[ObservableProperty]
|
||||
private int players;
|
||||
|
||||
[ObservableProperty]
|
||||
private int port = serverDefaults.ServerPort;
|
||||
|
||||
[ObservableProperty]
|
||||
private string seed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Bitmap serverIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
private Version version = NitroxEnvironment.Version;
|
||||
|
||||
internal ServerProcess Process { get; private set; }
|
||||
|
||||
public static ServerEntry FromDirectory(string saveDir)
|
||||
{
|
||||
ServerEntry result = new();
|
||||
return result.RefreshFromDirectory(saveDir) ? result : null;
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(IsOnline):
|
||||
WeakReferenceMessenger.Default.Send(new ServerStatusMessage(this, IsOnline));
|
||||
break;
|
||||
}
|
||||
base.OnPropertyChanged(e);
|
||||
}
|
||||
|
||||
public static ServerEntry CreateNew(string saveDir, NitroxGameMode saveGameMode)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(saveDir, nameof(saveDir));
|
||||
|
||||
Directory.CreateDirectory(saveDir);
|
||||
|
||||
SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir);
|
||||
string fileEnding = config.SerializerMode switch
|
||||
{
|
||||
ServerSerializerMode.JSON => ServerJsonSerializer.FILE_ENDING,
|
||||
ServerSerializerMode.PROTOBUF => ServerProtoBufSerializer.FILE_ENDING,
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
|
||||
File.WriteAllText(Path.Combine(saveDir, $"Version{fileEnding}"), null);
|
||||
using (config.Update(saveDir))
|
||||
{
|
||||
config.GameMode = saveGameMode;
|
||||
}
|
||||
|
||||
return FromDirectory(saveDir);
|
||||
}
|
||||
|
||||
public bool RefreshFromDirectory(string saveDir)
|
||||
{
|
||||
if (!File.Exists(Path.Combine(saveDir, DEFAULT_SERVER_CONFIG_NAME)))
|
||||
{
|
||||
Log.Warn($"Tried loading invalid save directory at '{saveDir}'");
|
||||
return false;
|
||||
}
|
||||
|
||||
Bitmap serverIcon = null;
|
||||
string serverIconPath = Path.Combine(saveDir, DEFAULT_SERVER_ICON_NAME);
|
||||
if (File.Exists(serverIconPath))
|
||||
{
|
||||
serverIcon = new Bitmap(Path.Combine(saveDir, DEFAULT_SERVER_ICON_NAME));
|
||||
}
|
||||
|
||||
SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir);
|
||||
string fileEnding = config.SerializerMode switch
|
||||
{
|
||||
ServerSerializerMode.JSON => ServerJsonSerializer.FILE_ENDING,
|
||||
ServerSerializerMode.PROTOBUF => ServerProtoBufSerializer.FILE_ENDING,
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
|
||||
string saveFileVersion = Path.Combine(saveDir, $"Version{fileEnding}");
|
||||
if (!File.Exists(saveFileVersion))
|
||||
{
|
||||
Log.Warn($"Tried loading invalid save directory at '{saveDir}', Version file is missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
Version version;
|
||||
using (FileStream stream = new(saveFileVersion, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
version = config.SerializerMode switch
|
||||
{
|
||||
ServerSerializerMode.JSON => new ServerJsonSerializer().Deserialize<SaveFileVersion>(stream)?.Version ?? NitroxEnvironment.Version,
|
||||
ServerSerializerMode.PROTOBUF => new ServerProtoBufSerializer().Deserialize<SaveFileVersion>(stream)?.Version ?? NitroxEnvironment.Version,
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
}
|
||||
|
||||
Name = Path.GetFileName(saveDir);
|
||||
ServerIcon = serverIcon;
|
||||
Password = config.ServerPassword;
|
||||
Seed = config.Seed;
|
||||
GameMode = config.GameMode;
|
||||
PlayerPermissions = config.DefaultPlayerPerm;
|
||||
AutoSaveInterval = config.SaveInterval / 1000;
|
||||
MaxPlayers = config.MaxConnections;
|
||||
Port = config.ServerPort;
|
||||
AutoPortForward = config.AutoPortForward;
|
||||
AllowLanDiscovery = config.LANDiscoveryEnabled;
|
||||
AllowCommands = !config.DisableConsole;
|
||||
IsNewServer = !File.Exists(Path.Combine(saveDir, $"PlayerData{fileEnding}"));
|
||||
Version = version;
|
||||
IsEmbedded = config.IsEmbedded || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); // Force embedded on MacOS
|
||||
LastAccessedTime = File.GetLastWriteTime(File.Exists(Path.Combine(saveDir, $"PlayerData{fileEnding}"))
|
||||
?
|
||||
// This file is affected by server saving
|
||||
Path.Combine(saveDir, $"PlayerData{fileEnding}")
|
||||
:
|
||||
// If the above file doesn't exist (server was never ran), use the Version file instead
|
||||
Path.Combine(saveDir, $"Version{fileEnding}"));
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Start(string savesDir)
|
||||
{
|
||||
if (!Directory.Exists(savesDir))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Directory '{savesDir}' not found");
|
||||
}
|
||||
|
||||
if (Process?.IsRunning ?? false)
|
||||
{
|
||||
throw new DuplicateSingularApplicationException("Nitrox Server");
|
||||
}
|
||||
|
||||
// Start server and add notify when server closed.
|
||||
Process = ServerProcess.Start(Path.Combine(savesDir, Name), () => Dispatcher.UIThread.InvokeAsync(StopAsync), IsEmbedded);
|
||||
|
||||
IsNewServer = false;
|
||||
IsOnline = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task<bool> StopAsync()
|
||||
{
|
||||
if (Process is not { IsRunning: true })
|
||||
{
|
||||
IsOnline = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await Process.CloseAsync())
|
||||
{
|
||||
IsOnline = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void OpenSaveFolder()
|
||||
{
|
||||
System.Diagnostics.Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name),
|
||||
Verb = "open",
|
||||
UseShellExecute = true
|
||||
})?.Dispose();
|
||||
}
|
||||
|
||||
internal partial class ServerProcess : IDisposable
|
||||
{
|
||||
private NamedPipeClientStream commandStream;
|
||||
private OutputLineType lastOutputType;
|
||||
private Process serverProcess;
|
||||
|
||||
[GeneratedRegex(@"^\[(?<timestamp>\d{2}:\d{2}:\d{2}\.\d{3})\]\s\[(?<level>\w+)\](?<logText>.*)?$")]
|
||||
private static partial Regex OutputLineRegex { get; }
|
||||
|
||||
public bool IsRunning => !serverProcess?.HasExited ?? false;
|
||||
public AvaloniaList<OutputLine> Output { get; } = [];
|
||||
|
||||
private ServerProcess(string saveDir, Action onExited, bool isEmbeddedMode = false)
|
||||
{
|
||||
string serverExeName = "NitroxServer-Subnautica.exe";
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
serverExeName = "NitroxServer-Subnautica";
|
||||
}
|
||||
string serverFile = Path.Combine(NitroxUser.ExecutableRootPath, serverExeName);
|
||||
ProcessStartInfo startInfo = new(serverFile)
|
||||
{
|
||||
WorkingDirectory = NitroxUser.ExecutableRootPath,
|
||||
ArgumentList =
|
||||
{
|
||||
"--save",
|
||||
Path.GetFileName(saveDir)
|
||||
},
|
||||
RedirectStandardOutput = isEmbeddedMode,
|
||||
RedirectStandardError = isEmbeddedMode,
|
||||
RedirectStandardInput = isEmbeddedMode,
|
||||
WindowStyle = isEmbeddedMode ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal,
|
||||
CreateNoWindow = isEmbeddedMode
|
||||
};
|
||||
if (isEmbeddedMode)
|
||||
{
|
||||
startInfo.ArgumentList.Add("--embedded");
|
||||
}
|
||||
Log.Info($"Starting server:{Environment.NewLine}File: {startInfo.FileName}{Environment.NewLine}Working directory: {startInfo.WorkingDirectory}{Environment.NewLine}Arguments: {string.Join(", ", startInfo.ArgumentList)}");
|
||||
|
||||
serverProcess = System.Diagnostics.Process.Start(startInfo);
|
||||
if (serverProcess != null)
|
||||
{
|
||||
serverProcess.EnableRaisingEvents = true; // Required for 'Exited' event from process.
|
||||
if (isEmbeddedMode)
|
||||
{
|
||||
serverProcess.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Match match = OutputLineRegex.Match(args.Data);
|
||||
if (match.Success)
|
||||
{
|
||||
OutputLine outputLine = new()
|
||||
{
|
||||
Timestamp = $"[{match.Groups["timestamp"].ValueSpan}]",
|
||||
LogText = match.Groups["logText"].ValueSpan.Trim().ToString(),
|
||||
Type = match.Groups["level"].ValueSpan switch
|
||||
{
|
||||
"DBG" => OutputLineType.DEBUG_LOG,
|
||||
"WRN" => OutputLineType.WARNING_LOG,
|
||||
"ERR" => OutputLineType.ERROR_LOG,
|
||||
_ => OutputLineType.INFO_LOG
|
||||
}
|
||||
};
|
||||
lastOutputType = outputLine.Type;
|
||||
Output.Add(outputLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
Output.Add(new OutputLine
|
||||
{
|
||||
Timestamp = "",
|
||||
LogText = args.Data,
|
||||
Type = lastOutputType
|
||||
});
|
||||
}
|
||||
};
|
||||
serverProcess.BeginOutputReadLine();
|
||||
}
|
||||
serverProcess.Exited += (_, _) =>
|
||||
{
|
||||
onExited?.Invoke();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static ServerProcess Start(string saveDir, Action onExited, bool isEmbedded) => new(saveDir, onExited, isEmbedded);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to close the server gracefully with a timeout of 30 seconds. If it fails, returns false.
|
||||
/// </summary>
|
||||
public async Task<bool> CloseAsync()
|
||||
{
|
||||
using CancellationTokenSource ctsCloseTimeout = new(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
if (!await SendCommandAsync("stop"))
|
||||
{
|
||||
await Task.Delay(100, ctsCloseTimeout.Token);
|
||||
}
|
||||
} while (IsRunning && !ctsCloseTimeout.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
if (IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SendCommandAsync(string command)
|
||||
{
|
||||
if (!IsRunning || string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
commandStream ??= new NamedPipeClientStream(".", $"Nitrox Server {serverProcess.Id}", PipeDirection.Out, PipeOptions.Asynchronous);
|
||||
if (!commandStream.IsConnected)
|
||||
{
|
||||
await commandStream.ConnectAsync(5000);
|
||||
}
|
||||
byte[] commandBytes = Encoding.UTF8.GetBytes(command);
|
||||
await commandStream.WriteAsync(BitConverter.GetBytes((uint)commandBytes.Length));
|
||||
await commandStream.WriteAsync(commandBytes);
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// ignored - "broken pipe" or "socket shutdown"
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
commandStream?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
serverProcess?.Dispose();
|
||||
serverProcess = null;
|
||||
}
|
||||
}
|
||||
}
|
18
Nitrox.Launcher/Models/Design/ServerStartEventArgs.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public sealed class ServerStartEventArgs : EventArgs
|
||||
{
|
||||
public bool IsEmbedded { get; }
|
||||
|
||||
public ServerStartEventArgs(bool embedded)
|
||||
{
|
||||
IsEmbedded = embedded;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[ServerStartEventArgs - IsEmbedded: {IsEmbedded}]";
|
||||
}
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Exceptions;
|
||||
|
||||
public class DuplicateSingularApplicationException : Exception
|
||||
{
|
||||
public DuplicateSingularApplicationException(string applicationName) : base($"An instance of {applicationName} is already running")
|
||||
{
|
||||
}
|
||||
}
|
63
Nitrox.Launcher/Models/Extensions/CloseByUserExtensions.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia doesn't provide a public API to close the window non-programmatically so this is a hack to support it.
|
||||
/// </summary>
|
||||
public static class CloseByUserExtensions
|
||||
{
|
||||
private static readonly Dictionary<Window, bool> isClosingByUser = [];
|
||||
|
||||
/// <summary>
|
||||
/// Closes the window non-programmatically (by user).
|
||||
/// </summary>
|
||||
public static void CloseByUser(this Window window)
|
||||
{
|
||||
if (window == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.Closed += WindowOnClosed;
|
||||
isClosingByUser[window] = true;
|
||||
window.Close();
|
||||
|
||||
static void WindowOnClosed(object sender, EventArgs e)
|
||||
{
|
||||
if (sender is not Window window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.Closed -= WindowOnClosed;
|
||||
isClosingByUser.Remove(window);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the window programmatically.
|
||||
/// </summary>
|
||||
public static void CloseByCode(this Window window)
|
||||
{
|
||||
if (window == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
isClosingByUser[window] = false;
|
||||
window.Close();
|
||||
}
|
||||
|
||||
public static bool IsClosingByUser(this Window closingWindow, WindowClosingEventArgs closingArgs = null)
|
||||
{
|
||||
if (closingWindow is not null && isClosingByUser.TryGetValue(closingWindow, out bool isByUser))
|
||||
{
|
||||
return isByUser;
|
||||
}
|
||||
if (closingArgs is { IsProgrammatic: false })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
15
Nitrox.Launcher/Models/Extensions/CollectionExtensions.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class CollectionExtensions
|
||||
{
|
||||
public static void AddRange<T>(this Collection<T> collection, params IEnumerable<T> items)
|
||||
{
|
||||
foreach (T item in items)
|
||||
{
|
||||
collection.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
53
Nitrox.Launcher/Models/Extensions/DialogServiceExtensions.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using HanumanInstitute.MvvmDialogs;
|
||||
using Nitrox.Launcher.Models.Utils;
|
||||
using Nitrox.Launcher.ViewModels;
|
||||
using Nitrox.Launcher.ViewModels.Abstract;
|
||||
using NitroxModel.Logger;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class DialogServiceExtensions
|
||||
{
|
||||
public static async Task<T> ShowAsync<T, TExtra>(this IDialogService dialogService, Action<T, TExtra> setup = null, TExtra extraParameter = default) where T : ModalViewModelBase
|
||||
{
|
||||
try
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dialogService);
|
||||
// DataContext must be accessed on the UI thread, or it'll throw error.
|
||||
INotifyPropertyChanged owner = await Dispatcher.UIThread.InvokeAsync(() => AppViewLocator.MainWindow?.DataContext as INotifyPropertyChanged);
|
||||
if (owner == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Expected {nameof(AppViewLocator.MainWindow)}.{nameof(AppViewLocator.MainWindow.DataContext)} to not be null");
|
||||
}
|
||||
|
||||
T viewModel = dialogService.CreateViewModel<T>();
|
||||
setup?.Invoke(viewModel, extraParameter);
|
||||
bool? result = await dialogService.ShowDialogAsync<T>(owner, viewModel);
|
||||
if (result == true)
|
||||
{
|
||||
return viewModel;
|
||||
}
|
||||
return default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, $"Failed to show dialog for ViewModel {typeof(T).FullName}");
|
||||
LauncherNotifier.Error(ex.Message);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<T> ShowAsync<T>(this IDialogService dialogService, Action<T> setup = null) where T : ModalViewModelBase => dialogService.ShowAsync<T, Action<T>>((model, act) => act?.Invoke(model), setup);
|
||||
|
||||
public static Task ShowErrorAsync(this IDialogService dialogService, Exception exception, string title = null, string description = null) =>
|
||||
dialogService.ShowAsync<DialogBoxViewModel>(model =>
|
||||
{
|
||||
model.Title = title ?? "Error";
|
||||
model.Description = string.IsNullOrWhiteSpace(description) ? exception.ToString() : $"{description}{Environment.NewLine}{exception}";
|
||||
model.ButtonOptions = ButtonOptions.OkClipboard;
|
||||
});
|
||||
}
|
17
Nitrox.Launcher/Models/Extensions/KeyValueStoreExtensions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using NitroxModel.Helper;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class KeyValueStoreExtensions
|
||||
{
|
||||
public static string GetSubnauticaLaunchArguments(this IKeyValueStore store, string defaultValue = "-vrmode none") => store == null ? defaultValue : store.GetValue("SubnauticaLaunchArguments", defaultValue);
|
||||
|
||||
public static void SetSubnauticaLaunchArguments(this IKeyValueStore store, string value)
|
||||
{
|
||||
if (store == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
store.SetValue("SubnauticaLaunchArguments", value);
|
||||
}
|
||||
}
|
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class MessageReceiverExtensions
|
||||
{
|
||||
public static void RegisterMessageListener<T, TReceiver>(this TReceiver receiver, Func<T, TReceiver, Task> asyncFunc) where T : class where TReceiver : IMessageReceiver
|
||||
{
|
||||
if (WeakReferenceMessenger.Default.IsRegistered<T>(receiver))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Unregister<T>(receiver);
|
||||
}
|
||||
WeakReferenceMessenger.Default.Register<T>(receiver, (_, message) => asyncFunc(message, receiver));
|
||||
}
|
||||
|
||||
public static void RegisterMessageListener<T, TReceiver>(this TReceiver receiver, Action<T, TReceiver> action) where T : class where TReceiver : IMessageReceiver
|
||||
{
|
||||
if (WeakReferenceMessenger.Default.IsRegistered<T>(receiver))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Unregister<T>(receiver);
|
||||
}
|
||||
WeakReferenceMessenger.Default.Register<T>(receiver, (_, message) => action(message, receiver));
|
||||
}
|
||||
}
|
33
Nitrox.Launcher/Models/Extensions/ProcessExExtensions.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using NitroxModel.Platforms.OS.Shared;
|
||||
using NitroxModel.Platforms.OS.Windows;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class ProcessExExtensions
|
||||
{
|
||||
public static void SetForegroundWindowAndRestore(this ProcessEx process)
|
||||
{
|
||||
if (Avalonia.Controls.Design.IsDesignMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
WindowsApi.BringProcessToFront(process.MainWindowHandle);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// xdotool sends an XEvent to X11 window manager on Linux systems.
|
||||
string command = $"xdotool windowactivate $(xdotool search --pid {process.Id} --onlyvisible --desktop '$(xdotool get_desktop)' --name 'nitrox launcher')";
|
||||
using Process proc = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "sh",
|
||||
ArgumentList = { "-c", command },
|
||||
});
|
||||
|
||||
// TODO: Support "bring to front" on Wayland window manager.
|
||||
}
|
||||
}
|
||||
}
|