first commit

This commit is contained in:
2025-07-06 00:23:46 +02:00
commit 38f50c8819
1788 changed files with 112878 additions and 0 deletions

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

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

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

View 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>

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

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

View 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",
};
}

View File

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
using System;
namespace Nitrox.Launcher.Models.Design;
public record BackupItem(DateTime BackupDate, string BackupFileName);

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

View File

@@ -0,0 +1,6 @@
namespace Nitrox.Launcher.Models.Design;
public interface IRoutingScreen
{
object ActiveViewModel { get; set; }
}

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

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

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

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

View File

@@ -0,0 +1,6 @@
using System;
namespace Nitrox.Launcher.Models.Design;
[Serializable]
public record NitroxChangelog(string Version, DateTime Released, string PatchNotes);

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

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

View File

@@ -0,0 +1,10 @@
namespace Nitrox.Launcher.Models.Design;
public enum OutputLineType
{
INFO_LOG,
DEBUG_LOG,
WARNING_LOG,
ERROR_LOG,
COMMAND
}

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

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

View 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}]";
}
}

View File

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

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

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

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

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

View File

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

View 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.
}
}
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
namespace Nitrox.Launcher.Models.Extensions;
public static class ScreenExtensions
{
private static readonly List<RoutableViewModelBase> navigationStack = [];
/// <summary>
/// Navigates to a view assigned to the given ViewModel.
/// </summary>
/// <param name="screen">The screen used to display the view.</param>
/// <param name="routableViewModel">ViewModel that should be shown.</param>
/// <typeparam name="TViewModel">Type of the ViewModel to show.</typeparam>
public static async Task ShowAsync<TViewModel>(this IRoutingScreen screen, TViewModel routableViewModel) where TViewModel : RoutableViewModelBase
{
if (screen == null)
{
return;
}
// When navigating away from a view in an async button command, busy states on buttons should also reset. Otherwise, when navigating back it would still show buttons being busy.
NitroxAttached.AsyncCommandButtonTagger.Clear();
if (screen.ActiveViewModel is RoutableViewModelBase routableViewModelBase)
{
navigationStack.RemoveAllFast(screen.ActiveViewModel, (item, param) => item.GetType() == param.GetType());
await routableViewModelBase.ViewContentUnloadAsync();
navigationStack.Add(routableViewModelBase);
}
Stopwatch sw = Stopwatch.StartNew();
Task contentLoadTask = routableViewModel.ViewContentLoadAsync();
if (screen.ActiveViewModel != null)
{
// Only show loading screen if page isn't loading super quickly.
await Task.Delay(50);
if (!contentLoadTask.IsCompleted)
{
screen.ActiveViewModel = AssetHelper.GetFullAssetPath("/Assets/Icons/loading.svg");
await Task.Delay((int)Math.Max(0, 500 - sw.Elapsed.TotalMilliseconds));
}
}
await contentLoadTask;
screen.ActiveViewModel = routableViewModel;
}
public static async Task<bool> BackAsync(this IRoutingScreen screen)
{
if (navigationStack.Count < 1)
{
return false;
}
RoutableViewModelBase backViewModel = navigationStack[^1];
navigationStack.Remove(backViewModel);
await ShowAsync(screen, backViewModel);
return true;
}
/// <summary>
/// Tries to go back to the view assigned to the given ViewModel.
/// </summary>
/// <returns>
/// True if ViewModel was found in the routing navigation stack. False when the ViewModel wasn't found and routing
/// failed.
/// </returns>
public static async Task<bool> BackToAsync<T>(this IRoutingScreen screen) where T : RoutableViewModelBase
{
for (int i = navigationStack.Count - 1; i >= 0; i--)
{
if (navigationStack[i] is T target)
{
// Cleanup the stack up and including the back-target.
for (int j = i; j < navigationStack.Count; j++)
{
navigationStack.RemoveAt(j);
}
await screen.ShowAsync(target);
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,45 @@
using HanumanInstitute.MvvmDialogs;
using HanumanInstitute.MvvmDialogs.Avalonia;
using Microsoft.Extensions.DependencyInjection;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Services;
using Nitrox.Launcher.ViewModels.Abstract;
using Nitrox.Launcher.Views.Abstract;
using NitroxModel.Helper;
using ServiceScan.SourceGenerator;
namespace Nitrox.Launcher.Models.Extensions;
public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection collection)
{
// Avalonia and Reactive services
collection.AddSingleton(provider => new AppViewLocator(provider));
collection.AddSingleton<IRoutingScreen, RoutingScreen>();
collection.AddSingleton<IDialogService>(provider => new DialogService(
new DialogManager(
provider.GetRequiredService<AppViewLocator>(),
new DialogFactory()),
provider.GetRequiredService));
// Domain services
collection.AddSingleton(_ => KeyValueStore.Instance);
collection.AddSingleton<ServerService>();
return collection
.AddDialogs()
.AddViews()
.AddViewModels();
}
[GenerateServiceRegistrations(AssignableTo = typeof(ModalViewModelBase), AsSelf = true)]
[GenerateServiceRegistrations(AssignableTo = typeof(ModalBase), AsSelf = true)]
private static partial IServiceCollection AddDialogs(this IServiceCollection services);
[GenerateServiceRegistrations(AssignableTo = typeof(RoutableViewBase<>), AsSelf = true, Lifetime = ServiceLifetime.Singleton)]
private static partial IServiceCollection AddViews(this IServiceCollection services);
[GenerateServiceRegistrations(AssignableTo = typeof(ViewModelBase), AsSelf = true)]
private static partial IServiceCollection AddViewModels(this IServiceCollection services);
}

View File

@@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
namespace Nitrox.Launcher.Models.Extensions;
public static class StorageProviderExtensions
{
public static async Task<string> OpenFolderPickerAsync(this IStorageProvider storageProvider, string title, string startingFolder = null)
{
IStorageFolder startingStorageFolder = null;
if (startingFolder != null)
{
startingStorageFolder = await storageProvider.TryGetFolderFromPathAsync(startingFolder);
}
IReadOnlyList<IStorageFolder> dialogResult = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = title,
AllowMultiple = false,
SuggestedStartLocation = startingStorageFolder
});
return dialogResult.FirstOrDefault()?.TryGetLocalPath() ?? "";
}
}

View File

@@ -0,0 +1,15 @@
using System.IO;
namespace Nitrox.Launcher.Models.Extensions;
public static class StringExtensions
{
public static string ReplaceInvalidFileNameCharacters(this string value)
{
foreach (char invalidFileNameChar in Path.GetInvalidFileNameChars())
{
value = value.Replace(invalidFileNameChar, ' ');
}
return value.Trim();
}
}

View File

@@ -0,0 +1,34 @@
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
using NitroxModel.Platforms.OS.Windows;
namespace Nitrox.Launcher.Models.Extensions;
public static class VisualExtensions
{
public static void ApplyOsWindowStyling(this Visual visual)
{
if (Avalonia.Controls.Design.IsDesignMode)
{
return;
}
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return;
}
if (visual.GetWindow() is not { } window)
{
return;
}
nint? windowHandle = window.TryGetPlatformHandle()?.Handle;
if (!windowHandle.HasValue)
{
return;
}
WindowsApi.EnableDefaultWindowAnimations(windowHandle.Value, window.CanResize);
}
public static Window GetWindow(this Visual visual) => TopLevel.GetTopLevel(visual) as Window;
}

View File

@@ -0,0 +1,5 @@
using System;
namespace Nitrox.Launcher.Models;
public interface IMessageReceiver : IDisposable;

View File

@@ -0,0 +1,16 @@
using Nitrox.Launcher.Models.Design;
namespace Nitrox.Launcher.Models;
/// <summary>
/// Sent when a save is deleted outside the Servers view (i.e. server manage view or via file explorer).
/// </summary>
public record SaveDeletedMessage(string SaveName);
public record NotificationAddMessage(NotificationItem Item);
public record NotificationCloseMessage(NotificationItem Item);
public record ViewShownMessage(object ViewModel);
public record ServerStatusMessage(ServerEntry Server, bool IsOnline);

View File

