first commit

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

View File

@@ -0,0 +1,94 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Skia;
using SkiaSharp;
namespace Nitrox.Launcher.Models.Controls;
/// <summary>
/// Draws a blur filter over the already rendered content.
/// </summary>
/// <remarks>
/// Based off of GrayscaleControl
/// </remarks>
public sealed class BlurControl : Decorator
{
public static readonly StyledProperty<float> BlurStrengthProperty =
AvaloniaProperty.Register<BlurControl, float>(nameof(BlurStrength), 5);
/// <summary>
/// Sets or gets how strong the blur should be. Defaults to 5.
/// </summary>
public float BlurStrength
{
get => GetValue(BlurStrengthProperty);
set => SetValue(BlurStrengthProperty, value);
}
static BlurControl()
{
ClipToBoundsProperty.OverrideDefaultValue<BlurControl>(true);
AffectsRender<BlurControl>(OpacityProperty);
AffectsRender<BlurControl>(BlurStrengthProperty);
}
public override void Render(DrawingContext context)
{
context.Custom(new BlurBehindRenderOperation((byte)Math.Round(byte.MaxValue * Opacity), BlurStrength, new Rect(default, Bounds.Size)));
}
private sealed record BlurBehindRenderOperation : ICustomDrawOperation
{
private readonly Rect bounds;
private readonly byte opacity;
private readonly float strength;
public Rect Bounds => bounds;
public BlurBehindRenderOperation(byte opacity, float strength, Rect bounds)
{
this.opacity = opacity;
this.strength = strength;
this.bounds = bounds;
}
public void Dispose()
{
}
public bool HitTest(Point p) => bounds.Contains(p);
public void Render(ImmediateDrawingContext context)
{
ISkiaSharpApiLeaseFeature leaseFeature = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
if (leaseFeature == null)
{
return;
}
using ISkiaSharpApiLease skia = leaseFeature.Lease();
if (!skia.SkCanvas.TotalMatrix.TryInvert(out SKMatrix currentInvertedTransform))
{
return;
}
if (skia.SkSurface == null)
{
return;
}
using SKImage backgroundSnapshot = skia.SkSurface.Snapshot();
using SKShader backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, currentInvertedTransform);
using SKImageFilter blurFilter = SKImageFilter.CreateBlur(strength, strength);
using SKPaint paint = new();
paint.Shader = backdropShader;
paint.ImageFilter = blurFilter;
paint.Color = new SKColor(0, 0, 0, opacity);
skia.SkCanvas.DrawRect(0, 0, (float)bounds.Width, (float)bounds.Height, paint);
}
public bool Equals(ICustomDrawOperation other) => Equals(other as BlurBehindRenderOperation);
}
}

View File

