first commit
This commit is contained in:
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);
|
||||
}
|
Reference in New Issue
Block a user