@@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Messaging;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels;
using NitroxModel.Helper;
using NitroxModel.Logger;
using NitroxModel.Server;
namespace Nitrox.Launcher.Models.Services;
/// <summary>
/// Keeps track of server instances.
/// </summary>
public class ServerService : IMessageReceiver, INotifyPropertyChanged
{
private readonly IDialogService dialogService;
private readonly IKeyValueStore keyValueStore;
private readonly IRoutingScreen screen;
private List<ServerEntry> servers = [];
private readonly Lock serversLock = new();
private bool shouldRefreshServersList;
private FileSystemWatcher watcher;
private readonly CancellationTokenSource serverRefreshCts = new();
private readonly HashSet<string> loggedErrorDirectories = [];
private volatile bool hasUpdatedAtLeastOnce;
public ServerService(IDialogService dialogService, IKeyValueStore keyValueStore, IRoutingScreen screen)
{
this.dialogService = dialogService;
this.keyValueStore = keyValueStore;
this.screen = screen;
_ = LoadServersAsync().ContinueWithHandleError(ex => LauncherNotifier.Error(ex.Message));
this.RegisterMessageListener<SaveDeletedMessage, ServerService>(static (message, receiver) =>
{
lock (receiver.serversLock)
{
bool changes = false;
for (int i = receiver.servers.Count - 1; i >= 0; i--)
{
if (receiver.servers[i].Name == message.SaveName)
{
receiver.servers.RemoveAt(i);
changes = true;
}
}
if (changes)
{
receiver.SetField(ref receiver.servers, receiver.servers);
}
}
});
}
private async Task LoadServersAsync()
{
await GetSavesOnDiskAsync();
_ = WatchServersAsync(serverRefreshCts.Token).ContinueWithHandleError(ex => LauncherNotifier.Error(ex.Message));
}
public async Task<bool> StartServerAsync(ServerEntry server)
{
// TODO: Exclude upgradeable versions + add separate prompt to upgrade first?
if (server.Version != NitroxEnvironment.Version && !await ConfirmServerVersionAsync(server))
{
return false;
}
if (await GameInspect.IsOutdatedGameAndNotify(NitroxUser.GamePath, dialogService))
{
return false;
}
try
{
server.Version = NitroxEnvironment.Version;
server.Start(keyValueStore.GetSavesFolderDir());
if (server.IsEmbedded)
{
await screen.ShowAsync(new EmbeddedServerViewModel(server));
}
return true;
}
catch (Exception ex)
{
Log.Error(ex, $"Error while starting server \"{server.Name}\"");
await Dispatcher.UIThread.InvokeAsync(async () => await dialogService.ShowErrorAsync(ex, $"Error while starting server \"{server.Name}\""));
return false;
}
}
public async Task<bool> ConfirmServerVersionAsync(ServerEntry server) =>
await dialogService.ShowAsync<DialogBoxViewModel>(model =>
{
model.Title = $"The version of '{server.Name}' is v{(server.Version != null ? server.Version.ToString() : "X.X.X.X")}. It is highly recommended to NOT use this save file with Nitrox v{NitroxEnvironment.Version}. Would you still like to continue?";
model.ButtonOptions = ButtonOptions.YesNo;
});
private async Task GetSavesOnDiskAsync(CancellationToken cancellationToken = default)
{
try
{
Directory.CreateDirectory(keyValueStore.GetSavesFolderDir());
Dictionary<string, (ServerEntry Data, bool HasFiles)> serversOnDisk = Servers.ToDictionary(entry => entry.Name, entry => (entry, false));
foreach (string saveDir in Directory.EnumerateDirectories(keyValueStore.GetSavesFolderDir()))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (serversOnDisk.TryGetValue(Path.GetFileName(saveDir), out (ServerEntry Data, bool _) server))
{
// This server has files, so don't filter it away from server list.
serversOnDisk[Path.GetFileName(saveDir)] = (server.Data, true);
continue;
}
ServerEntry entryFromDir = await Task.Run(() => ServerEntry.FromDirectory(saveDir), cancellationToken);
if (entryFromDir != null)
{
serversOnDisk.Add(entryFromDir.Name, (entryFromDir, true));
}
loggedErrorDirectories.Remove(saveDir);
}
catch (Exception ex)
{
if (loggedErrorDirectories.Add(saveDir)) // Only log once per directory to prevent log spam
{
Log.Error(ex, $"Error while initializing save from directory \"{saveDir}\". Skipping...");
}
}
}
lock (serversLock)
{
Servers = [..serversOnDisk.Values.Where(server => server.HasFiles).Select(server => server.Data).OrderByDescending(entry => entry.LastAccessedTime)];
hasUpdatedAtLeastOnce = true;
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
Log.Error(ex, "Error while getting saves");
await dialogService.ShowErrorAsync(ex, "Error while getting saves");
}
}
private async Task WatchServersAsync(CancellationToken cancellationToken = default)
{
watcher = new FileSystemWatcher
{
Path = keyValueStore.GetSavesFolderDir(),
NotifyFilter = NotifyFilters.DirectoryName | NotifyFilters.LastWrite | NotifyFilters.Size,
Filter = "*.*",
IncludeSubdirectories = true
};
watcher.Changed += OnDirectoryChanged;
watcher.Created += OnDirectoryChanged;
watcher.Deleted += OnDirectoryChanged;
watcher.Renamed += OnDirectoryChanged;
try
{
await Task.Run(async () =>
{
watcher.EnableRaisingEvents = true; // Slowish (~2ms) - Moved into Task.Run.
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
while (shouldRefreshServersList)
{
try
{
await GetSavesOnDiskAsync(cancellationToken);
shouldRefreshServersList = false;
}
catch (IOException)
{
await Task.Delay(100, cancellationToken);
}
}
await Task.Delay(1000, cancellationToken);
}
}, cancellationToken);
}
catch (OperationCanceledException)
{
// ignored
}
}
private void OnDirectoryChanged(object sender, FileSystemEventArgs e)
{
shouldRefreshServersList = true;
}
public void Dispose()
{
serverRefreshCts.Cancel();
serverRefreshCts.Dispose();
WeakReferenceMessenger.Default.UnregisterAll(this);
watcher?.Dispose();
}
public ServerEntry[] Servers
{
get
{
lock (serversLock)
{
return [..servers];
}
}
private set
{
lock (serversLock)
{
SetField(ref servers, [..value]);
}
}
}
/// <summary>
/// Gets the servers or waits for servers to be loaded from file system.
/// </summary>
public async Task<ServerEntry[]> GetServersAsync()
{
while (true)
{
lock (serversLock)
{
if (hasUpdatedAtLeastOnce)
{
return Servers;
}
}
await Task.Delay(100);
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
public async Task<ServerEntry> GetOrCreateServerAsync(string saveName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(saveName);
string serverPath = Path.Combine(keyValueStore.GetSavesFolderDir(), saveName);
return (await GetServersAsync()).FirstOrDefault(s => s.Name == saveName) ?? ServerEntry.FromDirectory(serverPath) ?? ServerEntry.CreateNew(serverPath, NitroxGameMode.SURVIVAL);
}
}

View File

@@ -0,0 +1,183 @@
<Styles
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>
<Panel Background="Cornflowerblue">
<StackPanel Margin="10" Spacing="10">
<ThemeVariantScope RequestedThemeVariant="Light">
<ContentControl Padding="10" Background="#FFFFFF">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="10">
<TextBlock>Light</TextBlock>
<TextBox Watermark="Enter something here.." />
</StackPanel>
</ContentControl>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Dark">
<ContentControl Padding="10" Background="#000000">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="10">
<TextBlock>Dark</TextBlock>
<TextBox Watermark="Enter something here.." />
</StackPanel>
</ContentControl>
</ThemeVariantScope>
</StackPanel>
</Panel>
</Design.PreviewWith>
<Style Selector="Window">
<Setter Property="Icon" Value="/Assets/Images/nitrox-icon.ico" />
<Setter Property="Focusable" Value="True" />
<Setter Property="WindowStartupLocation" Value="CenterOwner" />
<Setter Property="FontFamily" Value="/Assets/Fonts/Inter-Black.ttf" />
<!-- Disables window border but allow resizing -->
<Setter Property="Background" Value="{DynamicResource BrandWhite}" />
<Setter Property="SystemDecorations">
<Setter.Value>
<OnPlatform>
<OnPlatform.Default>
<SystemDecorations>None</SystemDecorations>
</OnPlatform.Default>
<On Options="Windows">
<SystemDecorations>Full</SystemDecorations>
</On>
</OnPlatform>
</Setter.Value>
</Setter>
<Setter Property="ExtendClientAreaToDecorationsHint" Value="True" />
<Setter Property="ExtendClientAreaTitleBarHeightHint" Value="-99" />
<Setter Property="Padding" Value="{Binding $self.OffScreenMargin}" />
</Style>
<Style Selector="Border">
<Setter Property="BorderBrush" Value="{DynamicResource BrandBorderBackground}" />
</Style>
<Style Selector="SelectableTextBlock">
<!-- Background should be transparent so text and be selected anywhere inside of control. -->
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="controls|CustomTitlebar">
<Setter Property="Background" Value="{DynamicResource BrandWhite}" />
</Style>
<Style Selector="Svg.theme">
<Setter Property="Css" Value="{DynamicResource BrandSvgStyle}" />
</Style>
<Style Selector=":is(Border).footer">
<Setter Property="Background" Value="{DynamicResource BrandControlBackground}" />
<Setter Property="Padding" Value="24 20" />
<Setter Property="BorderThickness" Value="0 2 0 0" />
<Setter Property="BorderBrush" Value="{DynamicResource BrandWhite}" />
</Style>
<Style Selector=":is(Control)">
<Setter Property="Opacity" Value="1" />
<Style Selector="^ /template/ ContentPresenter">
<Setter Property="Opacity" Value="{TemplateBinding Opacity}" />
</Style>
<!-- TODO: a good-looking focus overlay, compatible with all controls -->
<Setter Property="FocusAdorner">
<Setter.Value>
<FocusAdornerTemplate>
<Border
Name="FocusBorder"
BorderBrush="{DynamicResource BrandFocusBorder}"
BorderThickness="2" />
</FocusAdornerTemplate>
</Setter.Value>
</Setter>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value=".85" />
</Style>
</Style>
<Style Selector=".form">
<Style Selector="^ > :is(Layoutable):not(:nth-last-child(1))">
<Setter Property="Margin" Value="0 0 0 26" />
</Style>
<Style Selector="^ StackPanel.form > TextBlock:nth-child(1)">
<Setter Property="FontSize" Value="10" />
<Setter Property="FontWeight" Value="700" />
<Setter Property="Margin" Value="0 0 0 11" />
</Style>
<Style Selector="^ StackPanel.form > TextBlock:nth-child(2)">
<Setter Property="FontSize" Value="10" />
<Setter Property="FontWeight" Value="500" />
<Setter Property="Margin" Value="0 -11 0 11" />
<Setter Property="IsVisible" Value="{Binding !$self.Text, Converter={converters:EqualityConverter}}" />
</Style>
</Style>
<Style Selector="Image.header">
<Setter Property="Width" Value="108" />
<Setter Property="Height" Value="24" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Bottom" />
</Style>
<Style Selector=":is(ContentControl).content">
<Setter Property="Background" Value="{DynamicResource BrandWhite}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
</Style>
<Style Selector=":is(Layoutable).viewPadding">
<Setter Property="Margin" Value="34 45" />
</Style>
<Style Selector="Border.serverEntry">
<Setter Property="Background" Value="{DynamicResource BrandPanelBackground}" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="22 20" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Height" Value="96" />
<Style Selector="^ StackPanel.description">
<Style Selector="^ > :is(Control)">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="6 0" />
</Style>
<Style Selector="^ > :is(Control):nth-child(1)">
<Setter Property="Margin" Value="0 0 6 0" />
</Style>
<Style Selector="^ > :is(Control):nth-last-child(1)">
<Setter Property="Margin" Value="6 0 0 0" />
</Style>
<Style Selector="^ Ellipse">
<Setter Property="Height" Value="6" />
<Setter Property="Width" Value="6" />
</Style>
<Style Selector="^ TextBlock">
<Setter Property="Opacity" Value="0.5" />
</Style>
</Style>
</Style>
<StyleInclude Source="/Models/Styles/Theme/ValidationErrorsStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/TextBoxStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/TextBlockStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/ToolTipStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/ButtonStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/ComboBoxStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/CheckBoxStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/RadioButtonGroupStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/RadioButtonStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/ScrollViewerStyle.axaml" />
<StyleInclude Source="/Models/Styles/Theme/ExpanderStyle.axaml" />
</Styles>

View File

@@ -0,0 +1,51 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary.ThemeDictionaries>
<!-- Default/Light theme -->
<ResourceDictionary x:Key="Default">
<!-- Main colors -->
<Color x:Key="BrandPrimary">#007BFF</Color>
<Color x:Key="BrandWhite">#FFFFFF</Color>
<Color x:Key="BrandBlack">#000000</Color>
<Color x:Key="BrandBorder">#D1D1D1</Color>
<Color x:Key="BrandControlBackground">#ECECEC</Color>
<Color x:Key="BrandCheckboxBackground">#D9D9D9</Color>
<Color x:Key="BrandPanelBackground">#EAEAE9</Color>
<Color x:Key="BrandBorderBackground">#b8b8b8</Color>
<Color x:Key="BrandCaret">#000000</Color>
<Color x:Key="BrandFocusBorder">#40000000</Color>
<Color x:Key="BrandSubText">#7F000000</Color>
<Color x:Key="BrandScrollThumb">#CCCCCC</Color>
<Color x:Key="BrandScrollThumbPointerOver">#8F8F8F</Color>
<Color x:Key="BrandScrollThumbPressed">#3F3F3F</Color>
<Color x:Key="BrandOnColor">#38C149</Color>
<Color x:Key="BrandOffColor">#FF5E57</Color>
<!-- Notification colors -->
<Color x:Key="BrandAbort">#FF005C</Color>
<Color x:Key="BrandAbortBackground">#FFB3B3</Color>
<Color x:Key="BrandError">#E60914</Color>
<Color x:Key="BrandSuccess">#11AD45</Color>
<Color x:Key="BrandInformation">#007BFF</Color>
<Color x:Key="BrandWarning">#F5A300</Color>
<!-- Misc -->
<x:String x:Key="BrandSvgStyle">* { fill: #000; stroke: #000; }</x:String>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<Color x:Key="BrandWhite">#000000</Color>
<Color x:Key="BrandBlack">#FFFFFF</Color>
<Color x:Key="BrandBorder">#323232</Color>
<Color x:Key="BrandControlBackground">#1D1D1D</Color>
<Color x:Key="BrandCheckboxBackground">#333333</Color>
<Color x:Key="BrandPanelBackground">#151516</Color>
<Color x:Key="BrandBorderBackground">#474747</Color>
<Color x:Key="BrandCaret">#FFFFFF</Color>
<Color x:Key="BrandFocusBorder">#40FFFFFF</Color>
<Color x:Key="BrandSubText">#7FFFFFFF</Color>
<Color x:Key="BrandScrollThumb">#262626</Color>
<Color x:Key="BrandScrollThumbPointerOver">#3F3F3F</Color>
<Color x:Key="BrandScrollThumbPressed">#8F8F8F</Color>
<x:String x:Key="BrandSvgStyle">* { fill: #fff; stroke: #fff; }</x:String>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>

View File

@@ -0,0 +1,191 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Panel Background="CornflowerBlue" Width="250">
<StackPanel Margin="10" Spacing="10">
<ThemeVariantScope RequestedThemeVariant="Dark">
<StackPanel HorizontalAlignment="Center">
<Button Content="Normal button" HorizontalAlignment="Stretch" />
<Button
Classes="primary"
Content="Primary button"
HorizontalAlignment="Stretch" />
</StackPanel>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Light">
<StackPanel HorizontalAlignment="Center">
<Button Content="Normal button" HorizontalAlignment="Stretch" />
<Button
Classes="primary"
Content="Primary button"
HorizontalAlignment="Stretch" />
<Button
Classes="abort"
Content="Abort button"
HorizontalAlignment="Stretch" />
<Button
Classes="primary big"
Content="Primary text"
HorizontalAlignment="Stretch" />
<Button Classes="primary big" HorizontalAlignment="Stretch">
<StackPanel>
<TextBlock Text="Primary Button Big" />
<TextBlock Text="Some sub text" />
</StackPanel>
</Button>
</StackPanel>
</ThemeVariantScope>
<StackPanel HorizontalAlignment="Center" Spacing="5">
<ThemeVariantScope RequestedThemeVariant="Light">
<Button
Background="White"
Classes="icon"
HorizontalAlignment="Center">
<StackPanel>
<Image Source="/Assets/Images/world-manager/cog.png" />
<TextBlock Text="Icon button" />
</StackPanel>
</Button>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Dark">
<Button
Background="Black"
Classes="icon"
HorizontalAlignment="Center">
<StackPanel>
<Image Source="/Assets/Images/world-manager/cog.png" />
<TextBlock Text="Icon button" />
</StackPanel>
</Button>
</ThemeVariantScope>
</StackPanel>
</StackPanel>
</Panel>
</Design.PreviewWith>
<Style Selector="Button">
<Setter Property="Background" Value="{DynamicResource BrandBorderBackground}" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="16 8.73" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Duration="0:0:0.075" Property="Opacity" />
<BrushTransition Duration="0:0:0.075" Property="Background" />
</Transitions>
</Setter>
<Style Selector="^:pointerover">
<Setter Property="Opacity" Value="0.7" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="^ TextBlock">
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="^ TextBlock:nth-child(1)">
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style Selector="^ StackPanel">
<Setter Property="Orientation" Value="Horizontal" />
</Style>
<!-- TODO: Pretty animations -->
<Style Selector="^.busy">
<Setter Property="Opacity" Value="1" />
<Style.Animations>
<Animation
Duration="0:0:.75"
Easing="SineEaseInOut"
IterationCount="INFINITE"
PlaybackDirection="Alternate">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="1" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="0.3" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="^.primary">
<Setter Property="Background" Value="{DynamicResource BrandPrimary}" />
<Setter Property="Foreground" Value="#ffffff" />
</Style>
<Style Selector="^.abort">
<Setter Property="Background" Value="{DynamicResource BrandAbort}" />
<Setter Property="Foreground" Value="#ffffff" />
</Style>
<Style Selector="^.big">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="10" />
<Setter Property="MinHeight" Value="62" />
<Style Selector="^ StackPanel">
<Setter Property="Orientation" Value="Vertical" />
</Style>
<Style Selector="^ ContentPresenter /template/ TextBlock">
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style Selector="^ TextBlock:not(:nth-child(1))">
<Setter Property="FontSize" Value="10" />
</Style>
</Style>
<Style Selector="^.anycontent">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="4" />
</Style>
<Style Selector="^.icon">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="4" />
<Style Selector="^ StackPanel">
<Setter Property="Spacing" Value="10" />
</Style>
<Style Selector="^ Image">
<Setter Property="Width" Value="18" />
<Setter Property="Height" Value="18" />
</Style>
<Style Selector="^:pointerover">
<Setter Property="Opacity" Value="1" />
<Setter Property="TextBlock.TextDecorations" Value="Underline" />
</Style>
</Style>
<!-- Override button template to use properties from parent Button. DO NOT HARD CODE VALUES HERE. -->
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Opacity" Value="{Binding $parent[Button].Opacity}" />
<Setter Property="TextBlock.Foreground" Value="{Binding $parent[Button].Foreground}" />
<Setter Property="Background" Value="{Binding $parent[Button].Background}" />
<Setter Property="CornerRadius" Value="{Binding $parent[Button].CornerRadius}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Opacity" Value="{Binding $parent[Button].Opacity}" />
<Setter Property="TextBlock.Foreground" Value="{Binding $parent[Button].Foreground}" />
<Setter Property="Background" Value="{Binding $parent[Button].Background}" />
<Setter Property="Cursor" Value="{Binding $parent[Button].Cursor}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.25" />
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="{Binding $parent[Button].Foreground}" />
<Setter Property="Background" Value="{Binding $parent[Button].Background}" />
<Setter Property="Cursor" Value="{Binding $parent[Button].Cursor}" />
</Style>
</Style>
</Style>
</Styles>

View File

@@ -0,0 +1,148 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Panel Background="CornflowerBlue">
<StackPanel Margin="10" Spacing="15">
<StackPanel
HorizontalAlignment="Center"
Orientation="Horizontal"
Spacing="5">
<ThemeVariantScope RequestedThemeVariant="Light">
<Border Background="White" Padding="5">
<CheckBox>Light</CheckBox>
</Border>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Dark">
<Border Background="Black" Padding="5">
<CheckBox Foreground="White" IsChecked="True">Dark</CheckBox>
</Border>
</ThemeVariantScope>
</StackPanel>
<StackPanel
HorizontalAlignment="Center"
Orientation="Horizontal"
Spacing="5">
<ThemeVariantScope RequestedThemeVariant="Light">
<StackPanel Background="White" Spacing="5">
<Border>
<CheckBox Classes="switch" />
</Border>
<Border>
<CheckBox Classes="switch" IsChecked="True" />
</Border>
</StackPanel>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Dark">
<StackPanel Background="Black" Spacing="5">
<Border>
<CheckBox Classes="switch" />
</Border>
<Border>
<CheckBox Classes="switch" IsChecked="True" />
</Border>
</StackPanel>
</ThemeVariantScope>
</StackPanel>
</StackPanel>
</Panel>
</Design.PreviewWith>
<Style Selector="CheckBox.switch">
<Setter Property="Background" Value="{DynamicResource BrandCheckboxBackground}" />
<Setter Property="CornerRadius" Value="15" />
<Setter Property="Height" Value="30" />
<Setter Property="Width" Value="54" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Duration="0:0:.075" Property="RenderTransform" />
<DoubleTransition Duration="0:0:0.15" Property="Opacity" />
<ThicknessTransition Duration="0:0:0.1" Property="Margin" />
</Transitions>
</Setter>
<Setter Property="Template">
<ControlTemplate>
<Grid x:Name="RootGrid">
<Border
Background="{TemplateBinding Background}"
CornerRadius="{TemplateBinding CornerRadius}"
Padding="4"
x:Name="PART_Border">
<Viewbox HorizontalAlignment="Left" x:Name="SlidingIconViewbox">
<Border
Background="{TemplateBinding Foreground}"
CornerRadius="15"
Height="24"
Width="24"
x:Name="SlidingIcon">
<Border.Styles>
<Style Selector="Border">
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Duration="0:0:.075" Property="RenderTransform" />
<ThicknessTransition Duration="0:0:0.1" Property="Margin" />
</Transitions>
</Setter>
</Style>
</Border.Styles>
</Border>
</Viewbox>
</Border>
</Grid>
</ControlTemplate>
</Setter>
<!-- Unchecked PointerOver State -->
<Style Selector="^:pointerover">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Opacity" Value=".75" />
<Style Selector="^ /template/ Border#PART_Border">
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
</Style>
<!-- Unchecked Pressed State -->
<Style Selector="^:pressed">
<Setter Property="RenderTransform" Value="scale(0.95)" />
<Style Selector="^ /template/ Border#PART_Border">
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
</Style>
<!-- Disabled State -->
<Style Selector="^:disabled /template/ Border#PART_Border">
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
<Style Selector="^:checked">
<!-- Checked Normal State -->
<Setter Property="Background" Value="{DynamicResource BrandCheckboxBackground}" />
<Setter Property="Foreground" Value="{DynamicResource BrandControlBackground}" />
<Style Selector="^ /template/ Border#SlidingIcon">
<Setter Property="Background" Value="{DynamicResource BrandPrimary}" />
<Setter Property="Margin" Value="22 0 0 0" />
</Style>
<!-- Checked PointerOver State -->
<Style Selector="^:pointerover">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Opacity" Value=".75" />
<Style Selector="^ /template/ Border#PART_Border">
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
</Style>
<!-- Checked Pressed State -->
<Style Selector="^:pressed">
<Setter Property="RenderTransform" Value="scale(0.95)" />
<Style Selector="^ /template/ Border#PART_Border">
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
</Style>
</Style>
</Style>
</Styles>

View File

@@ -0,0 +1,186 @@
<Styles
xmlns="https://github.com/avaloniaui"
xmlns:design="clr-namespace:Nitrox.Launcher.Models.Design"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Panel Background="CornflowerBlue">
<StackPanel
Margin="10"
Orientation="Horizontal"
Spacing="20">
<StackPanel Spacing="10">
<Border
Background="White"
HorizontalAlignment="Center"
Padding="10">
<ThemeVariantScope RequestedThemeVariant="Light">
<Border Margin="0,0,0,110">
<ComboBox
HorizontalAlignment="Stretch"
PlaceholderText="Light"
Width="200">
<ComboBoxItem Content="Item 1" />
<ComboBoxItem Content="Item 2" />
<ComboBoxItem Content="Item 3" />
</ComboBox>
</Border>
</ThemeVariantScope>
</Border>
<Border
Background="Black"
HorizontalAlignment="Center"
Padding="10">
<ThemeVariantScope RequestedThemeVariant="Dark">
<Border Margin="0,0,0,110">
<ComboBox
HorizontalAlignment="Stretch"
PlaceholderText="Dark"
Width="200">
<ComboBoxItem Content="Item 1" />
<ComboBoxItem Content="Item 2" />
<ComboBoxItem Content="Item 3" />
</ComboBox>
</Border>
</ThemeVariantScope>
</Border>
</StackPanel>
</StackPanel>
</Panel>
</Design.PreviewWith>
<Style Selector="ComboBox">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Padding" Value="14" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="ScrollViewer.IsScrollInertiaEnabled" Value="true" />
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="MinWidth" Value="100" />
<Setter Property="MinHeight" Value="30" />
<Setter Property="Background" Value="{DynamicResource BrandControlBackground}" />
<Style Selector="^ /template/ ContentPresenter">
<Style Selector="^ TextBlock">
<Setter Property="Foreground" Value="{DynamicResource BrandBlack}" />
</Style>
<Style Selector="^ TextBlock#PlaceholderTextBlock">
<Setter Property="Foreground" Value="{DynamicResource BrandSubText}" />
</Style>
<Style Selector="^ PathIcon">
<Setter Property="Foreground" Value="{DynamicResource BrandBlack}" />
</Style>
</Style>
<Style Selector="^:pointerover /template/ Border#Background">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="{DynamicResource BrandControlBackground}" />
</Style>
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Duration="0:0:.075" Property="RenderTransform" />
<DoubleTransition Duration="0:0:0.15" Property="Opacity" />
</Transitions>
</Setter>
<Setter Property="Template">
<ControlTemplate>
<DataValidationErrors>
<Grid>
<Grid ColumnDefinitions="*,32" IsHitTestVisible="True">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Grid.ColumnSpan="2"
MinWidth="{DynamicResource ComboBoxThemeMinWidth}"
x:Name="Background" />
<TextBlock
Grid.Column="0"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
IsVisible="{TemplateBinding SelectionBoxItem,
Converter={x:Static ObjectConverters.IsNull}}"
Margin="{TemplateBinding Padding}"
Text="{TemplateBinding PlaceholderText}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
x:Name="PlaceholderTextBlock" />
<ContentControl
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding ItemTemplate}"
Grid.Column="0"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
Margin="{TemplateBinding Padding}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
x:Name="ContentPresenter" />
<Border
Background="Transparent"
Grid.Column="1"
HorizontalAlignment="Right"
IsVisible="False"
Margin="0,1,1,1"
Width="30"
x:Name="DropDownOverlay" />
<PathIcon
Data="M1939 486L2029 576L1024 1581L19 576L109 486L1024 1401L1939 486Z"
Foreground="{TemplateBinding Foreground}"
Grid.Column="1"
Height="12"
HorizontalAlignment="Right"
IsHitTestVisible="False"
Margin="0,0,10,0"
UseLayoutRounding="False"
VerticalAlignment="Center"
Width="12"
x:Name="DropDownGlyph" />
</Grid>
<Popup
Grid.Column="0"
InheritsTransform="True"
IsLightDismissEnabled="True"
IsOpen="{TemplateBinding IsDropDownOpen,
Mode=TwoWay}"
MaxHeight="{TemplateBinding MaxDropDownHeight}"
MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}"
Name="PART_Popup"
PlacementTarget="Background"
WindowManagerAddShadowHint="False">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{DynamicResource BrandWhite}"
BorderThickness="1,0,2,2"
CornerRadius="{DynamicResource OverlayCornerRadius}"
HorizontalAlignment="Stretch"
Padding="{DynamicResource ComboBoxDropdownBorderPadding}"
x:Name="PopupBorder">
<ScrollViewer HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}" VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}">
<ItemsPresenter
ItemsPanel="{TemplateBinding ItemsPanel}"
Margin="{DynamicResource ComboBoxDropdownContentMargin}"
Name="PART_ItemsPresenter" />
</ScrollViewer>
</Border>
</Popup>
</Grid>
</DataValidationErrors>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover">
<Setter Property="Opacity" Value=".75" />
</Style>
<Style Selector="^:pressed">
<Setter Property="RenderTransform" Value="scale(0.98)" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
</Style>
</Styles>