@@ -0,0 +1,121 @@
<ResourceDictionary
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:Nitrox.Launcher.Models.Controls"
xmlns:converters="clr-namespace:Nitrox.Launcher.Models.Converters">
<Design.PreviewWith>
<StackPanel Width="200">
<controls:CustomTitlebar
Background="IndianRed"
CanMaximize="False"
CanMinimize="False" />
<controls:CustomTitlebar
Background="ForestGreen"
CanMaximize="True"
CanMinimize="False" />
<controls:CustomTitlebar Background="CornflowerBlue" CanMaximize="False" />
<controls:CustomTitlebar Background="Violet" />
</StackPanel>
</Design.PreviewWith>
<ControlTheme x:Key="{x:Type controls:CustomTitlebar}" TargetType="controls:CustomTitlebar">
<Setter Property="Template">
<ControlTemplate>
<Panel Background="Transparent">
<Border
Padding="5,2"
HorizontalAlignment="Left"
IsVisible="{TemplateBinding ShowTitle}">
<TextBlock
VerticalAlignment="Stretch"
Text="{Binding $parent[Window].Title}"
TextAlignment="Center" />
</Border>
<StackPanel>
<Button
x:Name="PART_MinimizeButton"
Classes.leftOff1="{x:True}"
Classes.leftOff2="{x:True}"
Command="{Binding MinimizeCommand, RelativeSource={RelativeSource TemplatedParent}}"
IsVisible="{TemplateBinding CanMinimize}">
<Svg Classes="theme" Path="/Assets/Icons/minimize.svg" />
</Button>
<Button
x:Name="PART_MaximizeButton"
Classes.leftOff1="{Binding !#PART_MinimizeButton.IsVisible}"
Classes.leftOff2="{x:True}"
Command="{Binding ToggleMaximizeCommand, RelativeSource={RelativeSource TemplatedParent}}">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<TemplateBinding Property="CanMaximize" />
<Binding Path="$parent[Window].CanResize" />
</MultiBinding>
</Button.IsVisible>
<Svg
Classes="theme"
Classes.maximize="{Binding $parent[Window].WindowState, Converter={converters:EqualityConverter}, ConverterParameter={x:Static WindowState.Normal}}"
Classes.restore="{Binding $parent[Window].WindowState, Converter={converters:EqualityConverter}, ConverterParameter={x:Static WindowState.Maximized}}" />
</Button>
<Button
x:Name="PART_CloseButton"
Classes="close"
Classes.leftOff1="{Binding !#PART_MinimizeButton.IsVisible}"
Classes.leftOff2="{Binding !#PART_MaximizeButton.IsVisible}"
Command="{Binding CloseCommand, RelativeSource={RelativeSource TemplatedParent}}">
<Svg Classes="theme" Path="/Assets/Icons/close.svg" />
</Button>
</StackPanel>
</Panel>
</ControlTemplate>
</Setter>
<!-- Default template values -->
<Setter Property="Background" Value="Black" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="ZIndex" Value="100" />
<Setter Property="Height" Value="28" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="ShowTitle" Value="False" />
<Style Selector="^ /template/ StackPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="HorizontalAlignment" Value="Right" />
<Style Selector="^ > Button">
<Setter Property="Width" Value="46" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Opacity" Value="1" />
<!-- This selector force overrides button style. TODO: fix ButtonStyle.axaml so this isn't necessary and move setters out of this selector. -->
<Style Selector="^:nth-child(1n)">
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
<!-- Button[IsVisible=True]:nth-child(1) doesn't work to filter only visible buttons. Here, leftOff1 is used to check if previous button is on and leftOff2 is the next one over that. -->
<Style Selector="^.leftOff1.leftOff2">
<Setter Property="CornerRadius" Value="0 0 0 5" />
</Style>
<Style Selector="^ > :is(Control)">
<Setter Property="Height" Value="11" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="^.close:pointerover">
<Setter Property="Background" Value="Red" />
</Style>
</Style>
<Style Selector="^ Svg.maximize">
<Setter Property="Path" Value="/Assets/Icons/maximize.svg" />
</Style>
<Style Selector="^ Svg.restore">
<Setter Property="Path" Value="/Assets/Icons/restore.svg" />
</Style>
</Style>
</ControlTheme>
</ResourceDictionary>

View File

@@ -0,0 +1,113 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using CommunityToolkit.Mvvm.Input;
namespace Nitrox.Launcher.Models.Controls;
public partial class CustomTitlebar : TemplatedControl
{
public static readonly DirectProperty<CustomTitlebar, bool> ShowTitleProperty =
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
nameof(showTitle),
o => o.showTitle,
(o, v) => o.showTitle = v, true);
public static readonly DirectProperty<CustomTitlebar, bool> CanMaximizeProperty =
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
nameof(CanMaximize),
o => o.CanMaximize,
(o, v) => o.CanMaximize = v, true);
public static readonly DirectProperty<CustomTitlebar, bool> CanMinimizeProperty =
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
nameof(CanMinimize),
o => o.CanMinimize,
(o, v) => o.CanMinimize = v, true);
private bool showTitle = true;
private bool canMaximize = true;
private bool canMinimize = true;
public bool ShowTitle
{
get => showTitle;
set => SetAndRaise(ShowTitleProperty, ref showTitle, value);
}
public bool CanMaximize
{
get => canMaximize;
set => SetAndRaise(CanMaximizeProperty, ref canMaximize, value);
}
public bool CanMinimize
{
get => canMinimize;
set => SetAndRaise(CanMinimizeProperty, ref canMinimize, value);
}
[RelayCommand]
public void Minimize()
{
if (!CanMinimize)
{
return;
}
if (this.GetWindow() is not { } window)
{
return;
}
window.WindowState = WindowState.Minimized;
}
[RelayCommand]
public void ToggleMaximize()
{
if (!CanMaximize)
{
return;
}
if (this.GetWindow() is not { } window)
{
return;
}
window.WindowState = window.WindowState == WindowState.Normal ? WindowState.Maximized : WindowState.Normal;
}
protected override void OnLoaded(RoutedEventArgs e)
{
PointerPressed += OnPointerPressed;
DoubleTapped += OnDoubleTapped;
base.OnLoaded(e);
}
protected override void OnUnloaded(RoutedEventArgs e)
{
PointerPressed -= OnPointerPressed;
DoubleTapped -= OnDoubleTapped;
base.OnUnloaded(e);
}
private void OnPointerPressed(object sender, PointerPressedEventArgs e)
{
if (e.Source is Visual element && element.GetWindow() is { } window)
{
window.BeginMoveDrag(e);
}
}
private void OnDoubleTapped(object sender, TappedEventArgs e) => ToggleMaximize();
[RelayCommand]
private void Close()
{
if (this.GetWindow() is not { } window)
{
return;
}
window.CloseByUser();
}
}

