first commit
This commit is contained in:
43
Nitrox.Launcher/Models/Behaviors/FocusOnViewShowBehavior.cs
Normal file
43
Nitrox.Launcher/Models/Behaviors/FocusOnViewShowBehavior.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Xaml.Interactivity;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Focuses the <see cref="Behavior.AssociatedObject" /> when its parent view is shown.
|
||||
/// </summary>
|
||||
public class FocusOnViewShowBehavior : Behavior<Control>
|
||||
{
|
||||
protected override void OnAttached()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Register<ViewShownMessage>(this, static (obj, _) => (obj as FocusOnViewShowBehavior)?.Focus());
|
||||
base.OnAttached();
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
WeakReferenceMessenger.Default.UnregisterAll(this);
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
protected override void OnAttachedToVisualTree() => Focus();
|
||||
|
||||
private void Focus()
|
||||
{
|
||||
if (AssociatedObject == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!AssociatedObject.IsEffectivelyVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AssociatedObject.Focus();
|
||||
if (AssociatedObject is TextBox textBox)
|
||||
{
|
||||
textBox.SelectAll();
|
||||
}
|
||||
}
|
||||
}
|
133
Nitrox.Launcher/Models/Behaviors/SmoothScrollBehavior.cs
Normal file
133
Nitrox.Launcher/Models/Behaviors/SmoothScrollBehavior.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Reactive;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Behaviors;
|
||||
|
||||
public abstract class SmoothScrollBehavior
|
||||
{
|
||||
private static CancellationTokenSource animationTokenSource;
|
||||
private static readonly Easing smoothScrollEasing = new ExponentialEaseOut();
|
||||
private static readonly Animation animation = new()
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(250),
|
||||
Easing = smoothScrollEasing,
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Cue = new Cue(0),
|
||||
Setters = { new Setter(ScrollViewer.OffsetProperty, 0) }
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Cue = new Cue(1),
|
||||
Setters = { new Setter(ScrollViewer.OffsetProperty, 0) }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly AttachedProperty<bool> SmoothScrollProperty =
|
||||
AvaloniaProperty.RegisterAttached<SmoothScrollBehavior, ScrollViewer, bool>("SmoothScroll");
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the target offset which was last used as smooth scrolling target.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// lastOffsetProperty is needed here since the ScrollViewer.Offset property is already set to the target offset when
|
||||
/// the PointerWheelChanged event is raised
|
||||
/// </remarks>
|
||||
private static readonly AttachedProperty<Vector> lastOffsetProperty =
|
||||
AvaloniaProperty.RegisterAttached<SmoothScrollBehavior, ScrollViewer, Vector>("LastOffset", new Vector(0, 0));
|
||||
|
||||
static SmoothScrollBehavior()
|
||||
{
|
||||
SmoothScrollProperty.Changed.Subscribe(new AnonymousObserver<AvaloniaPropertyChangedEventArgs<bool>>(OnEnableSmoothScrollingChanged));
|
||||
ScrollViewer.OffsetProperty.Changed.Subscribe(new AnonymousObserver<AvaloniaPropertyChangedEventArgs<Vector>>(OnScrollOffsetChanged));
|
||||
}
|
||||
|
||||
private static void OnScrollOffsetChanged(AvaloniaPropertyChangedEventArgs<Vector> args)
|
||||
{
|
||||
if (args.Sender is not ScrollViewer scrollViewer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// This keeps LastOffset in sync with programmatic changes to offset so there won't be any huge and ugly scroll jumps.
|
||||
if (animationTokenSource is null or { IsCancellationRequested: true })
|
||||
{
|
||||
SetLastOffset(scrollViewer, args.OldValue.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetSmoothScroll(ScrollViewer element) => element.GetValue(SmoothScrollProperty);
|
||||
|
||||
public static void SetSmoothScroll(ScrollViewer element, bool value) => element.SetValue(SmoothScrollProperty, value);
|
||||
|
||||
private static Vector GetLastOffset(ScrollViewer element) => element.GetValue(lastOffsetProperty);
|
||||
|
||||
private static void SetLastOffset(ScrollViewer element, Vector value) => element.SetValue(lastOffsetProperty, value);
|
||||
|
||||
private static void OnEnableSmoothScrollingChanged(AvaloniaPropertyChangedEventArgs<bool> args)
|
||||
{
|
||||
if (args.Sender is not ScrollViewer scrollViewer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.NewValue.GetValueOrDefault())
|
||||
{
|
||||
scrollViewer.AddHandler(InputElement.PointerWheelChangedEvent, OnPointerWheelChanged, handledEventsToo: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
scrollViewer.RemoveHandler(InputElement.PointerWheelChangedEvent, OnPointerWheelChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPointerWheelChanged(object sender, PointerWheelEventArgs e)
|
||||
{
|
||||
if (sender is not ScrollViewer scrollViewer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel ongoing animations
|
||||
if (animationTokenSource is { IsCancellationRequested: false })
|
||||
{
|
||||
animationTokenSource.Cancel();
|
||||
animationTokenSource.Dispose();
|
||||
}
|
||||
|
||||
// Get new offset (already set on each ScrollViewer as attached property)
|
||||
Vector lastOffset = GetLastOffset(scrollViewer);
|
||||
Vector newOffset = scrollViewer.Offset;
|
||||
if (lastOffset != newOffset)
|
||||
{
|
||||
animationTokenSource = new CancellationTokenSource();
|
||||
SetLastOffset(scrollViewer, newOffset);
|
||||
AnimateScrollToTargetAsync(scrollViewer, lastOffset, newOffset, animationTokenSource.Token).ContinueWithHandleError();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AnimateScrollToTargetAsync(ScrollViewer scrollViewer, Vector previousOffset, Vector targetOffset, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
((Setter)animation.Children[0].Setters[0]).Value = previousOffset;
|
||||
((Setter)animation.Children[1].Setters[0]).Value = targetOffset;
|
||||
await animation.RunAsync(scrollViewer, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
94
Nitrox.Launcher/Models/Controls/BlurControl.cs
Normal file
94
Nitrox.Launcher/Models/Controls/BlurControl.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Rendering.SceneGraph;
|
||||
using Avalonia.Skia;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Draws a blur filter over the already rendered content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Based off of GrayscaleControl
|
||||
/// </remarks>
|
||||
public sealed class BlurControl : Decorator
|
||||
{
|
||||
public static readonly StyledProperty<float> BlurStrengthProperty =
|
||||
AvaloniaProperty.Register<BlurControl, float>(nameof(BlurStrength), 5);
|
||||
|
||||
/// <summary>
|
||||
/// Sets or gets how strong the blur should be. Defaults to 5.
|
||||
/// </summary>
|
||||
public float BlurStrength
|
||||
{
|
||||
get => GetValue(BlurStrengthProperty);
|
||||
set => SetValue(BlurStrengthProperty, value);
|
||||
}
|
||||
|
||||
static BlurControl()
|
||||
{
|
||||
ClipToBoundsProperty.OverrideDefaultValue<BlurControl>(true);
|
||||
AffectsRender<BlurControl>(OpacityProperty);
|
||||
AffectsRender<BlurControl>(BlurStrengthProperty);
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
context.Custom(new BlurBehindRenderOperation((byte)Math.Round(byte.MaxValue * Opacity), BlurStrength, new Rect(default, Bounds.Size)));
|
||||
}
|
||||
|
||||
private sealed record BlurBehindRenderOperation : ICustomDrawOperation
|
||||
{
|
||||
private readonly Rect bounds;
|
||||
private readonly byte opacity;
|
||||
private readonly float strength;
|
||||
|
||||
public Rect Bounds => bounds;
|
||||
|
||||
public BlurBehindRenderOperation(byte opacity, float strength, Rect bounds)
|
||||
{
|
||||
this.opacity = opacity;
|
||||
this.strength = strength;
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public bool HitTest(Point p) => bounds.Contains(p);
|
||||
|
||||
public void Render(ImmediateDrawingContext context)
|
||||
{
|
||||
ISkiaSharpApiLeaseFeature leaseFeature = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
|
||||
if (leaseFeature == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
using ISkiaSharpApiLease skia = leaseFeature.Lease();
|
||||
if (!skia.SkCanvas.TotalMatrix.TryInvert(out SKMatrix currentInvertedTransform))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (skia.SkSurface == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using SKImage backgroundSnapshot = skia.SkSurface.Snapshot();
|
||||
using SKShader backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, currentInvertedTransform);
|
||||
using SKImageFilter blurFilter = SKImageFilter.CreateBlur(strength, strength);
|
||||
using SKPaint paint = new();
|
||||
paint.Shader = backdropShader;
|
||||
paint.ImageFilter = blurFilter;
|
||||
paint.Color = new SKColor(0, 0, 0, opacity);
|
||||
skia.SkCanvas.DrawRect(0, 0, (float)bounds.Width, (float)bounds.Height, paint);
|
||||
}
|
||||
|
||||
public bool Equals(ICustomDrawOperation other) => Equals(other as BlurBehindRenderOperation);
|
||||
}
|
||||
}
|
121
Nitrox.Launcher/Models/Controls/CustomTitlebar.axaml
Normal file
121
Nitrox.Launcher/Models/Controls/CustomTitlebar.axaml
Normal file
@@ -0,0 +1,121 @@
|
||||
<ResourceDictionary
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:Nitrox.Launcher.Models.Controls"
|
||||
xmlns:converters="clr-namespace:Nitrox.Launcher.Models.Converters">
|
||||
<Design.PreviewWith>
|
||||
<StackPanel Width="200">
|
||||
<controls:CustomTitlebar
|
||||
Background="IndianRed"
|
||||
CanMaximize="False"
|
||||
CanMinimize="False" />
|
||||
<controls:CustomTitlebar
|
||||
Background="ForestGreen"
|
||||
CanMaximize="True"
|
||||
CanMinimize="False" />
|
||||
<controls:CustomTitlebar Background="CornflowerBlue" CanMaximize="False" />
|
||||
<controls:CustomTitlebar Background="Violet" />
|
||||
</StackPanel>
|
||||
</Design.PreviewWith>
|
||||
|
||||
<ControlTheme x:Key="{x:Type controls:CustomTitlebar}" TargetType="controls:CustomTitlebar">
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Panel Background="Transparent">
|
||||
<Border
|
||||
Padding="5,2"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{TemplateBinding ShowTitle}">
|
||||
<TextBlock
|
||||
VerticalAlignment="Stretch"
|
||||
Text="{Binding $parent[Window].Title}"
|
||||
TextAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel>
|
||||
<Button
|
||||
x:Name="PART_MinimizeButton"
|
||||
Classes.leftOff1="{x:True}"
|
||||
Classes.leftOff2="{x:True}"
|
||||
Command="{Binding MinimizeCommand, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
IsVisible="{TemplateBinding CanMinimize}">
|
||||
<Svg Classes="theme" Path="/Assets/Icons/minimize.svg" />
|
||||
</Button>
|
||||
<Button
|
||||
x:Name="PART_MaximizeButton"
|
||||
Classes.leftOff1="{Binding !#PART_MinimizeButton.IsVisible}"
|
||||
Classes.leftOff2="{x:True}"
|
||||
Command="{Binding ToggleMaximizeCommand, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<Button.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<TemplateBinding Property="CanMaximize" />
|
||||
<Binding Path="$parent[Window].CanResize" />
|
||||
</MultiBinding>
|
||||
</Button.IsVisible>
|
||||
<Svg
|
||||
Classes="theme"
|
||||
Classes.maximize="{Binding $parent[Window].WindowState, Converter={converters:EqualityConverter}, ConverterParameter={x:Static WindowState.Normal}}"
|
||||
Classes.restore="{Binding $parent[Window].WindowState, Converter={converters:EqualityConverter}, ConverterParameter={x:Static WindowState.Maximized}}" />
|
||||
</Button>
|
||||
<Button
|
||||
x:Name="PART_CloseButton"
|
||||
Classes="close"
|
||||
Classes.leftOff1="{Binding !#PART_MinimizeButton.IsVisible}"
|
||||
Classes.leftOff2="{Binding !#PART_MaximizeButton.IsVisible}"
|
||||
Command="{Binding CloseCommand, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<Svg Classes="theme" Path="/Assets/Icons/close.svg" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
|
||||
<!-- Default template values -->
|
||||
<Setter Property="Background" Value="Black" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="ZIndex" Value="100" />
|
||||
<Setter Property="Height" Value="28" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
<Setter Property="ShowTitle" Value="False" />
|
||||
|
||||
<Style Selector="^ /template/ StackPanel">
|
||||
<Setter Property="Orientation" Value="Horizontal" />
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
|
||||
<Style Selector="^ > Button">
|
||||
<Setter Property="Width" Value="46" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
|
||||
<!-- This selector force overrides button style. TODO: fix ButtonStyle.axaml so this isn't necessary and move setters out of this selector. -->
|
||||
<Style Selector="^:nth-child(1n)">
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Background" Value="{TemplateBinding Background}" />
|
||||
</Style>
|
||||
|
||||
<!-- Button[IsVisible=True]:nth-child(1) doesn't work to filter only visible buttons. Here, leftOff1 is used to check if previous button is on and leftOff2 is the next one over that. -->
|
||||
<Style Selector="^.leftOff1.leftOff2">
|
||||
<Setter Property="CornerRadius" Value="0 0 0 5" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^ > :is(Control)">
|
||||
<Setter Property="Height" Value="11" />
|
||||
<Setter Property="IsHitTestVisible" Value="False" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^.close:pointerover">
|
||||
<Setter Property="Background" Value="Red" />
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<Style Selector="^ Svg.maximize">
|
||||
<Setter Property="Path" Value="/Assets/Icons/maximize.svg" />
|
||||
</Style>
|
||||
<Style Selector="^ Svg.restore">
|
||||
<Setter Property="Path" Value="/Assets/Icons/restore.svg" />
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
</ControlTheme>
|
||||
</ResourceDictionary>
|
113
Nitrox.Launcher/Models/Controls/CustomTitlebar.axaml.cs
Normal file
113
Nitrox.Launcher/Models/Controls/CustomTitlebar.axaml.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
public partial class CustomTitlebar : TemplatedControl
|
||||
{
|
||||
public static readonly DirectProperty<CustomTitlebar, bool> ShowTitleProperty =
|
||||
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
|
||||
nameof(showTitle),
|
||||
o => o.showTitle,
|
||||
(o, v) => o.showTitle = v, true);
|
||||
|
||||
public static readonly DirectProperty<CustomTitlebar, bool> CanMaximizeProperty =
|
||||
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
|
||||
nameof(CanMaximize),
|
||||
o => o.CanMaximize,
|
||||
(o, v) => o.CanMaximize = v, true);
|
||||
|
||||
public static readonly DirectProperty<CustomTitlebar, bool> CanMinimizeProperty =
|
||||
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
|
||||
nameof(CanMinimize),
|
||||
o => o.CanMinimize,
|
||||
(o, v) => o.CanMinimize = v, true);
|
||||
|
||||
private bool showTitle = true;
|
||||
private bool canMaximize = true;
|
||||
private bool canMinimize = true;
|
||||
|
||||
public bool ShowTitle
|
||||
{
|
||||
get => showTitle;
|
||||
set => SetAndRaise(ShowTitleProperty, ref showTitle, value);
|
||||
}
|
||||
|
||||
public bool CanMaximize
|
||||
{
|
||||
get => canMaximize;
|
||||
set => SetAndRaise(CanMaximizeProperty, ref canMaximize, value);
|
||||
}
|
||||
|
||||
public bool CanMinimize
|
||||
{
|
||||
get => canMinimize;
|
||||
set => SetAndRaise(CanMinimizeProperty, ref canMinimize, value);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void Minimize()
|
||||
{
|
||||
if (!CanMinimize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (this.GetWindow() is not { } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.WindowState = WindowState.Minimized;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void ToggleMaximize()
|
||||
{
|
||||
if (!CanMaximize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (this.GetWindow() is not { } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.WindowState = window.WindowState == WindowState.Normal ? WindowState.Maximized : WindowState.Normal;
|
||||
}
|
||||
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
PointerPressed += OnPointerPressed;
|
||||
DoubleTapped += OnDoubleTapped;
|
||||
base.OnLoaded(e);
|
||||
}
|
||||
|
||||
protected override void OnUnloaded(RoutedEventArgs e)
|
||||
{
|
||||
PointerPressed -= OnPointerPressed;
|
||||
DoubleTapped -= OnDoubleTapped;
|
||||
base.OnUnloaded(e);
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.Source is Visual element && element.GetWindow() is { } window)
|
||||
{
|
||||
window.BeginMoveDrag(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDoubleTapped(object sender, TappedEventArgs e) => ToggleMaximize();
|
||||
|
||||
[RelayCommand]
|
||||
private void Close()
|
||||
{
|
||||
if (this.GetWindow() is not { } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.CloseByUser();
|
||||
}
|
||||
}
|
189
Nitrox.Launcher/Models/Controls/FittingWrapPanel.cs
Normal file
189
Nitrox.Launcher/Models/Controls/FittingWrapPanel.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Utilities;
|
||||
using static System.Math;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Panel that arranges stretchable child controls to fit min width, up to the limit of <see cref="MinItemWidth" />.
|
||||
/// Code inspired by Avalonia's WrapPanel
|
||||
/// (https://github.com/AvaloniaUI/Avalonia/blob/master/src/Avalonia.Controls/WrapPanel.cs).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Looks similar to YouTube video layout.
|
||||
/// </remarks>
|
||||
public class FittingWrapPanel : Panel, INavigableContainer
|
||||
{
|
||||
public static readonly StyledProperty<double> MinItemWidthProperty =
|
||||
AvaloniaProperty.Register<WrapPanel, double>(nameof(MinItemWidth), 100);
|
||||
|
||||
public double MinItemWidth
|
||||
{
|
||||
get => GetValue(MinItemWidthProperty);
|
||||
set => SetValue(MinItemWidthProperty, value);
|
||||
}
|
||||
|
||||
static FittingWrapPanel()
|
||||
{
|
||||
AffectsMeasure<WrapPanel>(MinItemWidthProperty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Size MeasureOverride(Size constraint)
|
||||
{
|
||||
UVSize curLineSize = new();
|
||||
UVSize panelSize = new();
|
||||
UVSize uvConstraint = new(constraint.Width, constraint.Height);
|
||||
|
||||
int itemsPerRow = (int)Min(constraint.Width / MinItemWidth, Max(Children.Count, 1));
|
||||
double adjustedWidth = constraint.Width / itemsPerRow;
|
||||
|
||||
for (int i = 0, count = Children.Count; i < count; i++)
|
||||
{
|
||||
Control child = Children[i];
|
||||
child.Measure(new Size(adjustedWidth, constraint.Height));
|
||||
|
||||
UVSize sz = new(adjustedWidth, child.DesiredSize.Height);
|
||||
|
||||
if (MathUtilities.GreaterThan(curLineSize.Width + sz.Width, uvConstraint.Width)) // Need to switch to another line
|
||||
{
|
||||
panelSize = new UVSize { Width = Max(curLineSize.Width, panelSize.Width), Height = panelSize.Height + curLineSize.Height };
|
||||
curLineSize = sz;
|
||||
|
||||
if (MathUtilities.GreaterThan(sz.Width, uvConstraint.Width)) // The element is wider then the constraint - give it a separate line
|
||||
{
|
||||
panelSize = new UVSize { Width = Max(sz.Width, panelSize.Width), Height = panelSize.Height + sz.Height };
|
||||
curLineSize = new UVSize();
|
||||
}
|
||||
}
|
||||
else // Continue to accumulate a line
|
||||
{
|
||||
curLineSize = new UVSize { Width = curLineSize.Width + sz.Width, Height = Max(sz.Height, curLineSize.Height) };
|
||||
}
|
||||
}
|
||||
|
||||
panelSize = new UVSize { Width = Max(curLineSize.Width, panelSize.Width), Height = panelSize.Height + curLineSize.Height };
|
||||
|
||||
return new Size(panelSize.Width, panelSize.Height);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Size ArrangeOverride(Size finalSize)
|
||||
{
|
||||
int firstInLine = 0;
|
||||
double accumulatedV = 0;
|
||||
UVSize curLineSize = new();
|
||||
UVSize uvFinalSize = new(finalSize.Width, finalSize.Height);
|
||||
|
||||
int itemsPerRow = (int)Min(finalSize.Width / MinItemWidth, Max(Children.Count, 1));
|
||||
double adjustedWidth = finalSize.Width / itemsPerRow;
|
||||
|
||||
for (int i = 0; i < Children.Count; i++)
|
||||
{
|
||||
Control child = Children[i];
|
||||
UVSize sz = new(adjustedWidth, child.DesiredSize.Height);
|
||||
|
||||
if (MathUtilities.GreaterThan(curLineSize.Width + sz.Width, uvFinalSize.Width)) // Need to switch to another line
|
||||
{
|
||||
ArrangeLine(accumulatedV, curLineSize.Height, firstInLine, i, adjustedWidth);
|
||||
|
||||
accumulatedV += curLineSize.Height;
|
||||
curLineSize = sz;
|
||||
|
||||
if (MathUtilities.GreaterThan(sz.Width, uvFinalSize.Width)) // The element is wider then the constraint - give it a separate line
|
||||
{
|
||||
ArrangeLine(accumulatedV, sz.Height, i, ++i, adjustedWidth);
|
||||
|
||||
accumulatedV += sz.Height;
|
||||
curLineSize = new UVSize();
|
||||
}
|
||||
firstInLine = i;
|
||||
}
|
||||
else // Continue to accumulate a line
|
||||
{
|
||||
curLineSize = new UVSize { Width = curLineSize.Width + sz.Width, Height = Max(sz.Height, curLineSize.Height) };
|
||||
}
|
||||
}
|
||||
|
||||
if (firstInLine < Children.Count)
|
||||
{
|
||||
ArrangeLine(accumulatedV, curLineSize.Height, firstInLine, Children.Count, adjustedWidth);
|
||||
}
|
||||
|
||||
return finalSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next control in the specified direction.
|
||||
/// </summary>
|
||||
/// <param name="direction">The movement direction.</param>
|
||||
/// <param name="from">The control from which movement begins.</param>
|
||||
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
|
||||
/// <returns>The control.</returns>
|
||||
IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
|
||||
{
|
||||
Avalonia.Controls.Controls children = Children;
|
||||
int index = from is not null ? Children.IndexOf((Control)from) : -1;
|
||||
|
||||
switch (direction)
|
||||
{
|
||||
case NavigationDirection.First:
|
||||
index = 0;
|
||||
break;
|
||||
case NavigationDirection.Last:
|
||||
index = children.Count - 1;
|
||||
break;
|
||||
case NavigationDirection.Next:
|
||||
++index;
|
||||
break;
|
||||
case NavigationDirection.Previous:
|
||||
--index;
|
||||
break;
|
||||
case NavigationDirection.Left:
|
||||
index -= 1;
|
||||
break;
|
||||
case NavigationDirection.Right:
|
||||
index += 1;
|
||||
break;
|
||||
case NavigationDirection.Up:
|
||||
case NavigationDirection.Down:
|
||||
index = -1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (index >= 0 && index < children.Count)
|
||||
{
|
||||
return children[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ArrangeLine(double v, double lineV, int start, int end, double itemU)
|
||||
{
|
||||
Avalonia.Controls.Controls children = Children;
|
||||
double u = 0;
|
||||
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
Control child = children[i];
|
||||
child.Arrange(new Rect(u, v, itemU, lineV));
|
||||
u += itemU;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct UVSize
|
||||
{
|
||||
|
||||
internal UVSize(double width, double height)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
}
|
||||
|
||||
public double Width { get; init; }
|
||||
|
||||
internal double Height { get; init; }
|
||||
}
|
||||
}
|
93
Nitrox.Launcher/Models/Controls/GrayscaleControl.cs
Normal file
93
Nitrox.Launcher/Models/Controls/GrayscaleControl.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
extern alias JB;
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Rendering.SceneGraph;
|
||||
using Avalonia.Skia;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Draws a grayscale filter over the already rendered content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Code from:<br/>
|
||||
/// - Draw-on-top logic: https://gist.github.com/kekekeks/ac06098a74fe87d49a9ff9ea37fa67bc <br/>
|
||||
/// - Grayscale logic: https://learn.microsoft.com/en-us/xamarin/xamarin-forms/user-interface/graphics/skiasharp/effects/color-filters <br/>
|
||||
/// </remarks>
|
||||
public class GrayscaleControl : Decorator
|
||||
{
|
||||
static GrayscaleControl()
|
||||
{
|
||||
AffectsRender<GrayscaleControl>(OpacityProperty);
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
context.Custom(new GrayscaleBehindRenderOperation((byte)Math.Round(byte.MaxValue * Opacity), new Rect(default, Bounds.Size)));
|
||||
}
|
||||
|
||||
private class GrayscaleBehindRenderOperation : ICustomDrawOperation
|
||||
{
|
||||
private static readonly float[] grayscaleColorFilterMatrix =
|
||||
{
|
||||
0.21f, 0.72f, 0.07f, 0, 0,
|
||||
0.21f, 0.72f, 0.07f, 0, 0,
|
||||
0.21f, 0.72f, 0.07f, 0, 0,
|
||||
0, 0, 0, 1, 0
|
||||
};
|
||||
|
||||
private readonly byte opacity;
|
||||
private readonly Rect bounds;
|
||||
|
||||
public Rect Bounds => bounds;
|
||||
|
||||
public GrayscaleBehindRenderOperation(byte opacity, Rect bounds)
|
||||
{
|
||||
this.opacity = opacity;
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public bool HitTest(Point p) => bounds.Contains(p);
|
||||
|
||||
public void Render(ImmediateDrawingContext context)
|
||||
{
|
||||
ISkiaSharpApiLeaseFeature leaseFeature = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
|
||||
if (leaseFeature == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
using ISkiaSharpApiLease skia = leaseFeature.Lease();
|
||||
if (!skia.SkCanvas.TotalMatrix.TryInvert(out SKMatrix currentInvertedTransform))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (skia.SkSurface == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using SKImage backgroundSnapshot = skia.SkSurface.Snapshot();
|
||||
using SKShader backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, currentInvertedTransform);
|
||||
using SKImageFilter grayscaleFilter = SKImageFilter.CreateColorFilter(CreateGrayscaleColorFilter());
|
||||
using SKPaint paint = new()
|
||||
{
|
||||
Shader = backdropShader,
|
||||
ImageFilter = grayscaleFilter,
|
||||
Color = new SKColor(0, 0, 0, opacity)
|
||||
};
|
||||
skia.SkCanvas.DrawRect(0, 0, (float)bounds.Width, (float)bounds.Height, paint);
|
||||
}
|
||||
|
||||
public bool Equals(ICustomDrawOperation other) => other is GrayscaleBehindRenderOperation op && op.bounds == bounds;
|
||||
|
||||
private static SKColorFilter CreateGrayscaleColorFilter() => SKColorFilter.CreateColorMatrix(grayscaleColorFilterMatrix);
|
||||
}
|
||||
}
|
51
Nitrox.Launcher/Models/Controls/RadioButtonGroup.cs
Normal file
51
Nitrox.Launcher/Models/Controls/RadioButtonGroup.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
public class RadioButtonGroup : ItemsControl
|
||||
{
|
||||
public static readonly DirectProperty<RadioButtonGroup, Type> EnumProperty = AvaloniaProperty.RegisterDirect<RadioButtonGroup, Type>(nameof(Enum), o => o.Enum, (o, v) => o.Enum = v);
|
||||
public static readonly StyledProperty<object> SelectedItemProperty = AvaloniaProperty.Register<RadioButtonGroup, object>(nameof(SelectedItem));
|
||||
|
||||
public static readonly DirectProperty<RadioButtonGroup, RelayCommand<Button>> ItemClickCommandProperty = AvaloniaProperty.RegisterDirect<RadioButtonGroup, RelayCommand<Button>>(nameof(ItemClickCommand), o => o.ItemClickCommand, (o, v) => o.ItemClickCommand = v);
|
||||
|
||||
private Type @enum;
|
||||
private RelayCommand<Button> itemClickCommand;
|
||||
|
||||
public Type Enum
|
||||
{
|
||||
get => @enum;
|
||||
set
|
||||
{
|
||||
if (value is not { IsEnum: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ItemsSource = System.Enum.GetValues(value);
|
||||
SetAndRaise(EnumProperty, ref @enum, value);
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand<Button> ItemClickCommand
|
||||
{
|
||||
get => itemClickCommand;
|
||||
private set => SetAndRaise(ItemClickCommandProperty, ref itemClickCommand, value);
|
||||
}
|
||||
|
||||
public object SelectedItem
|
||||
{
|
||||
get => GetValue(SelectedItemProperty);
|
||||
set => SetValue(SelectedItemProperty, value);
|
||||
}
|
||||
|
||||
public RadioButtonGroup()
|
||||
{
|
||||
itemClickCommand = new RelayCommand<Button>(param => SelectedItem = param.Tag);
|
||||
}
|
||||
|
||||
protected override Type StyleKeyOverride { get; } = typeof(ItemsControl);
|
||||
}
|
149
Nitrox.Launcher/Models/Controls/RichTextBlock.cs
Normal file
149
Nitrox.Launcher/Models/Controls/RichTextBlock.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A basic Rich Textbox. Supports bold, italic, underline, colors and hyperlinks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tag legend:<br />
|
||||
/// [b][/b] - Bold <br />
|
||||
/// [i][/i] - Italicize <br />
|
||||
/// [u][/u] - Underline <br />
|
||||
/// [#colorHex][/#colorHex] - Change text color <br />
|
||||
/// [Flavor text](example.com) <br />
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// [b]Text[/b] => <b>Text</b> <br />
|
||||
/// [i]Text[/i] => <i>Text</i> <br />
|
||||
/// [u]Text[/u] => <u>Text</u> <br />
|
||||
/// [#0000FF]Text[/#0000FF] => Text (with blue foreground) <br />
|
||||
/// <a href="https://example.com">Flavor text</a> <br />
|
||||
/// </example>
|
||||
public partial class RichTextBlock : TextBlock
|
||||
{
|
||||
private static readonly TextDecorationCollection underlineTextDecoration = [new() { Location = TextDecorationLocation.Underline }];
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
if (change.Property == TextProperty)
|
||||
{
|
||||
Inlines?.Clear();
|
||||
ParseTextAndAddInlines(Text ?? "", Inlines);
|
||||
// If all text was just tags, set Text to empty. Otherwise, it will be displayed as fallback by Avalonia.
|
||||
if (Inlines?.Count < 1)
|
||||
{
|
||||
Text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\[\/?([^]]+)\](?:\(([^\)]*)\))?")]
|
||||
private static partial Regex TagParserRegex { get; }
|
||||
|
||||
public static void ParseTextAndAddInlines(ReadOnlySpan<char> text, InlineCollection inlines)
|
||||
{
|
||||
if (inlines == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Regex.ValueMatchEnumerator matchEnumerator = TagParserRegex.EnumerateMatches(text);
|
||||
if (!matchEnumerator.MoveNext())
|
||||
{
|
||||
inlines.Add(new Run(text.ToString()));
|
||||
return;
|
||||
}
|
||||
|
||||
ValueMatch lastRange = default;
|
||||
Dictionary<string, Action<Run, string>> activeTags = new(4);
|
||||
do
|
||||
{
|
||||
ValueMatch range = matchEnumerator.Current;
|
||||
|
||||
// Handle text in-between previous and current tag.
|
||||
ReadOnlySpan<char> textPart = text[(lastRange.Index + lastRange.Length)..range.Index];
|
||||
if (!textPart.IsEmpty)
|
||||
{
|
||||
inlines.Add(CreateRunWithTags(textPart.ToString(), activeTags));
|
||||
}
|
||||
|
||||
// Handle current tag (this tracks state of active tags at current text position)
|
||||
ReadOnlySpan<char> match = text.Slice(range.Index, range.Length);
|
||||
switch (match)
|
||||
{
|
||||
case ['[', '/', ..]:
|
||||
activeTags.Remove(match[2..^1].ToString());
|
||||
break;
|
||||
case "[b]":
|
||||
activeTags["b"] = static (run, _) => run.FontWeight = FontWeight.Bold;
|
||||
break;
|
||||
case "[u]":
|
||||
activeTags["u"] = static (run, _) => run.TextDecorations = underlineTextDecoration;
|
||||
break;
|
||||
case "[i]":
|
||||
activeTags["i"] = static (run, _) => run.FontStyle = FontStyle.Italic;
|
||||
break;
|
||||
case ['[', ..] when match.IndexOf("](", StringComparison.OrdinalIgnoreCase) > -1:
|
||||
TextBlock textBlock = new();
|
||||
textBlock.Classes.Add("link");
|
||||
textBlock.Text = match[1..match.IndexOfAny("]")].ToString();
|
||||
textBlock.Tag = match[(match.IndexOfAny("(")+1)..match.IndexOfAny(")")].ToString();
|
||||
inlines.Add(textBlock);
|
||||
break;
|
||||
case ['[', '#', ..]:
|
||||
ReadOnlySpan<char> colorCode = match[1..match.IndexOfAny("]")];
|
||||
if (!Color.TryParse(colorCode, out Color _))
|
||||
{
|
||||
goto default;
|
||||
}
|
||||
activeTags[colorCode.ToString()] = static (run, tag) => run.Foreground = new SolidColorBrush(Color.Parse(tag));
|
||||
break;
|
||||
default:
|
||||
// Unknown tag, let's handle as normal text (issue is likely due to input text not knowing about this RichTextBox format)
|
||||
inlines.Add(CreateRunWithTags(match.ToString(), activeTags));
|
||||
break;
|
||||
}
|
||||
|
||||
lastRange = range;
|
||||
} while (matchEnumerator.MoveNext());
|
||||
|
||||
// Handle any final text (after the last tag).
|
||||
ReadOnlySpan<char> lastPart = text[(lastRange.Index + lastRange.Length)..];
|
||||
if (!lastPart.IsEmpty)
|
||||
{
|
||||
inlines.Add(CreateRunWithTags(lastPart.ToString(), activeTags));
|
||||
}
|
||||
}
|
||||
|
||||
private static Run CreateRunWithTags(string text, Dictionary<string, Action<Run, string>> tags)
|
||||
{
|
||||
Run run = new(text);
|
||||
KeyValuePair<string, Action<Run, string>>? lastColorTag = null;
|
||||
foreach (KeyValuePair<string, Action<Run, string>> pair in tags)
|
||||
{
|
||||
switch (pair.Key)
|
||||
{
|
||||
case ['#', ..]:
|
||||
// Optimization: only the last color needs to be applied for the current run, ignore all others.
|
||||
lastColorTag = pair;
|
||||
break;
|
||||
default:
|
||||
pair.Value(run, pair.Key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lastColorTag?.Value(run, lastColorTag.Value.Key);
|
||||
return run;
|
||||
}
|
||||
|
||||
protected override Type StyleKeyOverride { get; } = typeof(TextBlock);
|
||||
}
|
27
Nitrox.Launcher/Models/Controls/SelectableRichTextBlock.cs
Normal file
27
Nitrox.Launcher/Models/Controls/SelectableRichTextBlock.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Controls;
|
||||
|
||||
/// <inheritdoc cref="RichTextBlock"/>
|
||||
public class SelectableRichTextBlock : SelectableTextBlock
|
||||
{
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
if (change.Property == TextProperty)
|
||||
{
|
||||
Inlines?.Clear();
|
||||
RichTextBlock.ParseTextAndAddInlines(Text ?? "", Inlines);
|
||||
// If all text was just tags, set Text to empty. Otherwise, it will be displayed as fallback by Avalonia.
|
||||
if (Inlines?.Count < 1)
|
||||
{
|
||||
Text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override Type StyleKeyOverride { get; } = typeof(SelectableTextBlock);
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Nitrox.Launcher.Models.Utils;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
public class BitmapAssetValueConverter : Converter<BitmapAssetValueConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
value switch
|
||||
{
|
||||
null => null,
|
||||
Bitmap when targetType.IsAssignableFrom(typeof(Bitmap)) => value,
|
||||
string s when targetType.IsAssignableFrom(typeof(Bitmap)) => AssetHelper.GetAssetFromStream(s, static stream => new Bitmap(stream)),
|
||||
_ => throw new NotSupportedException()
|
||||
};
|
||||
}
|
45
Nitrox.Launcher/Models/Converters/BoolToIconConverter.cs
Normal file
45
Nitrox.Launcher/Models/Converters/BoolToIconConverter.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Nitrox.Launcher.Models.Utils;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
public sealed class BoolToIconConverter : MarkupExtension, IValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// String that will be outputted if the input boolean value is <c>true</c>
|
||||
/// </summary>
|
||||
public string True { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// String that will be outputted if the input boolean value is <c>false</c>
|
||||
/// </summary>
|
||||
public string False { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Decides if the converter will inverse the input boolean value before computing the output
|
||||
/// </summary>
|
||||
public bool Invert { get; set; }
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not bool @bool)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Invert)
|
||||
{
|
||||
@bool = !@bool;
|
||||
}
|
||||
|
||||
return AssetHelper.GetAssetFromStream(@bool ? True : False, static stream => new Bitmap(stream));
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException();
|
||||
|
||||
public override object ProvideValue(IServiceProvider serviceProvider) => this;
|
||||
}
|
21
Nitrox.Launcher/Models/Converters/Converter.cs
Normal file
21
Nitrox.Launcher/Models/Converters/Converter.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// A converter base class that provides itself as value to the XAML compiler.
|
||||
/// </summary>
|
||||
public abstract class Converter<TSelf> : MarkupExtension, IValueConverter
|
||||
where TSelf : Converter<TSelf>, new()
|
||||
{
|
||||
private static TSelf Instance { get; } = new();
|
||||
|
||||
public sealed override object ProvideValue(IServiceProvider serviceProvider) => Instance;
|
||||
|
||||
public abstract object Convert(object value, Type targetType, object parameter, CultureInfo culture);
|
||||
|
||||
public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException();
|
||||
}
|
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the bound value as a relative date string from a DateTime value.
|
||||
/// </summary>
|
||||
public class DateToRelativeDateConverter : Converter<DateToRelativeDateConverter>
|
||||
{
|
||||
private const float DAYS_IN_YEAR = 365.2425f;
|
||||
private const float MEAN_DAYS_IN_MONTH = DAYS_IN_YEAR / 12f;
|
||||
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
DateTimeOffset date = value switch
|
||||
{
|
||||
DateTime dateTime => dateTime,
|
||||
DateTimeOffset dateTimeOffset => dateTimeOffset,
|
||||
DateOnly dateOnly => dateOnly.ToDateTime(TimeOnly.MinValue),
|
||||
string text when DateTimeOffset.TryParse(text, out DateTimeOffset offset) => offset,
|
||||
_ => throw new ArgumentException($"Value must be a {nameof(DateTime)} or {nameof(DateTimeOffset)}", nameof(value))
|
||||
};
|
||||
|
||||
TimeSpan delta = DateTimeOffset.UtcNow - date.UtcDateTime;
|
||||
|
||||
return delta switch
|
||||
{
|
||||
{ TotalSeconds: < 1 } => "just now",
|
||||
{ TotalSeconds: < 2 } => "a second ago",
|
||||
{ TotalMinutes: < 1 } => $"{(int)delta.TotalSeconds} seconds ago",
|
||||
{ TotalMinutes: < 2 } => "a minute ago",
|
||||
{ TotalMinutes: < 45 } => $"{(int)delta.TotalMinutes} minutes ago",
|
||||
{ TotalHours: < 1.5 } => "an hour ago",
|
||||
{ TotalDays: < 1 } => $"{(int)delta.TotalHours} hours ago",
|
||||
{ TotalDays: < 2 } => "yesterday",
|
||||
{ TotalDays: < MEAN_DAYS_IN_MONTH } => $"{(int)delta.TotalDays} days ago",
|
||||
{ TotalDays: < MEAN_DAYS_IN_MONTH * 2 } => "a month ago",
|
||||
{ TotalDays: < DAYS_IN_YEAR } => $"{(int)(delta.TotalDays / MEAN_DAYS_IN_MONTH)} months ago",
|
||||
{ TotalDays: < DAYS_IN_YEAR * 2 } => "a year ago",
|
||||
_ => $"{(int)(delta.TotalDays / DAYS_IN_YEAR)} years ago"
|
||||
};
|
||||
}
|
||||
}
|
22
Nitrox.Launcher/Models/Converters/DeduplicateConverter.cs
Normal file
22
Nitrox.Launcher/Models/Converters/DeduplicateConverter.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Removes duplicates by non-unique ToString values of the given list.
|
||||
/// </summary>
|
||||
public class DeduplicateConverter : Converter<DeduplicateConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not IEnumerable<object> list)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return list.DistinctBy(i => i.ToString());
|
||||
}
|
||||
}
|
36
Nitrox.Launcher/Models/Converters/EqualityConverter.cs
Normal file
36
Nitrox.Launcher/Models/Converters/EqualityConverter.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if values are equal to each other.
|
||||
/// Or if value is singular, if parameter is equal to the value.
|
||||
/// </summary>
|
||||
public class EqualityConverter : Converter<EqualityConverter>, IMultiValueConverter
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) => Equals(value, parameter);
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException();
|
||||
|
||||
public object Convert(IList<object> values, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
foreach (object val1 in values)
|
||||
{
|
||||
foreach (object val2 in values)
|
||||
{
|
||||
if (ReferenceEquals(val1, val2))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!Equals(val1, val2))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
43
Nitrox.Launcher/Models/Converters/IntToStringConverter.cs
Normal file
43
Nitrox.Launcher/Models/Converters/IntToStringConverter.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the bound value as a string from an integer.
|
||||
/// </summary>
|
||||
public partial class IntToStringConverter : Converter<IntToStringConverter>
|
||||
{
|
||||
[GeneratedRegex("[^0-9]")]
|
||||
private static partial Regex DigitReplaceRegex { get; }
|
||||
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
return value?.ToString() ?? "";
|
||||
}
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value is not string str)
|
||||
{
|
||||
str = value.ToString();
|
||||
if (str is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
str = DigitReplaceRegex.Replace(str, "");
|
||||
if (int.TryParse(str, out int result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
34
Nitrox.Launcher/Models/Converters/IsTypeConverter.cs
Normal file
34
Nitrox.Launcher/Models/Converters/IsTypeConverter.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if value is of the type as given by parameter (or any if parameter is a collection of types).
|
||||
/// </summary>
|
||||
public class IsTypeConverter : Converter<IsTypeConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
switch (parameter)
|
||||
{
|
||||
case Type typeParameter:
|
||||
return typeParameter.IsInstanceOfType(value);
|
||||
case IEnumerable<Type> typeParameters:
|
||||
{
|
||||
foreach (Type type in typeParameters)
|
||||
{
|
||||
if (type.IsInstanceOfType(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return new BindingNotification(new ArgumentException($"Expected {nameof(parameter)} to be a {typeof(Type).FullName}"), BindingErrorType.Error);
|
||||
}
|
||||
}
|
||||
}
|
24
Nitrox.Launcher/Models/Converters/PlatformToIconConverter.cs
Normal file
24
Nitrox.Launcher/Models/Converters/PlatformToIconConverter.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Nitrox.Launcher.Models.Utils;
|
||||
using NitroxModel.Discovery.Models;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
public class PlatformToIconConverter : Converter<PlatformToIconConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
return AssetHelper.GetAssetFromStream(GetIconPathForPlatform(value as Platform?), static stream => new Bitmap(stream));
|
||||
}
|
||||
|
||||
private static string GetIconPathForPlatform(Platform? platform) => platform switch
|
||||
{
|
||||
Platform.EPIC => "/Assets/Images/store-icons/epic.png",
|
||||
Platform.STEAM => "/Assets/Images/store-icons/steam.png",
|
||||
Platform.MICROSOFT => "/Assets/Images/store-icons/xbox.png",
|
||||
Platform.DISCORD => "/Assets/Images/store-icons/discord.png",
|
||||
_ => "/Assets/Images/store-icons/missing.png",
|
||||
};
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the bound value as "0 BoundValue 0 BoundValue" Margin from a Padding, used for TextBox styling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This converter is used to solve a niche issue with the styling of TextBoxes.
|
||||
/// </remarks>
|
||||
public class TextBoxPaddingToMarginConverter : Converter<TextBoxPaddingToMarginConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not Thickness padding)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
bool isNegative = parameter != null && bool.TryParse(parameter.ToString(), out bool result) && result;
|
||||
double top = isNegative ? -padding.Top : padding.Top;
|
||||
double bottom = isNegative ? -padding.Bottom : padding.Bottom;
|
||||
return new Thickness(0, top, 0, bottom);
|
||||
}
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
30
Nitrox.Launcher/Models/Converters/ToIntConverter.cs
Normal file
30
Nitrox.Launcher/Models/Converters/ToIntConverter.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
public class ToIntConverter : Converter<ToIntConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
try
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
int i => i,
|
||||
string valueStr when int.TryParse(valueStr, out int result) => result,
|
||||
ICollection list => list.Count,
|
||||
IEnumerable enumerable => enumerable.Cast<object>().Count(),
|
||||
_ => System.Convert.ToInt32(value)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value;
|
||||
}
|
45
Nitrox.Launcher/Models/Converters/ToStringConverter.cs
Normal file
45
Nitrox.Launcher/Models/Converters/ToStringConverter.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data;
|
||||
using NitroxModel.Helper;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the bound value as a string using a specific formatting style.
|
||||
/// </summary>
|
||||
public class ToStringConverter : Converter<ToStringConverter>
|
||||
{
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.GetType().IsEnum)
|
||||
{
|
||||
value = (value as Enum)?.GetAttribute<DescriptionAttribute>()?.Description ?? value.ToString();
|
||||
}
|
||||
|
||||
if (value is not string sourceText)
|
||||
{
|
||||
sourceText = value?.ToString();
|
||||
}
|
||||
|
||||
if (!targetType.IsAssignableTo(typeof(string)) || sourceText == null)
|
||||
{
|
||||
return new BindingNotification(new InvalidCastException(), BindingErrorType.Error);
|
||||
}
|
||||
|
||||
return parameter switch
|
||||
{
|
||||
"upper" => sourceText.ToUpperInvariant(),
|
||||
"lower" => sourceText.ToLowerInvariant(),
|
||||
_ => CultureManager.CultureInfo.TextInfo.ToTitleCase(sourceText.ToLower().Replace("_", " ")),
|
||||
};
|
||||
}
|
||||
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value;
|
||||
}
|
67
Nitrox.Launcher/Models/Converters/TrimConverter.cs
Normal file
67
Nitrox.Launcher/Models/Converters/TrimConverter.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Trims the value when retrieved by code but keeps the spaces in the input field intact for improved UX.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This converter is unconventional (inverted converter) in that the value is converted for the backend.
|
||||
/// The user wants to be able to input spaces while they're typing, but we don't want to save those spaces.
|
||||
/// </remarks>
|
||||
public class TrimConverter : Converter<TrimConverter>
|
||||
{
|
||||
private readonly Lock inOutCacheLock = new();
|
||||
/// <summary>
|
||||
/// Cache to remember the last known untrimmed value (here, the value) for trimmed values (here, the key).
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, string> inOutCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Converts trimmed value back to last known untrimmed value.
|
||||
/// </summary>
|
||||
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string strValue)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
lock (inOutCacheLock)
|
||||
{
|
||||
if (inOutCache.TryGetValue(strValue.Trim(), out string untrimmedValue))
|
||||
{
|
||||
strValue = untrimmedValue;
|
||||
}
|
||||
}
|
||||
return strValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts untrimmed value back to trimmed value.
|
||||
/// </summary>
|
||||
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string strValue)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
if (!strValue.StartsWith(' ') && !strValue.EndsWith(' '))
|
||||
{
|
||||
// It's safe to reset cache now.
|
||||
lock (inOutCacheLock)
|
||||
{
|
||||
inOutCache.Clear();
|
||||
}
|
||||
return strValue;
|
||||
}
|
||||
string trim = strValue.Trim();
|
||||
lock (inOutCacheLock)
|
||||
{
|
||||
inOutCache[trim] = strValue;
|
||||
}
|
||||
return trim;
|
||||
}
|
||||
}
|
87
Nitrox.Launcher/Models/Design/AsyncCommandButtonTagger.cs
Normal file
87
Nitrox.Launcher/Models/Design/AsyncCommandButtonTagger.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Reactive;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Listens for async command changes on buttons to add the chosen classname to, for use with styling.
|
||||
/// </summary>
|
||||
public class AsyncCommandButtonTagger : IDisposable
|
||||
{
|
||||
public string ClassName { get; init; }
|
||||
private readonly ConcurrentDictionary<ICommand, BusyState> states = [];
|
||||
private readonly IDisposable commandChangeSubscription;
|
||||
|
||||
public AsyncCommandButtonTagger(string className)
|
||||
{
|
||||
ClassName = className;
|
||||
commandChangeSubscription = Button.CommandProperty.Changed.Subscribe(new AnonymousObserver<AvaloniaPropertyChangedEventArgs<ICommand>>(ButtonCommandChangedOnNext));
|
||||
|
||||
void ButtonCommandChangedOnNext(AvaloniaPropertyChangedEventArgs<ICommand> args)
|
||||
{
|
||||
if (args.Sender is not Button button)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (args.OldValue.Value is { } oldCommand && states.TryRemove(oldCommand, out BusyState oldState))
|
||||
{
|
||||
oldState.Dispose();
|
||||
}
|
||||
if (args.NewValue.Value is { } newCommand)
|
||||
{
|
||||
states.TryAdd(newCommand, new BusyState(ClassName, newCommand, button));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BusyState : IDisposable
|
||||
{
|
||||
public string ClassName { get; }
|
||||
private ICommand Command { get; }
|
||||
private Button Button { get; }
|
||||
|
||||
public BusyState(string className, ICommand command, Button button)
|
||||
{
|
||||
ClassName = className;
|
||||
Command = command;
|
||||
Button = button;
|
||||
Command.CanExecuteChanged += CommandOnCanExecuteChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Command.CanExecuteChanged -= CommandOnCanExecuteChanged;
|
||||
Button.Classes.Set(ClassName, false);
|
||||
}
|
||||
|
||||
private void CommandOnCanExecuteChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (sender is IAsyncRelayCommand asyncCommand)
|
||||
{
|
||||
Button.Classes.Set(ClassName, asyncCommand.IsRunning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
commandChangeSubscription.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the busy states of buttons.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach ((ICommand _, BusyState value) in states)
|
||||
{
|
||||
value.Dispose();
|
||||
}
|
||||
states.Clear();
|
||||
}
|
||||
}
|
5
Nitrox.Launcher/Models/Design/BackupItem.cs
Normal file
5
Nitrox.Launcher/Models/Design/BackupItem.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public record BackupItem(DateTime BackupDate, string BackupFileName);
|
34
Nitrox.Launcher/Models/Design/EditorField.cs
Normal file
34
Nitrox.Launcher/Models/Design/EditorField.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Reflection;
|
||||
using Avalonia.Collections;
|
||||
using NitroxModel.Serialization;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public record EditorField
|
||||
{
|
||||
public object Value { get; set; }
|
||||
|
||||
public PropertyInfo PropertyInfo { get; init; }
|
||||
|
||||
public AvaloniaList<object> PossibleValues { get; set; }
|
||||
|
||||
public string Description
|
||||
{
|
||||
get
|
||||
{
|
||||
string description = PropertyInfo.GetCustomAttribute<PropertyDescriptionAttribute>()?.Description;
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
description = null;
|
||||
}
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
public EditorField(PropertyInfo propertyInfo, object value, AvaloniaList<object> possibleValues)
|
||||
{
|
||||
PropertyInfo = propertyInfo;
|
||||
Value = value;
|
||||
PossibleValues = possibleValues;
|
||||
}
|
||||
}
|
6
Nitrox.Launcher/Models/Design/IRoutingScreen.cs
Normal file
6
Nitrox.Launcher/Models/Design/IRoutingScreen.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public interface IRoutingScreen
|
||||
{
|
||||
object ActiveViewModel { get; set; }
|
||||
}
|
9
Nitrox.Launcher/Models/Design/KnownGame.cs
Normal file
9
Nitrox.Launcher/Models/Design/KnownGame.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using NitroxModel.Discovery.Models;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public class KnownGame
|
||||
{
|
||||
public string PathToGame { get; init; }
|
||||
public Platform Platform { get; init; }
|
||||
}
|
59
Nitrox.Launcher/Models/Design/MultiDataTemplate.cs
Normal file
59
Nitrox.Launcher/Models/Design/MultiDataTemplate.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
extern alias JB;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Markup.Xaml.Templates;
|
||||
using Avalonia.Metadata;
|
||||
using JB::JetBrains.Annotations;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Selects a <see cref="DataTemplate" /> based on its <see cref="DataTemplate.DataType" />.
|
||||
/// </summary>
|
||||
public class MultiDataTemplate : AvaloniaList<DataTemplate>, IRecyclingDataTemplate
|
||||
{
|
||||
[Content]
|
||||
[UsedImplicitly]
|
||||
public List<DataTemplate> Content { get; set; } = new();
|
||||
|
||||
private readonly Dictionary<Type, Control> typeToControlCache = [];
|
||||
|
||||
public bool Match(object data) => GetTemplateForType(data?.GetType()) != null;
|
||||
|
||||
public Control Build(object data, Control existing)
|
||||
{
|
||||
Type type = data?.GetType();
|
||||
if (type != null && typeToControlCache.TryGetValue(type, out Control control))
|
||||
{
|
||||
return control;
|
||||
}
|
||||
Control build = GetTemplateForType(type)?.Build(data);
|
||||
if (type != null && build != null)
|
||||
{
|
||||
typeToControlCache[type] = build;
|
||||
}
|
||||
|
||||
return build ?? existing;
|
||||
}
|
||||
|
||||
public Control Build(object data) => GetTemplateForType(data.GetType())?.Build(data) ?? new TextBlock { Text = "" };
|
||||
|
||||
private IDataTemplate GetTemplateForType(Type type)
|
||||
{
|
||||
if (type == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
foreach (DataTemplate template in Content)
|
||||
{
|
||||
if (template.DataType?.IsAssignableTo(type) ?? false)
|
||||
{
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
167
Nitrox.Launcher/Models/Design/NitroxAttached.cs
Normal file
167
Nitrox.Launcher/Models/Design/NitroxAttached.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Reactive;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Container class for our attached properties.
|
||||
/// </summary>
|
||||
public class NitroxAttached : AvaloniaObject
|
||||
{
|
||||
public static readonly AttachedProperty<bool> SelectedProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, Interactive, bool>("Selected");
|
||||
public static readonly AttachedProperty<bool> AutoScrollToHomeProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, ScrollViewer, bool>("AutoScrollToHome");
|
||||
public static readonly AttachedProperty<Orientation> PrimaryScrollWheelDirectionProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, ScrollViewer, Orientation>("PrimaryScrollWheelDirection", Orientation.Vertical);
|
||||
public static readonly AttachedProperty<bool> IsNumericInputProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, InputElement, bool>("IsNumericInput");
|
||||
public static readonly AttachedProperty<bool> HasUserInteractedProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, InputElement, bool>("HasUserInteracted");
|
||||
public static readonly AttachedProperty<bool> UseCustomTitleBarProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, Window, bool>("UseCustomTitleBar", true);
|
||||
internal static readonly AsyncCommandButtonTagger AsyncCommandButtonTagger;
|
||||
|
||||
static NitroxAttached()
|
||||
{
|
||||
InputElement.LostFocusEvent.Raised.Subscribe(new AnonymousObserver<(object, RoutedEventArgs)>(HasUserInteractedOnNext));
|
||||
InputElement.TextInputEvent.Raised.Subscribe(new AnonymousObserver<(object, RoutedEventArgs)>(HasUserInteractedOnNext));
|
||||
AsyncCommandButtonTagger = new AsyncCommandButtonTagger("busy");
|
||||
|
||||
void HasUserInteractedOnNext((object Sender, RoutedEventArgs EventArgs) args)
|
||||
{
|
||||
if (args.Sender is InputElement element)
|
||||
{
|
||||
SetHasUserInteracted(element, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetSelected(AvaloniaObject element) => element.GetValue(SelectedProperty);
|
||||
|
||||
public static void SetSelected(AvaloniaObject obj, bool value) => obj.SetValue(SelectedProperty, value);
|
||||
|
||||
public static void SetAutoScrollToHome(AvaloniaObject obj, bool value)
|
||||
{
|
||||
static void VisualAttached(object sender, VisualTreeAttachmentEventArgs e) => (sender as ScrollViewer)?.ScrollToHome();
|
||||
|
||||
obj.SetValue(AutoScrollToHomeProperty, value);
|
||||
if (obj is not Visual visual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (value)
|
||||
{
|
||||
visual.AttachedToVisualTree += VisualAttached;
|
||||
}
|
||||
else
|
||||
{
|
||||
visual.AttachedToVisualTree -= VisualAttached;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetAutoScrollToHome(AvaloniaObject element) => element.GetValue(AutoScrollToHomeProperty);
|
||||
|
||||
public static Orientation GetPrimaryScrollWheelDirection(AvaloniaObject obj) => obj.GetValue(PrimaryScrollWheelDirectionProperty);
|
||||
|
||||
/// <summary>
|
||||
/// Changes scroll wheel input to move scroll viewer left and right if set to <see cref="Orientation.Horizontal"/>.
|
||||
/// </summary>
|
||||
public static void SetPrimaryScrollWheelDirection(AvaloniaObject obj, Orientation orientation)
|
||||
{
|
||||
static void RotatedOrientationWheelHandler(object sender, PointerWheelEventArgs e)
|
||||
{
|
||||
ScrollViewer scrollViewer = sender as ScrollViewer;
|
||||
if (scrollViewer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (GetPrimaryScrollWheelDirection(scrollViewer) == Orientation.Vertical)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Delta.Y < 0)
|
||||
{
|
||||
for (int i = 0; i <= -e.Delta.Y; i++)
|
||||
{
|
||||
scrollViewer.LineRight();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i <= e.Delta.Y; i++)
|
||||
{
|
||||
scrollViewer.LineLeft();
|
||||
}
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
obj.SetValue(PrimaryScrollWheelDirectionProperty, orientation);
|
||||
if (obj is not ScrollViewer scrollViewer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (orientation)
|
||||
{
|
||||
case Orientation.Horizontal:
|
||||
scrollViewer.PointerWheelChanged += RotatedOrientationWheelHandler;
|
||||
break;
|
||||
case Orientation.Vertical:
|
||||
scrollViewer.PointerWheelChanged -= RotatedOrientationWheelHandler;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetIsNumericInput(AvaloniaObject obj, bool value)
|
||||
{
|
||||
static void OnKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Up:
|
||||
case Key.Down:
|
||||
if (sender is not TextBox textBox)
|
||||
{
|
||||
throw new NotSupportedException($"{sender.GetType()} is not supported by property {nameof(IsNumericInputProperty)}");
|
||||
}
|
||||
|
||||
string previousText = textBox.Text;
|
||||
if (int.TryParse(textBox.Text, out int val))
|
||||
{
|
||||
val += e.Key == Key.Up ? 1 : -1;
|
||||
}
|
||||
textBox.Text = Math.Clamp(val, 0, int.MaxValue).ToString();
|
||||
if (textBox.Text.Length > textBox.MaxLength)
|
||||
{
|
||||
textBox.Text = previousText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (obj is not InputElement inputElement)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (value)
|
||||
{
|
||||
inputElement.KeyDown += OnKeyDown;
|
||||
}
|
||||
else
|
||||
{
|
||||
inputElement.KeyDown -= OnKeyDown;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetHasUserInteracted(InputElement input) => input.GetValue(HasUserInteractedProperty);
|
||||
|
||||
public static void SetHasUserInteracted(InputElement input, bool value) => input.SetValue(HasUserInteractedProperty, value);
|
||||
|
||||
public static bool GetUseCustomTitleBar(Window window) => window.GetValue(UseCustomTitleBarProperty);
|
||||
|
||||
public static void SetUseCustomTitleBar(Window window, bool value) => window.SetValue(UseCustomTitleBarProperty, value);
|
||||
}
|
12
Nitrox.Launcher/Models/Design/NitroxBlog.cs
Normal file
12
Nitrox.Launcher/Models/Design/NitroxBlog.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public sealed record NitroxBlog(string Title, DateOnly Date, string Url, Bitmap Image)
|
||||
{
|
||||
public NitroxBlog() : this("", default, "", null)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
6
Nitrox.Launcher/Models/Design/NitroxChangelog.cs
Normal file
6
Nitrox.Launcher/Models/Design/NitroxChangelog.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
[Serializable]
|
||||
public record NitroxChangelog(string Version, DateTime Released, string PatchNotes);
|
28
Nitrox.Launcher/Models/Design/NotificationItem.cs
Normal file
28
Nitrox.Launcher/Models/Design/NotificationItem.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Controls.Notifications;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public partial class NotificationItem : ObservableObject
|
||||
{
|
||||
public string Message { get; }
|
||||
public NotificationType Type { get; }
|
||||
public ICommand CloseCommand { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool dismissed;
|
||||
|
||||
public NotificationItem()
|
||||
{
|
||||
}
|
||||
|
||||
public NotificationItem(string message, NotificationType type = NotificationType.Information, ICommand closeCommand = null)
|
||||
{
|
||||
Message = message;
|
||||
Type = type;
|
||||
CloseCommand = closeCommand ?? new RelayCommand(() => WeakReferenceMessenger.Default.Send(new NotificationCloseMessage(this)));
|
||||
}
|
||||
}
|
8
Nitrox.Launcher/Models/Design/OutputLine.cs
Normal file
8
Nitrox.Launcher/Models/Design/OutputLine.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public record OutputLine
|
||||
{
|
||||
public string Timestamp { get; init; }
|
||||
public string LogText { get; init; }
|
||||
public OutputLineType Type { get; init; }
|
||||
}
|
10
Nitrox.Launcher/Models/Design/OutputLineType.cs
Normal file
10
Nitrox.Launcher/Models/Design/OutputLineType.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public enum OutputLineType
|
||||
{
|
||||
INFO_LOG,
|
||||
DEBUG_LOG,
|
||||
WARNING_LOG,
|
||||
ERROR_LOG,
|
||||
COMMAND
|
||||
}
|
20
Nitrox.Launcher/Models/Design/RoutingScreen.cs
Normal file
20
Nitrox.Launcher/Models/Design/RoutingScreen.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public partial class RoutingScreen : ObservableObject, IRoutingScreen
|
||||
{
|
||||
[ObservableProperty]
|
||||
private object activeViewModel;
|
||||
|
||||
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ActiveViewModel))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new ViewShownMessage(ActiveViewModel));
|
||||
}
|
||||
base.OnPropertyChanged(e);
|
||||
}
|
||||
}
|
413
Nitrox.Launcher/Models/Design/ServerEntry.cs
Normal file
413
Nitrox.Launcher/Models/Design/ServerEntry.cs
Normal file
@@ -0,0 +1,413 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Nitrox.Launcher.Models.Exceptions;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.Helper;
|
||||
using NitroxModel.Logger;
|
||||
using NitroxModel.Serialization;
|
||||
using NitroxModel.Server;
|
||||
using NitroxServer.Serialization;
|
||||
using NitroxServer.Serialization.World;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Manager object for a server. Used to start/stop a server and change its settings.
|
||||
/// </summary>
|
||||
public partial class ServerEntry : ObservableObject
|
||||
{
|
||||
public const string DEFAULT_SERVER_ICON_NAME = "servericon.png";
|
||||
public const string DEFAULT_SERVER_CONFIG_NAME = "server.cfg";
|
||||
|
||||
private static readonly SubnauticaServerConfig serverDefaults = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private bool allowCommands = !serverDefaults.DisableConsole;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool allowLanDiscovery = serverDefaults.LANDiscoveryEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool autoPortForward = serverDefaults.AutoPortForward;
|
||||
|
||||
[ObservableProperty]
|
||||
private int autoSaveInterval = serverDefaults.SaveInterval / 1000;
|
||||
|
||||
[ObservableProperty]
|
||||
private NitroxGameMode gameMode = serverDefaults.GameMode;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isEmbedded;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isNewServer = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isOnline;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime lastAccessedTime = DateTime.Now;
|
||||
|
||||
[ObservableProperty]
|
||||
private int maxPlayers = serverDefaults.MaxConnections;
|
||||
|
||||
[ObservableProperty]
|
||||
private string name;
|
||||
|
||||
[ObservableProperty]
|
||||
private string password;
|
||||
|
||||
[ObservableProperty]
|
||||
private Perms playerPermissions = serverDefaults.DefaultPlayerPerm;
|
||||
|
||||
[ObservableProperty]
|
||||
private int players;
|
||||
|
||||
[ObservableProperty]
|
||||
private int port = serverDefaults.ServerPort;
|
||||
|
||||
[ObservableProperty]
|
||||
private string seed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Bitmap serverIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
private Version version = NitroxEnvironment.Version;
|
||||
|
||||
internal ServerProcess Process { get; private set; }
|
||||
|
||||
public static ServerEntry FromDirectory(string saveDir)
|
||||
{
|
||||
ServerEntry result = new();
|
||||
return result.RefreshFromDirectory(saveDir) ? result : null;
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(IsOnline):
|
||||
WeakReferenceMessenger.Default.Send(new ServerStatusMessage(this, IsOnline));
|
||||
break;
|
||||
}
|
||||
base.OnPropertyChanged(e);
|
||||
}
|
||||
|
||||
public static ServerEntry CreateNew(string saveDir, NitroxGameMode saveGameMode)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(saveDir, nameof(saveDir));
|
||||
|
||||
Directory.CreateDirectory(saveDir);
|
||||
|
||||
SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir);
|
||||
string fileEnding = config.SerializerMode switch
|
||||
{
|
||||
ServerSerializerMode.JSON => ServerJsonSerializer.FILE_ENDING,
|
||||
ServerSerializerMode.PROTOBUF => ServerProtoBufSerializer.FILE_ENDING,
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
|
||||
File.WriteAllText(Path.Combine(saveDir, $"Version{fileEnding}"), null);
|
||||
using (config.Update(saveDir))
|
||||
{
|
||||
config.GameMode = saveGameMode;
|
||||
}
|
||||
|
||||
return FromDirectory(saveDir);
|
||||
}
|
||||
|
||||
public bool RefreshFromDirectory(string saveDir)
|
||||
{
|
||||
if (!File.Exists(Path.Combine(saveDir, DEFAULT_SERVER_CONFIG_NAME)))
|
||||
{
|
||||
Log.Warn($"Tried loading invalid save directory at '{saveDir}'");
|
||||
return false;
|
||||
}
|
||||
|
||||
Bitmap serverIcon = null;
|
||||
string serverIconPath = Path.Combine(saveDir, DEFAULT_SERVER_ICON_NAME);
|
||||
if (File.Exists(serverIconPath))
|
||||
{
|
||||
serverIcon = new Bitmap(Path.Combine(saveDir, DEFAULT_SERVER_ICON_NAME));
|
||||
}
|
||||
|
||||
SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir);
|
||||
string fileEnding = config.SerializerMode switch
|
||||
{
|
||||
ServerSerializerMode.JSON => ServerJsonSerializer.FILE_ENDING,
|
||||
ServerSerializerMode.PROTOBUF => ServerProtoBufSerializer.FILE_ENDING,
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
|
||||
string saveFileVersion = Path.Combine(saveDir, $"Version{fileEnding}");
|
||||
if (!File.Exists(saveFileVersion))
|
||||
{
|
||||
Log.Warn($"Tried loading invalid save directory at '{saveDir}', Version file is missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
Version version;
|
||||
using (FileStream stream = new(saveFileVersion, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
version = config.SerializerMode switch
|
||||
{
|
||||
ServerSerializerMode.JSON => new ServerJsonSerializer().Deserialize<SaveFileVersion>(stream)?.Version ?? NitroxEnvironment.Version,
|
||||
ServerSerializerMode.PROTOBUF => new ServerProtoBufSerializer().Deserialize<SaveFileVersion>(stream)?.Version ?? NitroxEnvironment.Version,
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
}
|
||||
|
||||
Name = Path.GetFileName(saveDir);
|
||||
ServerIcon = serverIcon;
|
||||
Password = config.ServerPassword;
|
||||
Seed = config.Seed;
|
||||
GameMode = config.GameMode;
|
||||
PlayerPermissions = config.DefaultPlayerPerm;
|
||||
AutoSaveInterval = config.SaveInterval / 1000;
|
||||
MaxPlayers = config.MaxConnections;
|
||||
Port = config.ServerPort;
|
||||
AutoPortForward = config.AutoPortForward;
|
||||
AllowLanDiscovery = config.LANDiscoveryEnabled;
|
||||
AllowCommands = !config.DisableConsole;
|
||||
IsNewServer = !File.Exists(Path.Combine(saveDir, $"PlayerData{fileEnding}"));
|
||||
Version = version;
|
||||
IsEmbedded = config.IsEmbedded || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); // Force embedded on MacOS
|
||||
LastAccessedTime = File.GetLastWriteTime(File.Exists(Path.Combine(saveDir, $"PlayerData{fileEnding}"))
|
||||
?
|
||||
// This file is affected by server saving
|
||||
Path.Combine(saveDir, $"PlayerData{fileEnding}")
|
||||
:
|
||||
// If the above file doesn't exist (server was never ran), use the Version file instead
|
||||
Path.Combine(saveDir, $"Version{fileEnding}"));
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Start(string savesDir)
|
||||
{
|
||||
if (!Directory.Exists(savesDir))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Directory '{savesDir}' not found");
|
||||
}
|
||||
|
||||
if (Process?.IsRunning ?? false)
|
||||
{
|
||||
throw new DuplicateSingularApplicationException("Nitrox Server");
|
||||
}
|
||||
|
||||
// Start server and add notify when server closed.
|
||||
Process = ServerProcess.Start(Path.Combine(savesDir, Name), () => Dispatcher.UIThread.InvokeAsync(StopAsync), IsEmbedded);
|
||||
|
||||
IsNewServer = false;
|
||||
IsOnline = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task<bool> StopAsync()
|
||||
{
|
||||
if (Process is not { IsRunning: true })
|
||||
{
|
||||
IsOnline = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await Process.CloseAsync())
|
||||
{
|
||||
IsOnline = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void OpenSaveFolder()
|
||||
{
|
||||
System.Diagnostics.Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name),
|
||||
Verb = "open",
|
||||
UseShellExecute = true
|
||||
})?.Dispose();
|
||||
}
|
||||
|
||||
internal partial class ServerProcess : IDisposable
|
||||
{
|
||||
private NamedPipeClientStream commandStream;
|
||||
private OutputLineType lastOutputType;
|
||||
private Process serverProcess;
|
||||
|
||||
[GeneratedRegex(@"^\[(?<timestamp>\d{2}:\d{2}:\d{2}\.\d{3})\]\s\[(?<level>\w+)\](?<logText>.*)?$")]
|
||||
private static partial Regex OutputLineRegex { get; }
|
||||
|
||||
public bool IsRunning => !serverProcess?.HasExited ?? false;
|
||||
public AvaloniaList<OutputLine> Output { get; } = [];
|
||||
|
||||
private ServerProcess(string saveDir, Action onExited, bool isEmbeddedMode = false)
|
||||
{
|
||||
string serverExeName = "NitroxServer-Subnautica.exe";
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
serverExeName = "NitroxServer-Subnautica";
|
||||
}
|
||||
string serverFile = Path.Combine(NitroxUser.ExecutableRootPath, serverExeName);
|
||||
ProcessStartInfo startInfo = new(serverFile)
|
||||
{
|
||||
WorkingDirectory = NitroxUser.ExecutableRootPath,
|
||||
ArgumentList =
|
||||
{
|
||||
"--save",
|
||||
Path.GetFileName(saveDir)
|
||||
},
|
||||
RedirectStandardOutput = isEmbeddedMode,
|
||||
RedirectStandardError = isEmbeddedMode,
|
||||
RedirectStandardInput = isEmbeddedMode,
|
||||
WindowStyle = isEmbeddedMode ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal,
|
||||
CreateNoWindow = isEmbeddedMode
|
||||
};
|
||||
if (isEmbeddedMode)
|
||||
{
|
||||
startInfo.ArgumentList.Add("--embedded");
|
||||
}
|
||||
Log.Info($"Starting server:{Environment.NewLine}File: {startInfo.FileName}{Environment.NewLine}Working directory: {startInfo.WorkingDirectory}{Environment.NewLine}Arguments: {string.Join(", ", startInfo.ArgumentList)}");
|
||||
|
||||
serverProcess = System.Diagnostics.Process.Start(startInfo);
|
||||
if (serverProcess != null)
|
||||
{
|
||||
serverProcess.EnableRaisingEvents = true; // Required for 'Exited' event from process.
|
||||
if (isEmbeddedMode)
|
||||
{
|
||||
serverProcess.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Match match = OutputLineRegex.Match(args.Data);
|
||||
if (match.Success)
|
||||
{
|
||||
OutputLine outputLine = new()
|
||||
{
|
||||
Timestamp = $"[{match.Groups["timestamp"].ValueSpan}]",
|
||||
LogText = match.Groups["logText"].ValueSpan.Trim().ToString(),
|
||||
Type = match.Groups["level"].ValueSpan switch
|
||||
{
|
||||
"DBG" => OutputLineType.DEBUG_LOG,
|
||||
"WRN" => OutputLineType.WARNING_LOG,
|
||||
"ERR" => OutputLineType.ERROR_LOG,
|
||||
_ => OutputLineType.INFO_LOG
|
||||
}
|
||||
};
|
||||
lastOutputType = outputLine.Type;
|
||||
Output.Add(outputLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
Output.Add(new OutputLine
|
||||
{
|
||||
Timestamp = "",
|
||||
LogText = args.Data,
|
||||
Type = lastOutputType
|
||||
});
|
||||
}
|
||||
};
|
||||
serverProcess.BeginOutputReadLine();
|
||||
}
|
||||
serverProcess.Exited += (_, _) =>
|
||||
{
|
||||
onExited?.Invoke();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static ServerProcess Start(string saveDir, Action onExited, bool isEmbedded) => new(saveDir, onExited, isEmbedded);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to close the server gracefully with a timeout of 30 seconds. If it fails, returns false.
|
||||
/// </summary>
|
||||
public async Task<bool> CloseAsync()
|
||||
{
|
||||
using CancellationTokenSource ctsCloseTimeout = new(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
if (!await SendCommandAsync("stop"))
|
||||
{
|
||||
await Task.Delay(100, ctsCloseTimeout.Token);
|
||||
}
|
||||
} while (IsRunning && !ctsCloseTimeout.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
if (IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SendCommandAsync(string command)
|
||||
{
|
||||
if (!IsRunning || string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
commandStream ??= new NamedPipeClientStream(".", $"Nitrox Server {serverProcess.Id}", PipeDirection.Out, PipeOptions.Asynchronous);
|
||||
if (!commandStream.IsConnected)
|
||||
{
|
||||
await commandStream.ConnectAsync(5000);
|
||||
}
|
||||
byte[] commandBytes = Encoding.UTF8.GetBytes(command);
|
||||
await commandStream.WriteAsync(BitConverter.GetBytes((uint)commandBytes.Length));
|
||||
await commandStream.WriteAsync(commandBytes);
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// ignored - "broken pipe" or "socket shutdown"
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
commandStream?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
serverProcess?.Dispose();
|
||||
serverProcess = null;
|
||||
}
|
||||
}
|
||||
}
|
18
Nitrox.Launcher/Models/Design/ServerStartEventArgs.cs
Normal file
18
Nitrox.Launcher/Models/Design/ServerStartEventArgs.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public sealed class ServerStartEventArgs : EventArgs
|
||||
{
|
||||
public bool IsEmbedded { get; }
|
||||
|
||||
public ServerStartEventArgs(bool embedded)
|
||||
{
|
||||
IsEmbedded = embedded;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[ServerStartEventArgs - IsEmbedded: {IsEmbedded}]";
|
||||
}
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Exceptions;
|
||||
|
||||
public class DuplicateSingularApplicationException : Exception
|
||||
{
|
||||
public DuplicateSingularApplicationException(string applicationName) : base($"An instance of {applicationName} is already running")
|
||||
{
|
||||
}
|
||||
}
|
63
Nitrox.Launcher/Models/Extensions/CloseByUserExtensions.cs
Normal file
63
Nitrox.Launcher/Models/Extensions/CloseByUserExtensions.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia doesn't provide a public API to close the window non-programmatically so this is a hack to support it.
|
||||
/// </summary>
|
||||
public static class CloseByUserExtensions
|
||||
{
|
||||
private static readonly Dictionary<Window, bool> isClosingByUser = [];
|
||||
|
||||
/// <summary>
|
||||
/// Closes the window non-programmatically (by user).
|
||||
/// </summary>
|
||||
public static void CloseByUser(this Window window)
|
||||
{
|
||||
if (window == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.Closed += WindowOnClosed;
|
||||
isClosingByUser[window] = true;
|
||||
window.Close();
|
||||
|
||||
static void WindowOnClosed(object sender, EventArgs e)
|
||||
{
|
||||
if (sender is not Window window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.Closed -= WindowOnClosed;
|
||||
isClosingByUser.Remove(window);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the window programmatically.
|
||||
/// </summary>
|
||||
public static void CloseByCode(this Window window)
|
||||
{
|
||||
if (window == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
isClosingByUser[window] = false;
|
||||
window.Close();
|
||||
}
|
||||
|
||||
public static bool IsClosingByUser(this Window closingWindow, WindowClosingEventArgs closingArgs = null)
|
||||
{
|
||||
if (closingWindow is not null && isClosingByUser.TryGetValue(closingWindow, out bool isByUser))
|
||||
{
|
||||
return isByUser;
|
||||
}
|
||||
if (closingArgs is { IsProgrammatic: false })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
15
Nitrox.Launcher/Models/Extensions/CollectionExtensions.cs
Normal file
15
Nitrox.Launcher/Models/Extensions/CollectionExtensions.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class CollectionExtensions
|
||||
{
|
||||
public static void AddRange<T>(this Collection<T> collection, params IEnumerable<T> items)
|
||||
{
|
||||
foreach (T item in items)
|
||||
{
|
||||
collection.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
53
Nitrox.Launcher/Models/Extensions/DialogServiceExtensions.cs
Normal file
53
Nitrox.Launcher/Models/Extensions/DialogServiceExtensions.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using HanumanInstitute.MvvmDialogs;
|
||||
using Nitrox.Launcher.Models.Utils;
|
||||
using Nitrox.Launcher.ViewModels;
|
||||
using Nitrox.Launcher.ViewModels.Abstract;
|
||||
using NitroxModel.Logger;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class DialogServiceExtensions
|
||||
{
|
||||
public static async Task<T> ShowAsync<T, TExtra>(this IDialogService dialogService, Action<T, TExtra> setup = null, TExtra extraParameter = default) where T : ModalViewModelBase
|
||||
{
|
||||
try
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dialogService);
|
||||
// DataContext must be accessed on the UI thread, or it'll throw error.
|
||||
INotifyPropertyChanged owner = await Dispatcher.UIThread.InvokeAsync(() => AppViewLocator.MainWindow?.DataContext as INotifyPropertyChanged);
|
||||
if (owner == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Expected {nameof(AppViewLocator.MainWindow)}.{nameof(AppViewLocator.MainWindow.DataContext)} to not be null");
|
||||
}
|
||||
|
||||
T viewModel = dialogService.CreateViewModel<T>();
|
||||
setup?.Invoke(viewModel, extraParameter);
|
||||
bool? result = await dialogService.ShowDialogAsync<T>(owner, viewModel);
|
||||
if (result == true)
|
||||
{
|
||||
return viewModel;
|
||||
}
|
||||
return default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, $"Failed to show dialog for ViewModel {typeof(T).FullName}");
|
||||
LauncherNotifier.Error(ex.Message);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<T> ShowAsync<T>(this IDialogService dialogService, Action<T> setup = null) where T : ModalViewModelBase => dialogService.ShowAsync<T, Action<T>>((model, act) => act?.Invoke(model), setup);
|
||||
|
||||
public static Task ShowErrorAsync(this IDialogService dialogService, Exception exception, string title = null, string description = null) =>
|
||||
dialogService.ShowAsync<DialogBoxViewModel>(model =>
|
||||
{
|
||||
model.Title = title ?? "Error";
|
||||
model.Description = string.IsNullOrWhiteSpace(description) ? exception.ToString() : $"{description}{Environment.NewLine}{exception}";
|
||||
model.ButtonOptions = ButtonOptions.OkClipboard;
|
||||
});
|
||||
}
|
17
Nitrox.Launcher/Models/Extensions/KeyValueStoreExtensions.cs
Normal file
17
Nitrox.Launcher/Models/Extensions/KeyValueStoreExtensions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using NitroxModel.Helper;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class KeyValueStoreExtensions
|
||||
{
|
||||
public static string GetSubnauticaLaunchArguments(this IKeyValueStore store, string defaultValue = "-vrmode none") => store == null ? defaultValue : store.GetValue("SubnauticaLaunchArguments", defaultValue);
|
||||
|
||||
public static void SetSubnauticaLaunchArguments(this IKeyValueStore store, string value)
|
||||
{
|
||||
if (store == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
store.SetValue("SubnauticaLaunchArguments", value);
|
||||
}
|
||||
}
|
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class MessageReceiverExtensions
|
||||
{
|
||||
public static void RegisterMessageListener<T, TReceiver>(this TReceiver receiver, Func<T, TReceiver, Task> asyncFunc) where T : class where TReceiver : IMessageReceiver
|
||||
{
|
||||
if (WeakReferenceMessenger.Default.IsRegistered<T>(receiver))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Unregister<T>(receiver);
|
||||
}
|
||||
WeakReferenceMessenger.Default.Register<T>(receiver, (_, message) => asyncFunc(message, receiver));
|
||||
}
|
||||
|
||||
public static void RegisterMessageListener<T, TReceiver>(this TReceiver receiver, Action<T, TReceiver> action) where T : class where TReceiver : IMessageReceiver
|
||||
{
|
||||
if (WeakReferenceMessenger.Default.IsRegistered<T>(receiver))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Unregister<T>(receiver);
|
||||
}
|
||||
WeakReferenceMessenger.Default.Register<T>(receiver, (_, message) => action(message, receiver));
|
||||
}
|
||||
}
|
33
Nitrox.Launcher/Models/Extensions/ProcessExExtensions.cs
Normal file
33
Nitrox.Launcher/Models/Extensions/ProcessExExtensions.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using NitroxModel.Platforms.OS.Shared;
|
||||
using NitroxModel.Platforms.OS.Windows;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Extensions;
|
||||
|
||||
public static class ProcessExExtensions
|
||||
{
|
||||
public static void SetForegroundWindowAndRestore(this ProcessEx process)
|
||||
{
|
||||
if (Avalonia.Controls.Design.IsDesignMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
WindowsApi.BringProcessToFront(process.MainWindowHandle);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// xdotool sends an XEvent to X11 window manager on Linux systems.
|
||||
string command = $"xdotool windowactivate $(xdotool search --pid {process.Id} --onlyvisible --desktop '$(xdotool get_desktop)' --name 'nitrox launcher')";
|
||||
using Process proc = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "sh",
|
||||
ArgumentList = { "-c", command },
|
||||
});
|
||||
|
||||
// TODO: Support "bring to front" on Wayland window manager.
|
||||
}
|
||||
}
|
||||
}
|
88
Nitrox.Launcher/Models/Extensions/ScreenExtensions.cs
Normal file
88
Nitrox.Launcher/Models/Extensions/ScreenExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
@@ -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() ?? "";
|
||||
}
|
||||
}
|
15
Nitrox.Launcher/Models/Extensions/StringExtensions.cs
Normal file
15
Nitrox.Launcher/Models/Extensions/StringExtensions.cs
Normal 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();
|
||||
}
|
||||
}
|
34
Nitrox.Launcher/Models/Extensions/VisualExtensions.cs
Normal file
34
Nitrox.Launcher/Models/Extensions/VisualExtensions.cs
Normal 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;
|
||||
}
|
5
Nitrox.Launcher/Models/IMessageReceiver.cs
Normal file
5
Nitrox.Launcher/Models/IMessageReceiver.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models;
|
||||
|
||||
public interface IMessageReceiver : IDisposable;
|
16
Nitrox.Launcher/Models/Messages.cs
Normal file
16
Nitrox.Launcher/Models/Messages.cs
Normal 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);
|
274
Nitrox.Launcher/Models/Services/ServerService.cs
Normal file
274
Nitrox.Launcher/Models/Services/ServerService.cs
Normal 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);
|
||||
}
|
||||
}
|
183
Nitrox.Launcher/Models/Styles/Nitrox.axaml
Normal file
183
Nitrox.Launcher/Models/Styles/Nitrox.axaml
Normal 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>
|
51
Nitrox.Launcher/Models/Styles/Palette/Nitrox.axaml
Normal file
51
Nitrox.Launcher/Models/Styles/Palette/Nitrox.axaml
Normal 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>
|
191
Nitrox.Launcher/Models/Styles/Theme/ButtonStyle.axaml
Normal file
191
Nitrox.Launcher/Models/Styles/Theme/ButtonStyle.axaml
Normal 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>
|
148
Nitrox.Launcher/Models/Styles/Theme/CheckBoxStyle.axaml
Normal file
148
Nitrox.Launcher/Models/Styles/Theme/CheckBoxStyle.axaml
Normal 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>
|
186
Nitrox.Launcher/Models/Styles/Theme/ComboBoxStyle.axaml
Normal file
186
Nitrox.Launcher/Models/Styles/Theme/ComboBoxStyle.axaml
Normal 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>
|
152
Nitrox.Launcher/Models/Styles/Theme/ExpanderStyle.axaml
Normal file
152
Nitrox.Launcher/Models/Styles/Theme/ExpanderStyle.axaml
Normal 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>
|
@@ -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>
|
221
Nitrox.Launcher/Models/Styles/Theme/RadioButtonStyle.axaml
Normal file
221
Nitrox.Launcher/Models/Styles/Theme/RadioButtonStyle.axaml
Normal 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>
|
301
Nitrox.Launcher/Models/Styles/Theme/ScrollViewerStyle.axaml
Normal file
301
Nitrox.Launcher/Models/Styles/Theme/ScrollViewerStyle.axaml
Normal 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>
|
38
Nitrox.Launcher/Models/Styles/Theme/TextBlockStyle.axaml
Normal file
38
Nitrox.Launcher/Models/Styles/Theme/TextBlockStyle.axaml
Normal 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>
|
167
Nitrox.Launcher/Models/Styles/Theme/TextBoxStyle.axaml
Normal file
167
Nitrox.Launcher/Models/Styles/Theme/TextBoxStyle.axaml
Normal 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>
|
29
Nitrox.Launcher/Models/Styles/Theme/ToolTipStyle.axaml
Normal file
29
Nitrox.Launcher/Models/Styles/Theme/ToolTipStyle.axaml
Normal 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>
|
@@ -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>
|
85
Nitrox.Launcher/Models/Utils/AssetHelper.cs
Normal file
85
Nitrox.Launcher/Models/Utils/AssetHelper.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
131
Nitrox.Launcher/Models/Utils/CacheFile.cs
Normal file
131
Nitrox.Launcher/Models/Utils/CacheFile.cs
Normal 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();
|
||||
}
|
||||
}
|
194
Nitrox.Launcher/Models/Utils/Downloader.cs
Normal file
194
Nitrox.Launcher/Models/Utils/Downloader.cs
Normal 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;
|
||||
}
|
||||
}
|
67
Nitrox.Launcher/Models/Utils/GameInspect.cs
Normal file
67
Nitrox.Launcher/Models/Utils/GameInspect.cs
Normal 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;
|
||||
}
|
||||
}
|
37
Nitrox.Launcher/Models/Utils/LauncherNotifier.cs
Normal file
37
Nitrox.Launcher/Models/Utils/LauncherNotifier.cs
Normal 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)));
|
||||
}
|
||||
}
|
202
Nitrox.Launcher/Models/Utils/NitroxEntryPatch.cs
Normal file
202
Nitrox.Launcher/Models/Utils/NitroxEntryPatch.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
70
Nitrox.Launcher/Models/Utils/ProcessUtils.cs
Normal file
70
Nitrox.Launcher/Models/Utils/ProcessUtils.cs
Normal 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"
|
||||
});
|
||||
}
|
||||
}
|
12
Nitrox.Launcher/Models/Utils/QModHelper.cs
Normal file
12
Nitrox.Launcher/Models/Utils/QModHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
29
Nitrox.Launcher/Models/Validators/BackupAttribute.cs
Normal file
29
Nitrox.Launcher/Models/Validators/BackupAttribute.cs
Normal 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;
|
||||
}
|
||||
}
|
28
Nitrox.Launcher/Models/Validators/FileNameAttribute.cs
Normal file
28
Nitrox.Launcher/Models/Validators/FileNameAttribute.cs
Normal 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;
|
||||
}
|
||||
}
|
58
Nitrox.Launcher/Models/Validators/NitroxUniqueSaveName.cs
Normal file
58
Nitrox.Launcher/Models/Validators/NitroxUniqueSaveName.cs
Normal 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;
|
||||
}
|
||||
}
|
@@ -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.");
|
||||
}
|
||||
}
|
19
Nitrox.Launcher/Models/Validators/NotEndsWithAttribute.cs
Normal file
19
Nitrox.Launcher/Models/Validators/NotEndsWithAttribute.cs
Normal 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;
|
||||
}
|
||||
}
|
31
Nitrox.Launcher/Models/Validators/SaveNameAttribute.cs
Normal file
31
Nitrox.Launcher/Models/Validators/SaveNameAttribute.cs
Normal 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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user