View File

@@ -0,0 +1,152 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Panel Background="CornflowerBlue">
<StackPanel Margin="10" Spacing="20">
<ThemeVariantScope RequestedThemeVariant="Light">
<Border Background="White" Padding="10">
<StackPanel Spacing="10">
<Expander
ExpandDirection="Up"
Header="Expand Up"
HorizontalAlignment="Center">
<StackPanel>
<TextBlock>Expanded content</TextBlock>
</StackPanel>
</Expander>
<Expander
Classes="changelog"
Header="Changelog Expander"
HorizontalAlignment="Center">
<TextBlock>Expanded content</TextBlock>
</Expander>
</StackPanel>
</Border>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Dark">
<Border Background="Black" Padding="10">
<StackPanel Spacing="10">
<Expander
ExpandDirection="Up"
Header="Expand Up"
HorizontalAlignment="Center">
<StackPanel>
<TextBlock>Expanded content</TextBlock>
</StackPanel>
</Expander>
<Expander
Classes="changelog"
Header="Changelog Expander"
HorizontalAlignment="Center">
<TextBlock>Expanded content</TextBlock>
</Expander>
</StackPanel>
</Border>
</ThemeVariantScope>
</StackPanel>
</Panel>
</Design.PreviewWith>
<!-- TODO: Create a clean Nitrox Expander+ToggleButton style -->
<Style Selector="ToggleButton.changelog">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Template">
<ControlTemplate>
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Foreground="{TemplateBinding Foreground}" Text="{TemplateBinding Content}" />
<Path
Data="M0.530273 1.46973L5.53027 6.46973M4.46961 6.46973L9.46961 1.46973"
HorizontalAlignment="Center"
RenderTransformOrigin="50%,50%"
Stretch="None"
Stroke="{TemplateBinding Foreground}"
StrokeThickness="1.5"
VerticalAlignment="Center">
<Path.RenderTransform>
<RotateTransform />
</Path.RenderTransform>
</Path>
</StackPanel>
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover">
<Setter Property="Cursor" Value="Hand" />
<Style Selector="^ /template/ TextBlock">
<Setter Property="Background" Value="{Binding $parent[ToggleButton].Background}" />
<Setter Property="TextDecorations" Value="Underline" />
</Style>
</Style>
<!-- Arrow Animation -->
<Style Selector="^[Tag=expanded] /template/ Path">
<Style.Animations>
<Animation Duration="0:0:0.0625" FillMode="Both">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="^[Tag=collapsed] /template/ Path">
<Style.Animations>
<Animation Duration="0:0:0.0625" FillMode="Both">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</Style>
<Style Selector="Expander.changelog">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Template">
<ControlTemplate>
<StackPanel>
<ToggleButton
Background="{TemplateBinding Background}"
Classes="changelog"
Content="{TemplateBinding Header}"
Foreground="{TemplateBinding Foreground}"
IsChecked="{TemplateBinding IsExpanded,
Mode=TwoWay}"
IsEnabled="{TemplateBinding IsEnabled}" />
<Border
HorizontalAlignment="Stretch"
IsVisible="{TemplateBinding IsExpanded,
Mode=TwoWay}"
Padding="{TemplateBinding Padding}"
VerticalAlignment="Stretch">
<ContentPresenter
Content="{TemplateBinding Content}"
Foreground="{TemplateBinding Foreground}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
</StackPanel>
</ControlTemplate>
</Setter>
<Style Selector="^:expanded /template/ ToggleButton">
<Setter Property="Tag" Value="expanded" />
</Style>
<Style Selector="^:not(:expanded) /template/ ToggleButton">
<Setter Property="Tag" Value="collapsed" />
</Style>
</Style>
</Styles>