View File

@@ -0,0 +1,189 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Utilities;
using static System.Math;
namespace Nitrox.Launcher.Models.Controls;
/// <summary>
/// Panel that arranges stretchable child controls to fit min width, up to the limit of <see cref="MinItemWidth" />.
/// Code inspired by Avalonia's WrapPanel
/// (https://github.com/AvaloniaUI/Avalonia/blob/master/src/Avalonia.Controls/WrapPanel.cs).
/// </summary>
/// <remarks>
/// Looks similar to YouTube video layout.
/// </remarks>
public class FittingWrapPanel : Panel, INavigableContainer
{
public static readonly StyledProperty<double> MinItemWidthProperty =
AvaloniaProperty.Register<WrapPanel, double>(nameof(MinItemWidth), 100);
public double MinItemWidth
{
get => GetValue(MinItemWidthProperty);
set => SetValue(MinItemWidthProperty, value);
}
static FittingWrapPanel()
{
AffectsMeasure<WrapPanel>(MinItemWidthProperty);
}
/// <inheritdoc />
protected override Size MeasureOverride(Size constraint)
{
UVSize curLineSize = new();
UVSize panelSize = new();
UVSize uvConstraint = new(constraint.Width, constraint.Height);
int itemsPerRow = (int)Min(constraint.Width / MinItemWidth, Max(Children.Count, 1));
double adjustedWidth = constraint.Width / itemsPerRow;
for (int i = 0, count = Children.Count; i < count; i++)
{
Control child = Children[i];
child.Measure(new Size(adjustedWidth, constraint.Height));
UVSize sz = new(adjustedWidth, child.DesiredSize.Height);
if (MathUtilities.GreaterThan(curLineSize.Width + sz.Width, uvConstraint.Width)) // Need to switch to another line
{
panelSize = new UVSize { Width = Max(curLineSize.Width, panelSize.Width), Height = panelSize.Height + curLineSize.Height };
curLineSize = sz;
if (MathUtilities.GreaterThan(sz.Width, uvConstraint.Width)) // The element is wider then the constraint - give it a separate line
{
panelSize = new UVSize { Width = Max(sz.Width, panelSize.Width), Height = panelSize.Height + sz.Height };
curLineSize = new UVSize();
}
}
else // Continue to accumulate a line
{
curLineSize = new UVSize { Width = curLineSize.Width + sz.Width, Height = Max(sz.Height, curLineSize.Height) };
}
}
panelSize = new UVSize { Width = Max(curLineSize.Width, panelSize.Width), Height = panelSize.Height + curLineSize.Height };
return new Size(panelSize.Width, panelSize.Height);
}
/// <inheritdoc />
protected override Size ArrangeOverride(Size finalSize)
{
int firstInLine = 0;
double accumulatedV = 0;
UVSize curLineSize = new();
UVSize uvFinalSize = new(finalSize.Width, finalSize.Height);
int itemsPerRow = (int)Min(finalSize.Width / MinItemWidth, Max(Children.Count, 1));
double adjustedWidth = finalSize.Width / itemsPerRow;
for (int i = 0; i < Children.Count; i++)
{
Control child = Children[i];
UVSize sz = new(adjustedWidth, child.DesiredSize.Height);
if (MathUtilities.GreaterThan(curLineSize.Width + sz.Width, uvFinalSize.Width)) // Need to switch to another line
{
ArrangeLine(accumulatedV, curLineSize.Height, firstInLine, i, adjustedWidth);
accumulatedV += curLineSize.Height;
curLineSize = sz;
if (MathUtilities.GreaterThan(sz.Width, uvFinalSize.Width)) // The element is wider then the constraint - give it a separate line
{
ArrangeLine(accumulatedV, sz.Height, i, ++i, adjustedWidth);
accumulatedV += sz.Height;
curLineSize = new UVSize();
}
firstInLine = i;
}
else // Continue to accumulate a line
{
curLineSize = new UVSize { Width = curLineSize.Width + sz.Width, Height = Max(sz.Height, curLineSize.Height) };
}
}
if (firstInLine < Children.Count)
{
ArrangeLine(accumulatedV, curLineSize.Height, firstInLine, Children.Count, adjustedWidth);
}
return finalSize;
}
/// <summary>
/// Gets the next control in the specified direction.
/// </summary>
/// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns>
IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
{
Avalonia.Controls.Controls children = Children;
int index = from is not null ? Children.IndexOf((Control)from) : -1;
switch (direction)
{
case NavigationDirection.First:
index = 0;
break;
case NavigationDirection.Last:
index = children.Count - 1;
break;
case NavigationDirection.Next:
++index;
break;
case NavigationDirection.Previous:
--index;
break;
case NavigationDirection.Left:
index -= 1;
break;
case NavigationDirection.Right:
index += 1;
break;
case NavigationDirection.Up:
case NavigationDirection.Down:
index = -1;
break;
}
if (index >= 0 && index < children.Count)
{
return children[index];
}
return null;
}
private void ArrangeLine(double v, double lineV, int start, int end, double itemU)
{
Avalonia.Controls.Controls children = Children;
double u = 0;
for (int i = start; i < end; i++)
{
Control child = children[i];
child.Arrange(new Rect(u, v, itemU, lineV));
u += itemU;
}
}
private readonly struct UVSize
{
internal UVSize(double width, double height)
{
Width = width;
Height = height;
}
public double Width { get; init; }
internal double Height { get; init; }
}
}