View File

@@ -0,0 +1,83 @@
<Styles
xmlns="https://github.com/avaloniaui"
xmlns:controls="clr-namespace:Nitrox.Launcher.Models.Controls"
xmlns:converters="clr-namespace:Nitrox.Launcher.Models.Converters"
xmlns:design="clr-namespace:Nitrox.Launcher.Models.Design"
xmlns:server="clr-namespace:NitroxModel.Server;assembly=NitroxModel"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Panel Background="Cornflowerblue">
<StackPanel Margin="10" Spacing="10">
<ThemeVariantScope RequestedThemeVariant="Dark">
<Border Background="#000000" Padding="10">
<controls:RadioButtonGroup Classes="radioGroup" Enum="{x:Type server:NitroxGameMode}" />
</Border>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Light">
<Border Background="#FFFFFF" Padding="10">
<controls:RadioButtonGroup Classes="radioGroup" Enum="{x:Type server:NitroxGameMode}" />
</Border>
</ThemeVariantScope>
</StackPanel>
</Panel>
</Design.PreviewWith>
<!-- Base style without palette -->
<Style Selector="ItemsControl.radioGroup">
<Setter Property="Background" Value="{DynamicResource BrandWhite}" />
<Setter Property="Foreground" Value="{DynamicResource BrandBlack}" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" />
</ItemsPanelTemplate>
</Setter>
<Setter Property="ItemTemplate">
<Setter.Value>
<DataTemplate>
<Button
Background="Transparent"
BorderBrush="{DynamicResource BrandBorder}"
BorderThickness="2"
Command="{Binding $parent[controls:RadioButtonGroup].ItemClickCommand}"
CommandParameter="{Binding $self}"
CornerRadius="8"
Name="ItemContainer"
Padding="14,16"
Tag="{Binding}">
<design:NitroxAttached.Selected>
<MultiBinding Converter="{converters:EqualityConverter}">
<Binding />
<Binding Path="$parent[controls:RadioButtonGroup].SelectedItem" />
</MultiBinding>
</design:NitroxAttached.Selected>
<TextBlock Name="Text" Text="{Binding Converter={converters:ToStringConverter}}" />
</Button>
</DataTemplate>
</Setter.Value>
</Setter>
</Style>
<Style Selector="ItemsControl.radioGroup Button#ItemContainer:pointerover /template/ ContentPresenter">
<Setter Property="BorderBrush" Value="{Binding $parent[Button].BorderBrush}" />
</Style>
<Style Selector="ItemsControl.radioGroup Button#ItemContainer">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Duration="0:0:0.15" Property="BorderBrush" />
</Transitions>
</Setter>
</Style>
<Style Selector="ItemsControl.radioGroup:disabled">
<Setter Property="Background" Value="{Binding $parent[Button].Background}" />
<Style Selector="^ Button#ItemContainer /template/ ContentPresenter">
<Setter Property="BorderBrush" Value="{Binding $parent[Button].BorderBrush}" />
</Style>
</Style>
<!-- Events -->
<Style Selector="ItemsControl.radioGroup Button#ItemContainer:pointerover">
<Setter Property="BorderBrush" Value="{DynamicResource BrandPrimary}" />
</Style>
<Style Selector="ItemsControl.radioGroup Button#ItemContainer[(design|NitroxAttached.Selected)=true]">
<Setter Property="BorderBrush" Value="{DynamicResource BrandPrimary}" />
</Style>
</Styles>

View File

@@ -0,0 +1,221 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Panel Background="CornflowerBlue">
<StackPanel Margin="10" Spacing="15">
<StackPanel
HorizontalAlignment="Center"
Orientation="Horizontal"
Spacing="5">
<ThemeVariantScope RequestedThemeVariant="Light">
<Border Background="White" Padding="5">
<StackPanel Spacing="10">
<RadioButton Content="Option 1" IsChecked="True" />
<RadioButton Content="Option 2" />
<RadioButton Content="Option 3" />
<RadioButton Content="Option 4" />
</StackPanel>
</Border>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Dark">
<Border Background="Black" Padding="5">
<StackPanel Spacing="10">
<RadioButton Content="Option 1" />
<RadioButton Content="Option 2" IsChecked="True" />
<RadioButton Content="Option 3" />
<RadioButton Content="Option 4" />
</StackPanel>
</Border>
</ThemeVariantScope>
</StackPanel>
</StackPanel>
</Panel>
</Design.PreviewWith>
<Style Selector="RadioButton">
<Setter Property="Background" Value="{DynamicResource RadioButtonBackground}" />
<Setter Property="Foreground" Value="{DynamicResource BrandBlack}" />
<Setter Property="BorderBrush" Value="{DynamicResource RadioButtonBorderBrush}" />
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
<Setter Property="Padding" Value="8,0,0,0" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="{DynamicResource ControlContentThemeFontSize}" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Duration="0:0:.075" Property="RenderTransform" />
<DoubleTransition Duration="0:0:0.15" Property="Opacity" />
</Transitions>
</Setter>
<Setter Property="Template">
<ControlTemplate TargetType="RadioButton">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Name="RootBorder">
<StackPanel Orientation="Horizontal">
<Grid Height="32" VerticalAlignment="Top">
<Ellipse
Fill="White"
Height="20"
Name="OuterEllipse"
Stroke="{TemplateBinding Foreground}"
StrokeThickness="{DynamicResource RadioButtonBorderThemeThickness}"
UseLayoutRounding="False"
Width="20" />
<Ellipse
Fill="{DynamicResource RadioButtonOuterEllipseCheckedFill}"
Height="20"
Name="CheckOuterEllipse"
Opacity="0"
Stroke="{DynamicResource RadioButtonOuterEllipseCheckedStroke}"
StrokeThickness="{DynamicResource RadioButtonBorderThemeThickness}"
UseLayoutRounding="False"
Width="20" />
<Ellipse
Fill="{DynamicResource RadioButtonCheckGlyphFill}"
Height="8"
Name="CheckGlyph"
Opacity="0"
Stroke="{DynamicResource RadioButtonCheckGlyphStroke}"
UseLayoutRounding="False"
Width="8" />
</Grid>
<ContentPresenter
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Foreground="{TemplateBinding Foreground}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
Margin="{TemplateBinding Padding}"
Name="PART_ContentPresenter"
RecognizesAccessKey="True"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</StackPanel>
</Border>
</ControlTemplate>
</Setter>
<!-- PointerOver State -->
<Style Selector="^:pointerover">
<Setter Property="Opacity" Value=".75" />
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{TemplateBinding Foreground}" />
</Style>
<Style Selector="^ /template/ Border#RootBorder">
<Setter Property="Background" Value="{DynamicResource RadioButtonBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource RadioButtonBorderBrush}" />
</Style>
<Style Selector="^ /template/ Ellipse#OuterEllipse">
<Setter Property="Stroke" Value="{Binding $parent[RadioButton].Foreground}" />
<Setter Property="Fill" Value="White" />
</Style>
<Style Selector="^ /template/ Ellipse#CheckOuterEllipse">
<Setter Property="Stroke" Value="{DynamicResource RadioButtonOuterEllipseCheckedStroke}" />
<Setter Property="Fill" Value="{DynamicResource RadioButtonOuterEllipseCheckedFill}" />
</Style>
<Style Selector="^ /template/ Ellipse#CheckGlyph">
<Setter Property="Stroke" Value="{DynamicResource RadioButtonCheckGlyphStroke}" />
<Setter Property="Fill" Value="{DynamicResource RadioButtonCheckGlyphFill}" />
</Style>
</Style>
<!-- Pressed State -->
<Style Selector="^:pressed">
<Setter Property="RenderTransform" Value="scale(0.98)" />
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{TemplateBinding Foreground}" />
</Style>
<Style Selector="^ /template/ Border#RootBorder">
<Setter Property="Background" Value="{DynamicResource RadioButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource RadioButtonBorderBrushPressed}" />
</Style>
<Style Selector="^ /template/ Ellipse#OuterEllipse">
<Setter Property="Stroke" Value="{Binding $parent[RadioButton].Foreground}" />
<Setter Property="Fill" Value="White" />
</Style>
<Style Selector="^ /template/ Ellipse#CheckOuterEllipse">
<Setter Property="Stroke" Value="{DynamicResource RadioButtonOuterEllipseCheckedStrokePressed}" />
<Setter Property="Fill" Value="{DynamicResource RadioButtonOuterEllipseCheckedFillPressed}" />
</Style>
<Style Selector="^ /template/ Ellipse#CheckGlyph">
<Setter Property="Stroke" Value="{DynamicResource RadioButtonCheckGlyphStrokePressed}" />
<Setter Property="Fill" Value="{DynamicResource RadioButtonCheckGlyphFillPressed}" />
</Style>
</Style>
<!-- Disabled State -->
<Style Selector="^:disabled">
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{Binding $parent[RadioButton].Foreground}" />
</Style>
<Style Selector="^ /template/ Border#RootBorder">
<Setter Property="Background" Value="{Binding $parent[RadioButton].Background}" />
<Setter Property="BorderBrush" Value="{DynamicResource RadioButtonBorderBrush}" />
</Style>
<Style Selector="^ /template/ Ellipse#OuterEllipse">
<Setter Property="Stroke" Value="{Binding $parent[RadioButton].Foreground}" />
<Setter Property="Fill" Value="White" />
</Style>
<Style Selector="^ /template/ Ellipse#CheckOuterEllipse">
<Setter Property="Stroke" Value="{DynamicResource RadioButtonOuterEllipseCheckedStroke}" />
<Setter Property="Fill" Value="{DynamicResource RadioButtonOuterEllipseCheckedFill}" />
</Style>
<Style Selector="^ /template/ Ellipse#CheckGlyph">
<Setter Property="Stroke" Value="{DynamicResource RadioButtonCheckGlyphStroke}" />
<Setter Property="Fill" Value="{DynamicResource RadioButtonCheckGlyphFill}" />
</Style>
<Style Selector="^:unchecked">
<Style Selector="^ /template/ Ellipse#CheckGlyph">
<Setter Property="Opacity" Value="0" />
</Style>
<Style Selector="^ /template/ Ellipse#OuterEllipse">
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="^ /template/ Ellipse#CheckOuterEllipse">
<Setter Property="Opacity" Value="0" />
</Style>
</Style>
</Style>
<!-- Checked State -->
<Style Selector="^:checked">
<Style Selector="^ /template/ Ellipse#CheckGlyph">
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="^ /template/ Ellipse#OuterEllipse">
<Setter Property="Opacity" Value="0" />
</Style>
<Style Selector="^ /template/ Ellipse#CheckOuterEllipse">
<Setter Property="Opacity" Value="1" />
</Style>
</Style>
</Style>
</Styles>

View File

@@ -0,0 +1,301 @@
<Styles
xmlns="https://github.com/avaloniaui"
xmlns:behaviors="clr-namespace:Nitrox.Launcher.Models.Behaviors"
xmlns:design="clr-namespace:Nitrox.Launcher.Models.Design"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Panel Background="CornflowerBlue">
<StackPanel Margin="10" Spacing="20">
<StackPanel Orientation="Horizontal" Spacing="10">
<ThemeVariantScope RequestedThemeVariant="Light">
<Border Background="White" Padding="10,10,0,10">
<ScrollViewer Height="200" Width="200">
<StackPanel Spacing="20">
<TextBlock Foreground="Black">Item 1</TextBlock>
<TextBlock Foreground="Black">Item 2</TextBlock>
<TextBlock Foreground="Black">Item 3</TextBlock>
<TextBlock Foreground="Black">Item 4</TextBlock>
<TextBlock Foreground="Black">Item 5</TextBlock>
<TextBlock Foreground="Black">Item 6</TextBlock>
<TextBlock Foreground="Black">Item 7</TextBlock>
<TextBlock Foreground="Black">Item 8</TextBlock>
<TextBlock Foreground="Black">Item 9</TextBlock>
</StackPanel>
</ScrollViewer>
</Border>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Dark">
<Border Background="Black" Padding="10,10,0,10">
<ScrollViewer Height="200" Width="200">
<StackPanel Spacing="20">
<TextBlock>Item 1</TextBlock>
<TextBlock>Item 2</TextBlock>
<TextBlock>Item 3</TextBlock>
<TextBlock>Item 4</TextBlock>
<TextBlock>Item 5</TextBlock>
<TextBlock>Item 6</TextBlock>
<TextBlock>Item 7</TextBlock>
<TextBlock>Item 8</TextBlock>
<TextBlock>Item 9</TextBlock>
</StackPanel>
</ScrollViewer>
</Border>
</ThemeVariantScope>
</StackPanel>
<StackPanel Spacing="10">
<ThemeVariantScope RequestedThemeVariant="Light">
<Border Background="White" Padding="10,10,10,0">
<ScrollViewer
HorizontalAlignment="Left"
HorizontalContentAlignment="Left"
HorizontalScrollBarVisibility="Visible"
Width="410">
<StackPanel Orientation="Horizontal" Spacing="20">
<TextBlock Foreground="Black">Item 1</TextBlock>
<TextBlock Foreground="Black">Item 2</TextBlock>
<TextBlock Foreground="Black">Item 3</TextBlock>
<TextBlock Foreground="Black">Item 4</TextBlock>
<TextBlock Foreground="Black">Item 5</TextBlock>
<TextBlock Foreground="Black">Item 6</TextBlock>
<TextBlock Foreground="Black">Item 7</TextBlock>
<TextBlock Foreground="Black">Item 8</TextBlock>
<TextBlock Foreground="Black">Item 9</TextBlock>
</StackPanel>
</ScrollViewer>
</Border>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Dark">
<Border Background="Black" Padding="10,10,10,0">
<ScrollViewer
HorizontalAlignment="Left"
HorizontalContentAlignment="Left"
HorizontalScrollBarVisibility="Visible"
Width="410">
<StackPanel Orientation="Horizontal" Spacing="20">
<TextBlock>Item 1</TextBlock>
<TextBlock>Item 2</TextBlock>
<TextBlock>Item 3</TextBlock>
<TextBlock>Item 4</TextBlock>
<TextBlock>Item 5</TextBlock>
<TextBlock>Item 6</TextBlock>
<TextBlock>Item 7</TextBlock>
<TextBlock>Item 8</TextBlock>
<TextBlock>Item 9</TextBlock>
</StackPanel>
</ScrollViewer>
</Border>
</ThemeVariantScope>
</StackPanel>
</StackPanel>
</Panel>
</Design.PreviewWith>
<Style Selector="ScrollViewer">
<Setter Property="Background" Value="Transparent" />
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="HorizontalScrollBarVisibility" Value="Auto" />
<Setter Property="AllowAutoHide" Value="False" />
<Setter Property="behaviors:SmoothScrollBehavior.SmoothScroll" Value="True" />
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
<ScrollContentPresenter
Background="{TemplateBinding Background}"
HorizontalSnapPointsAlignment="{TemplateBinding HorizontalSnapPointsAlignment}"
HorizontalSnapPointsType="{TemplateBinding HorizontalSnapPointsType}"
Name="PART_ContentPresenter"
Padding="{TemplateBinding Padding}"
ScrollViewer.IsScrollInertiaEnabled="{TemplateBinding IsScrollInertiaEnabled}"
VerticalSnapPointsAlignment="{TemplateBinding VerticalSnapPointsAlignment}"
VerticalSnapPointsType="{TemplateBinding VerticalSnapPointsType}">
<ScrollContentPresenter.GestureRecognizers>
<ScrollGestureRecognizer
CanHorizontallyScroll="{Binding CanHorizontallyScroll, ElementName=PART_ContentPresenter}"
CanVerticallyScroll="{Binding CanVerticallyScroll, ElementName=PART_ContentPresenter}"
IsScrollInertiaEnabled="{Binding (ScrollViewer.IsScrollInertiaEnabled), ElementName=PART_ContentPresenter}" />
</ScrollContentPresenter.GestureRecognizers>
</ScrollContentPresenter>
<ScrollBar
Grid.Column="0"
Grid.Row="1"
Margin="0,5,0,0"
Name="PART_HorizontalScrollBar"
Orientation="Horizontal" />
<ScrollBar
Grid.Column="1"
Grid.Row="0"
Margin="5,0,0,0"
Name="PART_VerticalScrollBar"
Orientation="Vertical" />
</Grid>
</ControlTemplate>
</Setter>
<Style Selector="^.main">
<Setter Property="HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="Template">
<ControlTemplate>
<Grid>
<ScrollContentPresenter
Background="{TemplateBinding Background}"
HorizontalSnapPointsAlignment="{TemplateBinding HorizontalSnapPointsAlignment}"
HorizontalSnapPointsType="{TemplateBinding HorizontalSnapPointsType}"
Name="PART_ContentPresenter"
Padding="{TemplateBinding Padding}"
ScrollViewer.IsScrollInertiaEnabled="{TemplateBinding IsScrollInertiaEnabled}"
VerticalSnapPointsAlignment="{TemplateBinding VerticalSnapPointsAlignment}"
VerticalSnapPointsType="{TemplateBinding VerticalSnapPointsType}">
<ScrollContentPresenter.GestureRecognizers>
<ScrollGestureRecognizer
CanHorizontallyScroll="{Binding CanHorizontallyScroll, ElementName=PART_ContentPresenter}"
CanVerticallyScroll="{Binding CanVerticallyScroll, ElementName=PART_ContentPresenter}"
IsScrollInertiaEnabled="{Binding (ScrollViewer.IsScrollInertiaEnabled), ElementName=PART_ContentPresenter}" />
</ScrollContentPresenter.GestureRecognizers>
</ScrollContentPresenter>
<ScrollBar
HorizontalAlignment="Right"
Margin="0,0,0,6"
Name="PART_VerticalScrollBar"
Orientation="Vertical" />
</Grid>
</ControlTemplate>
</Setter>
</Style>
</Style>
<Style Selector="Window[(design|NitroxAttached.UseCustomTitleBar)=True] ScrollViewer.main /template/ ScrollBar#PART_VerticalScrollBar">
<Setter Property="Margin" Value="0 30 0 6" />
</Style>
<Style Selector="ScrollBar">
<Setter Property="MinWidth" Value="{DynamicResource ScrollBarSize}" />
<Setter Property="MinHeight" Value="{DynamicResource ScrollBarSize}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="AllowAutoHide" Value="False" />
<Style Selector="^:vertical">
<Setter Property="Template">
<ControlTemplate>
<Grid x:Name="Root">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
x:Name="VerticalRoot">
<Track
IsDirectionReversed="True"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Orientation="{TemplateBinding Orientation}"
Value="{TemplateBinding Value,
Mode=TwoWay}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.DecreaseButton>
<RepeatButton Focusable="False" Name="PART_PageUpButton" />
</Track.DecreaseButton>
<Track.IncreaseButton>
<RepeatButton Focusable="False" Name="PART_PageDownButton" />
</Track.IncreaseButton>
<Thumb
MinHeight="8"
RenderTransform="{DynamicResource VerticalSmallScrollThumbScaleTransform}"
RenderTransformOrigin="100%,50%"
Width="8" />
</Track>
</Border>
</Grid>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="^:horizontal">
<Setter Property="Template">
<ControlTemplate>
<Grid x:Name="Root">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
x:Name="HorizontalRoot">
<Track
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Orientation="{TemplateBinding Orientation}"
Value="{TemplateBinding Value,
Mode=TwoWay}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.DecreaseButton>
<RepeatButton Focusable="False" Name="PART_PageUpButton" />
</Track.DecreaseButton>
<Track.IncreaseButton>
<RepeatButton Focusable="False" Name="PART_PageDownButton" />
</Track.IncreaseButton>
<Thumb
Height="8"
MinWidth="8"
RenderTransform="{DynamicResource HorizontalSmallScrollThumbScaleTransform}"
RenderTransformOrigin="50%,100%" />
</Track>
</Border>
</Grid>
</ControlTemplate>
</Setter>
</Style>
</Style>
<Style Selector="Thumb">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<Border Background="{DynamicResource BrandScrollThumb}" CornerRadius="4" />
</ControlTemplate>
</Setter.Value>
</Setter>
<Style Selector="^ /template/ Border">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Duration="0:0:0.10" Property="Background" />
</Transitions>
</Setter>
<Style Selector="^:pointerover">
<Setter Property="Background" Value="{DynamicResource BrandScrollThumbPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed /template/ Border">
<Setter Property="Background" Value="{DynamicResource BrandScrollThumbPressed}" />
</Style>
<!--<Style Selector="^[(design|NitroxAttached.Theme)=LIGHT]">
</Style>
<Style Selector="^[(design|NitroxAttached.Theme)=DARK]">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<Border Background="#262626"
CornerRadius="4"/>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style Selector="^:pointerover /template/ Border">
<Setter Property="Background" Value="#3f3f3f" />
</Style>
<Style Selector="^:pressed /template/ Border">
<Setter Property="Background" Value="#8f8f8f" />
</Style>
</Style>-->
</Style>
<Style Selector="RepeatButton">
<Setter Property="Background" Value="Transparent" />
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Opacity" Value="0" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}" />
</ControlTemplate>
</Setter>
</Style>
</Styles>

View File

@@ -0,0 +1,38 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<StackPanel Spacing="10">
<TextBlock Text="I'm a textblock ! 0123456789" />
<TextBlock Classes="link" Text="I'm a textblock ! 0123456789" />
<TextBlock Classes="header" Text="I'm a textblock ! 0123456789" />
<TextBlock Classes="modalHeader" Text="I'm a textblock ! 0123456789" />
</StackPanel>
</Design.PreviewWith>
<Style Selector="TextBlock">
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="SelectableTextBlock">
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.link">
<Setter Property="Foreground" Value="{DynamicResource BrandPrimary}" />
<Setter Property="Cursor" Value="Hand" />
<Style Selector="^:pointerover">
<Setter Property="TextDecorations" Value="Underline" />
</Style>
</Style>
<Style Selector="TextBlock.header">
<Setter Property="FontSize" Value="32" />
<Setter Property="FontWeight" Value="700" />
</Style>
<Style Selector="TextBlock.modalHeader">
<Setter Property="FontSize" Value="24" />
<Setter Property="FontWeight" Value="700" />
</Style>
</Styles>

View File