View File

@@ -0,0 +1,93 @@
extern alias JB;
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Skia;
using SkiaSharp;
namespace Nitrox.Launcher.Models.Controls;
/// <summary>
/// Draws a grayscale filter over the already rendered content.
/// </summary>
/// <remarks>
/// Code from:<br/>
/// - Draw-on-top logic: https://gist.github.com/kekekeks/ac06098a74fe87d49a9ff9ea37fa67bc <br/>
/// - Grayscale logic: https://learn.microsoft.com/en-us/xamarin/xamarin-forms/user-interface/graphics/skiasharp/effects/color-filters <br/>
/// </remarks>
public class GrayscaleControl : Decorator
{
static GrayscaleControl()
{
AffectsRender<GrayscaleControl>(OpacityProperty);
}
public override void Render(DrawingContext context)
{
context.Custom(new GrayscaleBehindRenderOperation((byte)Math.Round(byte.MaxValue * Opacity), new Rect(default, Bounds.Size)));
}
private class GrayscaleBehindRenderOperation : ICustomDrawOperation
{
private static readonly float[] grayscaleColorFilterMatrix =
{
0.21f, 0.72f, 0.07f, 0, 0,
0.21f, 0.72f, 0.07f, 0, 0,
0.21f, 0.72f, 0.07f, 0, 0,
0, 0, 0, 1, 0
};
private readonly byte opacity;
private readonly Rect bounds;
public Rect Bounds => bounds;
public GrayscaleBehindRenderOperation(byte opacity, Rect bounds)
{
this.opacity = opacity;
this.bounds = bounds;
}
public void Dispose()
{
}
public bool HitTest(Point p) => bounds.Contains(p);
public void Render(ImmediateDrawingContext context)
{
ISkiaSharpApiLeaseFeature leaseFeature = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
if (leaseFeature == null)
{
return;
}
using ISkiaSharpApiLease skia = leaseFeature.Lease();
if (!skia.SkCanvas.TotalMatrix.TryInvert(out SKMatrix currentInvertedTransform))
{
return;
}
if (skia.SkSurface == null)
{
return;
}
using SKImage backgroundSnapshot = skia.SkSurface.Snapshot();
using SKShader backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, currentInvertedTransform);
using SKImageFilter grayscaleFilter = SKImageFilter.CreateColorFilter(CreateGrayscaleColorFilter());
using SKPaint paint = new()
{
Shader = backdropShader,
ImageFilter = grayscaleFilter,
Color = new SKColor(0, 0, 0, opacity)
};
skia.SkCanvas.DrawRect(0, 0, (float)bounds.Width, (float)bounds.Height, paint);
}
public bool Equals(ICustomDrawOperation other) => other is GrayscaleBehindRenderOperation op && op.bounds == bounds;
private static SKColorFilter CreateGrayscaleColorFilter() => SKColorFilter.CreateColorMatrix(grayscaleColorFilterMatrix);
}
}