@@ -0,0 +1,167 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:Nitrox.Launcher.Models.Converters">
<Design.PreviewWith>
<Panel Background="CornflowerBlue">
<StackPanel
Margin="10"
Orientation="Horizontal"
Spacing="20">
<StackPanel Spacing="10">
<ThemeVariantScope RequestedThemeVariant="Light">
<Border
Background="White"
HorizontalAlignment="Center"
Padding="10">
<StackPanel Spacing="10" Width="200">
<TextBox HorizontalAlignment="Stretch" Watermark="Watermark" />
<TextBox HorizontalAlignment="Stretch" Text="Light" />
<TextBox
Classes="revealPasswordButton"
HorizontalAlignment="Stretch"
Text="Light" />
</StackPanel>
</Border>
</ThemeVariantScope>
<ThemeVariantScope RequestedThemeVariant="Dark">
<Border
Background="Black"
HorizontalAlignment="Center"
Padding="10">
<StackPanel Spacing="10" Width="200">
<TextBox HorizontalAlignment="Stretch" Watermark="Watermark" />
<TextBox HorizontalAlignment="Stretch" Text="Dark" />
<TextBox
Classes="revealPasswordButton"
HorizontalAlignment="Stretch"
Text="Dark" />
</StackPanel>
</Border>
</ThemeVariantScope>
</StackPanel>
</StackPanel>
</Panel>
</Design.PreviewWith>
<Style Selector="TextBox">
<Setter Property="Foreground" Value="{DynamicResource BrandBlack}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="14" />
<Setter Property="Background" Value="{DynamicResource BrandControlBackground}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CaretBrush" Value="{DynamicResource BrandCaret}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Style Selector="^ /template/ Border">
<Setter Property="Background" Value="{TemplateBinding Background}" />
<Setter Property="CornerRadius" Value="{TemplateBinding CornerRadius}" />
<Setter Property="BorderThickness" Value="{TemplateBinding BorderThickness}" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Duration="0:0:0.15" Property="Opacity" />
</Transitions>
</Setter>
<Style Selector="^:pointerover">
<Setter Property="Background" Value="{TemplateBinding Background}" />
<Setter Property="Opacity" Value=".75" />
</Style>
<Style Selector="^:focus">
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
</Style>
<!-- Remove base border (which removes default Avalonia border brush styles) -->
<Style Selector="^ /template/ Border#PART_BorderElement">
<Setter Property="Opacity" Value="0" />
</Style>
<!-- Fix cursor selection of textbox (weird issue with DockPanel#PART_InnerDockPanel still keeping margin) -->
<Style Selector="^ /template/ Panel > Border > Grid"> <!-- Most senior grid with negative margin to counteract InnerDockPanel -->
<Setter Property="Margin" Value="{Binding Padding, RelativeSource={RelativeSource TemplatedParent}, Converter={converters:TextBoxPaddingToMarginConverter}, ConverterParameter=True}" />
</Style>
<Style Selector="^ /template/ TextBlock#PART_Watermark"> <!-- Watermark text -->
<Setter Property="Margin" Value="{Binding Padding, RelativeSource={RelativeSource TemplatedParent}, Converter={converters:TextBoxPaddingToMarginConverter}}" />
</Style>
<Style Selector="^ /template/ TextPresenter#PART_TextPresenter"> <!-- Text -->
<Setter Property="Margin" Value="{Binding Padding, RelativeSource={RelativeSource TemplatedParent}, Converter={converters:TextBoxPaddingToMarginConverter}}" />
</Style>
<Style Selector="^ /template/ Border > Grid > ContentPresenter:nth-child(3)"> <!-- Reveal Password Button -->
<Setter Property="Margin" Value="{Binding Padding, RelativeSource={RelativeSource TemplatedParent}, Converter={converters:TextBoxPaddingToMarginConverter}}" />
</Style>
<!-- Watermark foreground dimming -->
<Style Selector="^ /template/ TextBlock#PART_Watermark">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Duration="0:0:0.15" Property="Foreground" />
</Transitions>
</Setter>
</Style>
<!-- Disabled Style -->
<Style Selector="^:disabled">
<Style Selector="^ /template/ Border">
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
<Setter Property="Foreground" Value="{TemplateBinding Foreground}" />
</Style>
<!-- Reveal Password Button syle -->
<Style Selector="^.revealPasswordButton[AcceptsReturn=False][IsReadOnly=False]:not(TextBox:empty)">
<Setter Property="PasswordChar" Value="●" />
<Setter Property="InnerRightContent">
<Template>
<ToggleButton
Focusable="True"
IsChecked="{Binding $parent[TextBox].RevealPassword, Mode=TwoWay}"
Width="35">
<Grid>
<!-- TODO: Replace these with image icons -->
<PathIcon
Data="m10.051 7.0032c2.215 0 4.0105 1.7901 4.0105 3.9984s-1.7956 3.9984-4.0105 3.9984c-2.215 0-4.0105-1.7901-4.0105-3.9984s1.7956-3.9984 4.0105-3.9984zm0 1.4994c-1.3844 0-2.5066 1.1188-2.5066 2.499s1.1222 2.499 2.5066 2.499 2.5066-1.1188 2.5066-2.499-1.1222-2.499-2.5066-2.499zm0-5.0026c4.6257 0 8.6188 3.1487 9.7267 7.5613 0.10085 0.40165-0.14399 0.80877-0.54686 0.90931-0.40288 0.10054-0.81122-0.14355-0.91208-0.54521-0.94136-3.7492-4.3361-6.4261-8.2678-6.4261-3.9334 0-7.3292 2.6792-8.2689 6.4306-0.10063 0.40171-0.50884 0.64603-0.91177 0.54571s-0.648-0.5073-0.54737-0.90901c1.106-4.4152 5.1003-7.5667 9.728-7.5667z"
Height="8"
IsVisible="{Binding !$parent[ToggleButton].IsChecked}"
Width="12" />
<PathIcon
Data="m0.21967 0.21965c-0.26627 0.26627-0.29047 0.68293-0.07262 0.97654l0.07262 0.08412 4.0346 4.0346c-1.922 1.3495-3.3585 3.365-3.9554 5.7495-0.10058 0.4018 0.14362 0.8091 0.54543 0.9097 0.40182 0.1005 0.80909-0.1436 0.90968-0.5455 0.52947-2.1151 1.8371-3.8891 3.5802-5.0341l1.8096 1.8098c-0.70751 0.7215-1.1438 1.71-1.1438 2.8003 0 2.2092 1.7909 4 4 4 1.0904 0 2.0788-0.4363 2.8004-1.1438l5.9193 5.9195c0.2929 0.2929 0.7677 0.2929 1.0606 0 0.2663-0.2662 0.2905-0.6829 0.0726-0.9765l-0.0726-0.0841-6.1135-6.1142 0.0012-0.0015-1.2001-1.1979-2.8699-2.8693 2e-3 -8e-4 -2.8812-2.8782 0.0012-0.0018-1.1333-1.1305-4.3064-4.3058c-0.29289-0.29289-0.76777-0.29289-1.0607 0zm7.9844 9.0458 3.5351 3.5351c-0.45 0.4358-1.0633 0.704-1.7392 0.704-1.3807 0-2.5-1.1193-2.5-2.5 0-0.6759 0.26824-1.2892 0.7041-1.7391zm1.7959-5.7655c-1.0003 0-1.9709 0.14807-2.8889 0.425l1.237 1.2362c0.5358-0.10587 1.0883-0.16119 1.6519-0.16119 3.9231 0 7.3099 2.6803 8.2471 6.4332 0.1004 0.4018 0.5075 0.6462 0.9094 0.5459 0.4019-0.1004 0.6463-0.5075 0.5459-0.9094-1.103-4.417-5.0869-7.5697-9.7024-7.5697zm0.1947 3.5093 3.8013 3.8007c-0.1018-2.0569-1.7488-3.7024-3.8013-3.8007z"
Height="12"
IsVisible="{Binding $parent[ToggleButton].IsChecked}"
Width="12" />
</Grid>
<ToggleButton.Styles>
<Style Selector="ToggleButton">
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Duration="0:0:0.15" Property="Opacity" />
<TransformOperationsTransition Duration="0:0:.075" Property="RenderTransform" />
</Transitions>
</Setter>
<Setter Property="Background" Value="Transparent" />
<Style Selector="^:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Opacity" Value=".5" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="^:checked /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="^:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="RenderTransform" Value="scale(0.95)" />
</Style>
<Style Selector="^:disabled /template/ ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="{Binding $parent[Button].Foreground}" />
<Setter Property="Background" Value="{Binding $parent[Button].Background}" />
</Style>
</Style>
</ToggleButton.Styles>
</ToggleButton>
</Template>
</Setter>
</Style>
</Style>
</Styles>

View File

@@ -0,0 +1,29 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Border Padding="20">
<StackPanel Spacing="20">
<ToolTip Opacity="1">Text Content</ToolTip>
<ToolTip Opacity="1">Very long text content which should exceed the maximum with of the tooltip and wrap.</ToolTip>
<ToolTip Opacity="1">
<StackPanel>
<TextBlock>Multi-line</TextBlock>
<TextBlock>Control Content</TextBlock>
</StackPanel>
</ToolTip>
</StackPanel>
</Border>
</Design.PreviewWith>
<Style Selector="ToolTip">
<Setter Property="IsHitTestVisible" Value="False" />
<Style Selector="^ Border">
<Setter Property="Opacity" Value="0.9" />
</Style>
<Style Selector="^ TextBlock">
<Setter Property="FontWeight" Value="600" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{DynamicResource BrandBlack}" />
</Style>
</Style>
</Styles>

View File

@@ -0,0 +1,63 @@
<Styles
xmlns="https://github.com/avaloniaui"
xmlns:design="clr-namespace:Nitrox.Launcher.Models.Design"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Code from: https://docs.avaloniaui.net/docs/data-binding/data-validation (except for colors) -->
<Style Selector="DataValidationErrors">
<Setter Property="Template">
<ControlTemplate>
<DockPanel LastChildFill="True">
<ContentControl
Content="{Binding (DataValidationErrors.Errors)}"
ContentTemplate="{TemplateBinding ErrorTemplate}"
DataContext="{TemplateBinding Owner}"
DockPanel.Dock="Right">
<!-- Don't show error on fields immediately on load, wait for user input (e.g. user is trying to skip a field with invalid value) -->
<ContentControl.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="(DataValidationErrors.HasErrors)" />
<Binding Path="(design:NitroxAttached.HasUserInteracted)" />
</MultiBinding>
</ContentControl.IsVisible>
</ContentControl>
<ContentPresenter
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
CornerRadius="{TemplateBinding CornerRadius}"
Name="PART_ContentPresenter"
Padding="{TemplateBinding Padding}" />
</DockPanel>
</ControlTemplate>
</Setter>
<Setter Property="ErrorTemplate">
<DataTemplate x:DataType="{x:Type x:Object}">
<Canvas
Background="Transparent"
Height="14"
Margin="4,0"
Width="14">
<Canvas.Styles>
<Style Selector="ToolTip">
<Setter Property="Background" Value="{DynamicResource BrandAbortBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource BrandAbort}" />
<Style Selector="^ TextBlock">
<Setter Property="Foreground" Value="{DynamicResource BrandWhite}" />
</Style>
</Style>
</Canvas.Styles>
<ToolTip.Tip>
<ItemsControl ItemsSource="{Binding}" />
</ToolTip.Tip>
<Path
Data="M14,7 A7,7 0 0,0 0,7 M0,7 A7,7 0 1,0 14,7 M7,3l0,5 M7,9l0,2"
Stroke="{DynamicResource BrandAbort}"
StrokeThickness="2" />
</Canvas>
</DataTemplate>
</Setter>
</Style>
</Styles>

View File

@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using Avalonia.Platform;
namespace Nitrox.Launcher.Models.Utils;
public static class AssetHelper
{
private static readonly string assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? throw new Exception("Unable to get Assembly name");
private static readonly Dictionary<string, Uri> assetPathCache = [];
public static Uri GetFullAssetPath(string assetPath)
{
if (assetPathCache.TryGetValue(assetPath, out Uri fullPath))
{
return fullPath;
}
Uri uri = assetPath.StartsWith("avares://") ? new Uri(assetPath) : new Uri($"avares://{assemblyName}{assetPath}");
if (!AssetLoader.Exists(uri) && !Avalonia.Controls.Design.IsDesignMode)
{
return assetPathCache[assetPath] = default;
}
return assetPathCache[assetPath] = uri;
}
public static T GetAssetFromStream<T>(string assetPath, Func<Stream, T> streamToDataFactory) => AssetLoader<T>.GetFromStream(assetPath, streamToDataFactory);
private static class AssetLoader<T>
{
private static readonly Dictionary<string, T> assetCache = [];
private static readonly Lock assetCacheLock = new();
public static T GetFromStream(string rawUri, Func<Stream, T> streamToDataFactory)
{
T data;
lock (assetCacheLock)
{
if (assetCache.TryGetValue(rawUri, out data))
{
return data;
}
}
// In design mode, resource aren't yet embedded.
if (Avalonia.Controls.Design.IsDesignMode)
{
using Stream stream = File.OpenRead(TryGetPathFromLocalFileSystem(rawUri));
data = streamToDataFactory(stream);
}
if (data == null)
{
using Stream stream = AssetLoader.Open(GetFullAssetPath(rawUri));
data = streamToDataFactory(stream);
}
lock (assetCacheLock)
{
assetCache.Add(rawUri, data);
}
return data;
}
private static string TryGetPathFromLocalFileSystem(string fileUri)
{
string targetedProject = Path.GetDirectoryName(Environment.GetCommandLineArgs().FirstOrDefault(part => !part.Contains("Designer", StringComparison.Ordinal) && part.EndsWith("dll", StringComparison.OrdinalIgnoreCase) && File.Exists(part)));
while (targetedProject != null && !Directory.EnumerateFileSystemEntries(targetedProject, "*.csproj", SearchOption.TopDirectoryOnly).Any())
{
targetedProject = Path.GetDirectoryName(targetedProject);
}
if (targetedProject == null)
{
return null;
}
ReadOnlySpan<char> fileUriSpan = fileUri.AsSpan();
while (fileUriSpan.StartsWith("/") || fileUriSpan.StartsWith("\\"))
{
fileUriSpan = fileUriSpan[1..];
}
return Path.Combine(targetedProject, fileUriSpan.ToString());
}
}
}

View File

@@ -0,0 +1,131 @@
using System;
using System.IO;
using System.Threading.Tasks;
namespace Nitrox.Launcher.Models.Utils;
public class CacheFile
{
private DateTimeOffset? creationTime;
public string FileName { get; init; }
public string TempFilePath => Path.Combine(Path.GetTempPath(), FileName);
public DateTimeOffset? CreationTime
{
get
{
if (creationTime == null && File.Exists(TempFilePath))
{
using FileStream stream = File.OpenRead(TempFilePath);
if (stream.Length < 8)
{
return null;
}
Span<byte> buffer = stackalloc byte[8];
stream.ReadExactly(buffer);
creationTime = DateTimeOffset.FromUnixTimeSeconds(BitConverter.ToInt64(buffer));
}
return creationTime;
}
}
public CacheFile(string fileName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
FileName = $"nitrox_{fileName.Trim()}.cache";
}
/// <summary>
/// Gets the cached data if not old or refreshes the cache using the <see cref="refreshedValueFactory"/>.
/// </summary>
public static async Task<T> GetOrRefreshAsync<T>(string name, Func<ValueReader, T> reader, Action<BinaryWriter, T> writer, Func<Task<T>> refreshedValueFactory = null, TimeSpan age = default)
{
if (age == default)
{
age = TimeSpan.FromDays(1);
}
CacheFile file = new(name);
if (writer != null && (file.CreationTime == null || DateTimeOffset.UtcNow - file.CreationTime >= age))
{
await using BinaryWriter binaryWriter = new(File.Create(file.TempFilePath));
binaryWriter.Write(BitConverter.GetBytes(DateTimeOffset.UtcNow.ToUnixTimeSeconds()));
T newValue = refreshedValueFactory == null ? default : await refreshedValueFactory();
writer(binaryWriter, newValue);
return newValue;
}
using ValueReader valueReader = new(file.GetStream());
T readerResult = reader(valueReader);
if (valueReader.ReachedEarlyEnd)
{
return refreshedValueFactory == null ? default : await refreshedValueFactory();
}
return readerResult;
}
private BinaryReader GetStream()
{
BinaryReader reader = new(File.OpenRead(TempFilePath));
reader.ReadInt64(); // file creation in unix time
return reader;
}
public class ValueReader : IDisposable
{
private readonly BinaryReader binaryReader;
public bool ReachedEarlyEnd { get; private set; }
public ValueReader(BinaryReader binaryReader)
{
this.binaryReader = binaryReader;
}
public T Read<T>(T defaultValue = default)
{
static T InnerRead<T2>(ValueReader reader, Func<BinaryReader, T2> read, T defaultValue = default)
{
try
{
return (T)(object)read(reader.binaryReader);
}
catch (EndOfStreamException)
{
reader.ReachedEarlyEnd = true;
return defaultValue;
}
catch
{
return defaultValue;
}
}
// Return default values for future reads when end of file (EOF).
if (ReachedEarlyEnd)
{
return defaultValue;
}
Type requestedType = typeof(T);
if (requestedType == typeof(int))
{
return InnerRead(this, reader => reader.ReadInt32(), defaultValue);
}
if (requestedType == typeof(string))
{
return InnerRead(this, reader => reader.ReadString(), defaultValue);
}
if (requestedType == typeof(byte[]))
{
return InnerRead(this, reader =>
{
int dataSize = reader.ReadInt32();
return reader.ReadBytes(dataSize);
}, defaultValue);
}
throw new NotSupportedException($"Type: '{requestedType}' is not yet supported to be read from cache files");
}
public void Dispose() => binaryReader?.Dispose();
}
}

View File

@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using LitJson;
using Nitrox.Launcher.Models.Design;
using NitroxModel.Logger;
namespace Nitrox.Launcher.Models.Utils;
public partial class Downloader
{
public const string BLOGS_URL = "https://nitroxblog.rux.gg/wp-json/wp/v2/posts?per_page=8&page=1";
public const string LATEST_VERSION_URL = "https://nitrox.rux.gg/api/version/latest";
public const string CHANGELOGS_URL = "https://nitrox.rux.gg/api/changelog/releases";
public const string RELEASES_URL = "https://nitrox.rux.gg/api/version/releases";
[GeneratedRegex(@"""version"":""([^""]*)""")]
private static partial Regex JsonVersionFieldRegex { get; }
public static async Task<IList<NitroxBlog>> GetBlogsAsync()
{
IList<NitroxBlog> blogs = new List<NitroxBlog>();
try
{
string jsonString = await CacheFile.GetOrRefreshAsync("blogs",
r => r.Read(""),
(w, v) => w.Write(v),
async () =>
{
using HttpResponseMessage response = await GetResponseFromCacheAsync(BLOGS_URL);
return await response.Content.ReadAsStringAsync();
});
JsonData data = JsonMapper.ToObject(jsonString);
// TODO : Add a json schema validator
for (int i = 0; i < data.Count; i++)
{
string released = (string)data[i]["date"];
string url = (string)data[i]["link"];
string title = WebUtility.HtmlDecode((string)data[i]["title"]["rendered"]);
string imageUrl = (string)data[i]["jetpack_featured_media_url"];
string imageCacheName = $"blogimage_{title.ReplaceInvalidFileNameCharacters().ToLowerInvariant()}";
if (!DateTimeOffset.TryParse(released, out DateTimeOffset dateTime))
{
dateTime = DateTimeOffset.UtcNow;
Log.Error($"Error while trying to parse release time ({released}) of blog {url}");
}
else
{
imageCacheName = $"blogimage_{dateTime.ToUnixTimeSeconds()}";
}
// Get image bitmap from image URL
byte[] imageData = await CacheFile.GetOrRefreshAsync(imageCacheName,
r => r.Read<byte[]>(),
(w, v) =>
{
w.Write(v.Length);
w.Write(v);
},
async () =>
{
HttpResponseMessage imageResponse = await GetResponseFromCacheAsync(imageUrl);
return await imageResponse.Content.ReadAsByteArrayAsync();
});
using MemoryStream imageMemoryStream = new(imageData);
Bitmap image = new(imageMemoryStream);
blogs.Add(new NitroxBlog(title, DateOnly.FromDateTime(dateTime.DateTime), url, image));
}
}
catch (Exception ex)
{
Log.Error(ex, $"{nameof(Downloader)} : Error while fetching Nitrox blogs from {BLOGS_URL}");
LauncherNotifier.Error("Unable to fetch Nitrox blogs");
}
return blogs;
}
public static async Task<IList<NitroxChangelog>> GetChangeLogsAsync()
{
IList<NitroxChangelog> changelogs = new List<NitroxChangelog>();
try
{
//https://developer.wordpress.org/rest-api/reference/posts/#arguments
string jsonString = await CacheFile.GetOrRefreshAsync("changelogs",
r => r.Read(""),
(w, v) => w.Write(v),
async () =>
{
using HttpResponseMessage response = await GetResponseFromCacheAsync(CHANGELOGS_URL);
return await response.Content.ReadAsStringAsync();
});
StringBuilder builder = new();
JsonData data = JsonMapper.ToObject(jsonString);
// TODO : Add a json schema validator
for (int i = 0; i < data.Count; i++)
{
string version = (string)data[i]["version"];
string released = (string)data[i]["released"];
JsonData patchnotes = data[i]["patchnotes"];
if (!DateTime.TryParse(released, out DateTime dateTime))
{
dateTime = DateTime.UtcNow;
Log.Error($"Error while trying to parse release time ({released}) of Nitrox v{version}");
}
builder.Clear();
for (int j = 0; j < patchnotes.Count; j++)
{
if (patchnotes[j].ToString().StartsWith('-'))
{
builder.AppendLine($"\n[b][u]{patchnotes[j].ToString().TrimStart('-', ' ')}[/u][/b]");
}
else
{
builder.AppendLine($"• {(string)patchnotes[j]}");
}
}
changelogs.Add(new NitroxChangelog(version, dateTime, builder.ToString()));
}
}
catch (Exception ex)
{
Log.Error(ex, $"{nameof(Downloader)} : Error while fetching Nitrox changelogs from {CHANGELOGS_URL}");
LauncherNotifier.Error("Unable to fetch Nitrox changelogs");
}
return changelogs;
}
public static async Task<Version> GetNitroxLatestVersionAsync()
{
try
{
string jsonString = await CacheFile.GetOrRefreshAsync("update",
r => r.Read(""),
(w, v) => w.Write(v),
async () =>
{
using HttpResponseMessage response = await GetResponseFromCacheAsync(LATEST_VERSION_URL);
return await response.Content.ReadAsStringAsync();
});
Match match = JsonVersionFieldRegex.Match(jsonString);
if (match.Success && match.Groups.Count > 1)
{
return new Version(match.Groups[1].Value);
}
}
catch (Exception ex)
{
Log.Error(ex, $"{nameof(Downloader)} : Error while fetching Nitrox version from {LATEST_VERSION_URL}");
LauncherNotifier.Error("Unable to check for Nitrox updates");
throw;
}
return new Version();
}
private static async Task<HttpResponseMessage> GetResponseFromCacheAsync(string url)
{
Log.Info($"Trying to request data from {url}");
using HttpClient client = new();
client.DefaultRequestHeaders.UserAgent.ParseAdd("Nitrox.Launcher");
client.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromDays(1) };
client.Timeout = TimeSpan.FromSeconds(5);
try
{
return await client.GetAsync(url);
}
catch (Exception ex)
{
Log.Error(ex, $"Error while requesting data from {url}");
}
return null;
}
}

View File

@@ -0,0 +1,67 @@
using System;
using System.IO;
using System.Threading.Tasks;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.ViewModels;
using NitroxModel.Helper;
using NitroxModel.Logger;
using NitroxModel.Platforms.OS.Shared;
namespace Nitrox.Launcher.Models.Utils;
internal static class GameInspect
{
/// <summary>
/// Check to ensure the Subnautica is not in legacy.
/// </summary>
public static async Task<bool> IsOutdatedGameAndNotify(string gameInstallDir, IDialogService dialogService = null)
{
try
{
ArgumentException.ThrowIfNullOrWhiteSpace(gameInstallDir);
string gameVersionFile = Path.Combine(gameInstallDir, GameInfo.Subnautica.DataFolder, "StreamingAssets", "SNUnmanagedData", "plastic_status.ignore");
if (int.TryParse(await File.ReadAllTextAsync(gameVersionFile), out int gameVersion) && gameVersion <= 68598)
{
if (dialogService != null)
{
await dialogService.ShowAsync<DialogBoxViewModel>(model =>
{
model.Title = "Legacy Game Detected";
model.Description = $"Nitrox does not support the legacy version of {GameInfo.Subnautica.FullName}. Please update your game to the latest version to run {GameInfo.Subnautica.FullName} with Nitrox.{Environment.NewLine}{Environment.NewLine}Version file location:{Environment.NewLine}{gameVersionFile}";
model.ButtonOptions = ButtonOptions.Ok;
});
}
return true;
}
}
catch (Exception ex)
{
Log.Error(ex, "Error while checking game version:");
LauncherNotifier.Debug(ex.Message);
// On error: ignore and assume it's not outdated in case of unforeseen changes. We don't want to block users.
return false;
}
return false;
}
/// <summary>
/// Checks game is running and if it is, warns. Does nothing in development mode for debugging purposes.
/// </summary>
public static bool WarnIfGameProcessExists(GameInfo game)
{
if (!NitroxEnvironment.IsReleaseMode)
{
return false;
}
if (!ProcessEx.ProcessExists(game.Name))
{
return false;
}
LauncherNotifier.Warning($"An instance of {game.FullName} is already running");
return true;
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Avalonia.Controls.Notifications;
using CommunityToolkit.Mvvm.Messaging;
using Nitrox.Launcher.Models.Design;
namespace Nitrox.Launcher.Models.Utils;
public static class LauncherNotifier
{
public static void Error(string message)
{
WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem(message, NotificationType.Error)));
}
public static void Info(string message)
{
WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem(message)));
}
public static void Warning(string message)
{
WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem(message, NotificationType.Warning)));
}
public static void Success(string message)
{
WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem(message, NotificationType.Success)));
}
[Conditional("DEBUG")]
public static void Debug(string message, [CallerMemberName] string memberName = "")
{
WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem($"Error in '{memberName}':{Environment.NewLine}{message}", NotificationType.Success)));
}
}

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using dnlib.DotNet;
using dnlib.DotNet.Emit;
using NitroxModel.Logger;
using NitroxModel.Platforms.OS.Shared;
namespace Nitrox.Launcher.Models.Utils;
public static class NitroxEntryPatch
{
public const string GAME_ASSEMBLY_NAME = "Assembly-CSharp.dll";
public const string NITROX_ASSEMBLY_NAME = "NitroxPatcher.dll";
public const string GAME_ASSEMBLY_MODIFIED_NAME = "Assembly-CSharp-Nitrox.dll";
private const string NITROX_ENTRY_TYPE_NAME = "Main";
private const string NITROX_ENTRY_METHOD_NAME = "Execute";
private const string GAME_INPUT_TYPE_NAME = "GameInput";
private const string GAME_INPUT_METHOD_NAME = "Awake";
private const string NITROX_EXECUTE_INSTRUCTION = "System.Void NitroxPatcher.Main::Execute()";
/// <summary>
/// Inject Nitrox entry point into Subnautica's Assembly-CSharp.dll
/// </summary>
public static void Apply(string subnauticaBasePath)
{
ArgumentException.ThrowIfNullOrEmpty(subnauticaBasePath, nameof(subnauticaBasePath));
Log.Debug("Adding Nitrox entry point to Subnautica");
string subnauticaManagedPath = Path.Combine(subnauticaBasePath, GameInfo.Subnautica.DataFolder, "Managed");
string assemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_NAME);
string nitroxPatcherPath = Path.Combine(subnauticaManagedPath, NITROX_ASSEMBLY_NAME);
string modifiedAssemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_MODIFIED_NAME);
if (File.Exists(modifiedAssemblyCSharp))
{
// Avoid the case where AssemblyCSharp.dll get wiped and the only file left is AssemblyCSharp-Nitrox.dll
if (!File.Exists(assemblyCSharp))
{
Log.Error($"Invalid state, {GAME_ASSEMBLY_NAME} not found, but {GAME_ASSEMBLY_MODIFIED_NAME} exists. Please verify your installation.");
FileSystem.Instance.ReplaceFile(modifiedAssemblyCSharp, assemblyCSharp);
}
else
{
Log.Debug($"{GAME_ASSEMBLY_MODIFIED_NAME} already exists, removing it");
Exception copyError = RetryWait(() => File.Delete(modifiedAssemblyCSharp), 100, 5);
if (copyError != null)
{
throw copyError;
}
}
}
/*
private void Awake()
{
NitroxPatcher.Main.Execute(); <----------- Insert this line inside subnautica's code
if (GameInput.instance != null)
{
global::UnityEngine.Object.Destroy(base.gameObject);
return;
}
GameInput.instance = this;
GameInput.instance.Initialize();
for (int i = 0; i < GameInput.numDevices; i++)
{
GameInput.SetupDefaultBindings((GameInput.Device)i);
}
DevConsole.RegisterConsoleCommand(this, "debuginput", false, false);
}
*/
// TODO: Find a better way to inject Nitrox entrypoint instead of using file swapping
using (ModuleDefMD module = ModuleDefMD.Load(assemblyCSharp))
using (ModuleDefMD nitroxPatcherAssembly = ModuleDefMD.Load(nitroxPatcherPath))
{
TypeDef nitroxMainDefinition = nitroxPatcherAssembly.GetTypes().FirstOrDefault(x => x.Name == NITROX_ENTRY_TYPE_NAME);
MethodDef executeMethodDefinition = nitroxMainDefinition.Methods.FirstOrDefault(x => x.Name == NITROX_ENTRY_METHOD_NAME);
MemberRef executeMethodReference = module.Import(executeMethodDefinition);
TypeDef gameInputType = module.GetTypes().First(x => x.FullName == GAME_INPUT_TYPE_NAME);
MethodDef awakeMethod = gameInputType.Methods.First(x => x.Name == GAME_INPUT_METHOD_NAME);
Instruction callNitroxExecuteInstruction = OpCodes.Call.ToInstruction(executeMethodReference);
if (awakeMethod.Body.Instructions[0].Operand == callNitroxExecuteInstruction.Operand)
{
Log.Warn("Nitrox entry point already patched.");
return;
}
awakeMethod.Body.Instructions.Insert(0, callNitroxExecuteInstruction);
module.Write(modifiedAssemblyCSharp);
Log.Debug($"Writing assembly to {GAME_ASSEMBLY_MODIFIED_NAME}");
File.SetAttributes(assemblyCSharp, System.IO.FileAttributes.Normal);
}
// The assembly might be used by other code or some other program might work in it. Retry to be on the safe side.
Log.Debug($"Deleting {GAME_ASSEMBLY_NAME}");
Exception error = RetryWait(() => File.Delete(assemblyCSharp), 100, 5);
if (error != null)
{
throw error;
}
FileSystem.Instance.ReplaceFile(modifiedAssemblyCSharp, assemblyCSharp);
Log.Debug("Added Nitrox entry point to Subnautica");
}
/// <summary>
/// Remote Nitrox entry point from Subnautica's Assembly-CSharp.dll
/// </summary>
public static void Remove(string subnauticaBasePath)
{
ArgumentException.ThrowIfNullOrEmpty(subnauticaBasePath, nameof(subnauticaBasePath));
Log.Debug("Removing Nitrox entry point from Subnautica");
string subnauticaManagedPath = Path.Combine(subnauticaBasePath, GameInfo.Subnautica.DataFolder, "Managed");
string assemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_NAME);
string modifiedAssemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_MODIFIED_NAME);
using (ModuleDefMD module = ModuleDefMD.Load(assemblyCSharp))
{
TypeDef gameInputType = module.GetTypes().First(x => x.FullName == GAME_INPUT_TYPE_NAME);
MethodDef awakeMethod = gameInputType.Methods.First(x => x.Name == GAME_INPUT_METHOD_NAME);
IList<Instruction> methodInstructions = awakeMethod.Body.Instructions;
int nitroxExecuteInstructionIndex = FindNitroxExecuteInstructionIndex(methodInstructions);
if (nitroxExecuteInstructionIndex == -1)
{
Log.Debug($"Nitrox entry point not found in {GAME_INPUT_TYPE_NAME}:{GAME_INPUT_METHOD_NAME}");
return;
}
methodInstructions.RemoveAt(nitroxExecuteInstructionIndex);
module.Write(modifiedAssemblyCSharp);
File.SetAttributes(assemblyCSharp, System.IO.FileAttributes.Normal);
}
FileSystem.Instance.ReplaceFile(modifiedAssemblyCSharp, assemblyCSharp);
Log.Debug("Removed Nitrox entry point from Subnautica");
}
private static int FindNitroxExecuteInstructionIndex(IList<Instruction> methodInstructions)
{
for (int instructionIndex = 0; instructionIndex < methodInstructions.Count; instructionIndex++)
{
string instruction = methodInstructions[instructionIndex].Operand?.ToString();
if (instruction == NITROX_EXECUTE_INSTRUCTION)
{
return instructionIndex;
}
}
return -1;
}
private static Exception RetryWait(Action action, int interval, int retries = 0)
{
Exception lastException = null;
while (retries >= 0)
{
try
{
retries--;
action();
return null;
}
catch (Exception ex)
{
lastException = ex;
Task.Delay(interval).Wait();
}
}
return lastException;
}
public static bool IsPatchApplied(string subnauticaBasePath)
{
string subnauticaManagedPath = Path.Combine(subnauticaBasePath, GameInfo.Subnautica.DataFolder, "Managed");
string gameInputPath = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_NAME);
using (ModuleDefMD module = ModuleDefMD.Load(gameInputPath))
{
TypeDef gameInputType = module.GetTypes().First(x => x.FullName == GAME_INPUT_TYPE_NAME);
MethodDef awakeMethod = gameInputType.Methods.First(x => x.Name == GAME_INPUT_METHOD_NAME);
return awakeMethod.Body.Instructions[0]?.ToString() == NITROX_EXECUTE_INSTRUCTION;
}
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using NitroxModel.Helper;
namespace Nitrox.Launcher.Models.Utils;
public static class ProcessUtils
{
public static Process StartProcessDetached(ProcessStartInfo startInfo)
{
if (!string.IsNullOrWhiteSpace(startInfo.Arguments))
{
throw new NotSupportedException($"Arguments must be supplied via {startInfo.ArgumentList}");
}
// On Linux, processes are started as child by default. So we wrap as shell command to start detached from current process.
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
List<string> newArgs = ["-c", string.Join(" ", ["nohup", $"'{startInfo.FileName}'", string.Join(" ", startInfo.ArgumentList), ">/dev/null 2>&1", "&"])];
startInfo.FileName = "/bin/sh";
startInfo.ArgumentList.Clear();
startInfo.ArgumentList.AddRange(newArgs);
}
return Process.Start(startInfo);
}
/// <summary>
/// Starts the current app as a new instance.
/// </summary>
public static void StartSelf(params string[] arguments)
{
string executableFilePath = NitroxUser.ExecutableFilePath ?? Environment.ProcessPath;
// On Linux, entry assembly is .dll file but real executable is without extension.
string temp = Path.ChangeExtension(executableFilePath, null);
if (File.Exists(temp))
{
executableFilePath = temp;
}
temp = Path.ChangeExtension(executableFilePath, ".exe");
if (File.Exists(temp))
{
executableFilePath = temp;
}
if (arguments.Contains("--allow-instances"))
{
arguments = [..arguments, "--allow-instances"];
}
using Process proc = StartProcessDetached(new ProcessStartInfo(executableFilePath!, arguments));
}
/// <summary>
/// Opens the Url in the default browser. Forces the Uri scheme as Https.
/// </summary>
public static void OpenUrl(string url)
{
UriBuilder urlBuilder = new(url) { Scheme = Uri.UriSchemeHttps, Port = -1 };
using Process proc = Process.Start(new ProcessStartInfo
{
FileName = urlBuilder.Uri.ToString(),
UseShellExecute = true,
Verb = "open"
});
}
}