View File

@@ -0,0 +1,51 @@
using System;
using Avalonia;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.Input;
namespace Nitrox.Launcher.Models.Controls;
public class RadioButtonGroup : ItemsControl
{
public static readonly DirectProperty<RadioButtonGroup, Type> EnumProperty = AvaloniaProperty.RegisterDirect<RadioButtonGroup, Type>(nameof(Enum), o => o.Enum, (o, v) => o.Enum = v);
public static readonly StyledProperty<object> SelectedItemProperty = AvaloniaProperty.Register<RadioButtonGroup, object>(nameof(SelectedItem));
public static readonly DirectProperty<RadioButtonGroup, RelayCommand<Button>> ItemClickCommandProperty = AvaloniaProperty.RegisterDirect<RadioButtonGroup, RelayCommand<Button>>(nameof(ItemClickCommand), o => o.ItemClickCommand, (o, v) => o.ItemClickCommand = v);
private Type @enum;
private RelayCommand<Button> itemClickCommand;
public Type Enum
{
get => @enum;
set
{
if (value is not { IsEnum: true })
{
return;
}
ItemsSource = System.Enum.GetValues(value);
SetAndRaise(EnumProperty, ref @enum, value);
}
}
public RelayCommand<Button> ItemClickCommand
{
get => itemClickCommand;
private set => SetAndRaise(ItemClickCommandProperty, ref itemClickCommand, value);
}
public object SelectedItem
{
get => GetValue(SelectedItemProperty);
set => SetValue(SelectedItemProperty, value);
}
public RadioButtonGroup()
{
itemClickCommand = new RelayCommand<Button>(param => SelectedItem = param.Tag);
}
protected override Type StyleKeyOverride { get; } = typeof(ItemsControl);
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Media;
namespace Nitrox.Launcher.Models.Controls;
/// <summary>
/// A basic Rich Textbox. Supports bold, italic, underline, colors and hyperlinks.
/// </summary>
/// <remarks>
/// Tag legend:<br />
/// [b][/b] - Bold <br />
/// [i][/i] - Italicize <br />
/// [u][/u] - Underline <br />
/// [#colorHex][/#colorHex] - Change text color <br />
/// [Flavor text](example.com) <br />
/// </remarks>
/// <example>
/// [b]Text[/b] => <b>Text</b> <br />
/// [i]Text[/i] => <i>Text</i> <br />
/// [u]Text[/u] => <u>Text</u> <br />
/// [#0000FF]Text[/#0000FF] => Text (with blue foreground) <br />
/// <a href="https://example.com">Flavor text</a> <br />
/// </example>
public partial class RichTextBlock : TextBlock
{
private static readonly TextDecorationCollection underlineTextDecoration = [new() { Location = TextDecorationLocation.Underline }];
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == TextProperty)
{
Inlines?.Clear();
ParseTextAndAddInlines(Text ?? "", Inlines);
// If all text was just tags, set Text to empty. Otherwise, it will be displayed as fallback by Avalonia.
if (Inlines?.Count < 1)
{
Text = "";
}
}
}
[GeneratedRegex(@"\[\/?([^]]+)\](?:\(([^\)]*)\))?")]
private static partial Regex TagParserRegex { get; }
public static void ParseTextAndAddInlines(ReadOnlySpan<char> text, InlineCollection inlines)
{
if (inlines == null)
{
return;
}
Regex.ValueMatchEnumerator matchEnumerator = TagParserRegex.EnumerateMatches(text);
if (!matchEnumerator.MoveNext())
{
inlines.Add(new Run(text.ToString()));
return;
}
ValueMatch lastRange = default;
Dictionary<string, Action<Run, string>> activeTags = new(4);
do
{
ValueMatch range = matchEnumerator.Current;
// Handle text in-between previous and current tag.
ReadOnlySpan<char> textPart = text[(lastRange.Index + lastRange.Length)..range.Index];
if (!textPart.IsEmpty)
{
inlines.Add(CreateRunWithTags(textPart.ToString(), activeTags));
}
// Handle current tag (this tracks state of active tags at current text position)
ReadOnlySpan<char> match = text.Slice(range.Index, range.Length);
switch (match)
{
case ['[', '/', ..]:
activeTags.Remove(match[2..^1].ToString());
break;
case "[b]":
activeTags["b"] = static (run, _) => run.FontWeight = FontWeight.Bold;
break;
case "[u]":
activeTags["u"] = static (run, _) => run.TextDecorations = underlineTextDecoration;
break;
case "[i]":
activeTags["i"] = static (run, _) => run.FontStyle = FontStyle.Italic;
break;
case ['[', ..] when match.IndexOf("](", StringComparison.OrdinalIgnoreCase) > -1:
TextBlock textBlock = new();
textBlock.Classes.Add("link");
textBlock.Text = match[1..match.IndexOfAny("]")].ToString();
textBlock.Tag = match[(match.IndexOfAny("(")+1)..match.IndexOfAny(")")].ToString();
inlines.Add(textBlock);
break;
case ['[', '#', ..]:
ReadOnlySpan<char> colorCode = match[1..match.IndexOfAny("]")];
if (!Color.TryParse(colorCode, out Color _))
{
goto default;
}
activeTags[colorCode.ToString()] = static (run, tag) => run.Foreground = new SolidColorBrush(Color.Parse(tag));
break;
default:
// Unknown tag, let's handle as normal text (issue is likely due to input text not knowing about this RichTextBox format)
inlines.Add(CreateRunWithTags(match.ToString(), activeTags));
break;
}
lastRange = range;
} while (matchEnumerator.MoveNext());
// Handle any final text (after the last tag).
ReadOnlySpan<char> lastPart = text[(lastRange.Index + lastRange.Length)..];
if (!lastPart.IsEmpty)
{
inlines.Add(CreateRunWithTags(lastPart.ToString(), activeTags));
}
}
private static Run CreateRunWithTags(string text, Dictionary<string, Action<Run, string>> tags)
{
Run run = new(text);
KeyValuePair<string, Action<Run, string>>? lastColorTag = null;
foreach (KeyValuePair<string, Action<Run, string>> pair in tags)
{
switch (pair.Key)
{
case ['#', ..]:
// Optimization: only the last color needs to be applied for the current run, ignore all others.
lastColorTag = pair;
break;
default:
pair.Value(run, pair.Key);
break;
}
}
lastColorTag?.Value(run, lastColorTag.Value.Key);
return run;
}
protected override Type StyleKeyOverride { get; } = typeof(TextBlock);
}

View File

@@ -0,0 +1,27 @@
using System;
using Avalonia;
using Avalonia.Controls;
namespace Nitrox.Launcher.Models.Controls;
/// <inheritdoc cref="RichTextBlock"/>
public class SelectableRichTextBlock : SelectableTextBlock
{
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == TextProperty)
{
Inlines?.Clear();
RichTextBlock.ParseTextAndAddInlines(Text ?? "", Inlines);
// If all text was just tags, set Text to empty. Otherwise, it will be displayed as fallback by Avalonia.
if (Inlines?.Count < 1)
{
Text = "";
}
}
}
protected override Type StyleKeyOverride { get; } = typeof(SelectableTextBlock);
}