View File

@@ -0,0 +1,12 @@
using System.IO;
namespace Nitrox.Launcher.Models.Utils;
internal static class QModHelper
{
internal static bool IsQModInstalled(string subnauticaBasePath)
{
string subnauticaQModManagerPath = Path.Combine(subnauticaBasePath, "Bepinex", "plugins", "QModManager");
return Directory.Exists(subnauticaQModManagerPath);
}
}

View File

@@ -0,0 +1,29 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.IO;
using Nitrox.Launcher.Models.Design;
namespace Nitrox.Launcher.Models.Validators;
/// <summary>
/// Checks that value is a usable <see cref="BackupItem" />.
/// </summary>
public sealed class BackupAttribute : TypedValidationAttribute<BackupItem>
{
protected override ValidationResult IsValid(BackupItem value, ValidationContext context)
{
if (value == null)
{
return new ValidationResult($"{context.DisplayName} must not be null.");
}
if (value.BackupFileName == null || value.BackupFileName.AsSpan().Trim().IsEmpty)
{
return new ValidationResult($"{context.DisplayName} must have a backup path assigned");
}
if (!File.Exists(value.BackupFileName))
{
return new ValidationResult($"{context.DisplayName} must point to a valid file.");
}
return ValidationResult.Success;
}
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
namespace Nitrox.Launcher.Models.Validators;
/// <summary>
/// Tests that the value is usable as file name (excluding validity as file path or file extension).
/// </summary>
public sealed class FileNameAttribute : TypedValidationAttribute<string>
{
internal static readonly char[] InvalidPathCharacters = Path.GetInvalidFileNameChars();
protected override ValidationResult IsValid(string value, ValidationContext context)
{
if (value == null)
{
return ValidationResult.Success;
}
int indexOfAny = value.IndexOfAny(InvalidPathCharacters);
if (indexOfAny > -1)
{
return new ValidationResult($"{context.DisplayName} must not contain '{value[indexOfAny]}'. All invalid characters: {string.Join(' ', InvalidPathCharacters.Where(c => c > 31))}");
}
return ValidationResult.Success;
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.IO;
namespace Nitrox.Launcher.Models.Validators;
/// <summary>
/// Tests that the save name doesn't conflict with other Nitrox saves.
/// </summary>
public sealed class NitroxUniqueSaveName : TypedValidationAttribute<string>
{
public string SavesFolderDirPropertyName { get; }
public bool AllowCaseInsensitiveName { get; }
public string OriginalValuePropertyName { get; }
public NitroxUniqueSaveName(string savesFolderDirPropertyName, bool allowCaseInsensitiveName = false, string originalValuePropertyName = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(savesFolderDirPropertyName);
SavesFolderDirPropertyName = savesFolderDirPropertyName;
AllowCaseInsensitiveName = allowCaseInsensitiveName;
OriginalValuePropertyName = originalValuePropertyName;
}
protected override ValidationResult IsValid(string value, ValidationContext context)
{
static bool SaveFolderExists(string folderName, bool matchExact, string savesFolderDir)
{
if (!matchExact)
{
foreach (string dir in Directory.EnumerateDirectories(savesFolderDir))
{
if (Path.GetFileName(dir).Equals(folderName, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
return Path.Exists(Path.Combine(savesFolderDir, folderName));
}
if (!Directory.Exists(ReadProperty<string>(context, SavesFolderDirPropertyName)))
{
return ValidationResult.Success;
}
if (!string.IsNullOrEmpty(OriginalValuePropertyName) && value == ReadProperty<string>(context, OriginalValuePropertyName))
{
return ValidationResult.Success;
}
if (SaveFolderExists(value, !AllowCaseInsensitiveName, ReadProperty<string>(context, SavesFolderDirPropertyName)))
{
return new ValidationResult($@"Save ""{value}"" already exists.");
}
return ValidationResult.Success;
}
}

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
namespace Nitrox.Launcher.Models.Validators;
public sealed partial class NitroxWorldSeedAttribute : TypedValidationAttribute<string>
{
[GeneratedRegex(@"^[a-zA-Z]{10}$")]
private static partial Regex NitroxWorldSeedRegex { get; }
protected override ValidationResult IsValid(string value, ValidationContext context)
{
if (string.IsNullOrEmpty(value) || NitroxWorldSeedRegex.IsMatch(value))
{
return ValidationResult.Success;
}
return new ValidationResult($"The field {context.DisplayName} must contain 10 alphabetical characters.");
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Nitrox.Launcher.Models.Validators;
/// <summary>
/// Tests that the value doesn't end with the specified text.
/// </summary>
public sealed class NotEndsWithAttribute(string text, StringComparison comparison = StringComparison.OrdinalIgnoreCase) : TypedValidationAttribute<string>
{
protected override ValidationResult IsValid(string value, ValidationContext context)
{
if (value == null)
{
return ValidationResult.Success;
}
return value.EndsWith(text, comparison) ? new ValidationResult($"{context.DisplayName} must not contain the text '{text}' at the end.") : ValidationResult.Success;
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Nitrox.Launcher.Models.Validators;
/// <summary>Validates a Nitrox save name.</summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public class SaveNameAttribute : DataTypeAttribute
{
public SaveNameAttribute() : base(DataType.Text)
{
}
public override bool IsValid(object value)
{
if (value is not string str)
{
return false;
}
int indexOfAny = str.IndexOfAny(FileNameAttribute.InvalidPathCharacters);
if (indexOfAny > -1)
{
return false;
}
if (str.EndsWith(".", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
}

View File

@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using System.Reflection;
namespace Nitrox.Launcher.Models.Validators;
public abstract class TypedValidationAttribute<T> : ValidationAttribute
{
protected abstract ValidationResult IsValid(T value, ValidationContext context);
protected override ValidationResult IsValid(object value, ValidationContext context)
{
if (value == default)
{
return IsValid(default, context);
}
if (value is not T typedValue)
{
return new ValidationResult($"The field {context.DisplayName} must be of type {typeof(T).Name}.");
}
return IsValid(typedValue, context);
}
protected static TResult ReadProperty<TResult>(ValidationContext context, string propertyName)
{
if (string.IsNullOrWhiteSpace(propertyName))
{
return default;
}
object value = context.ObjectType.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(context.ObjectInstance);
return value is TResult tValue ? tValue : default;
}
}