Add server status display and protocol version upgrade handling

- Introduced a new `ServerStatusInfo` class to encapsulate server status data including MOTD, player counts, and version information.
- Implemented `ServerStatusDisplay` to format and display server status information in both classic and TUI modes.
- Added protocol version upgrade logic in `ProtocolHandler` to determine the highest supported protocol version for multi-version servers.
- Updated translations to support new server status labels and messages.
- Created `ServerStatusPanelBuilder` for TUI to visually represent server status with player information and connection details.
This commit is contained in:
BruceChen 2026-03-30 01:35:38 +08:00
parent 2f03f66f5c
commit 871305bd72
10 changed files with 954 additions and 269 deletions

View file

@ -1171,5 +1171,18 @@ namespace MinecraftClient.Tui
_commandInput.Focus();
}, DispatcherPriority.Loaded);
}
#region Custom Control Append
public void AppendControlToLog(Control control)
{
_logLines.Add(string.Empty);
_logControls.Add(control);
TrimLog();
if (_autoScroll)
ScheduleScrollToEnd();
}
#endregion
}
}

View file

@ -52,6 +52,8 @@ namespace MinecraftClient.Tui
IBrush currentColor = Brushes.White;
bool bold = false;
bool italic = false;
bool underline = false;
bool strikethrough = false;
int start = 0;
for (int i = 0; i < text.Length; i++)
@ -59,7 +61,7 @@ namespace MinecraftClient.Tui
if (text[i] == '§' && i + 1 < text.Length)
{
if (i > start)
AddRun(tb, text[start..i], currentColor, bold, italic);
AddRun(tb, text[start..i], currentColor, bold, italic, underline, strikethrough);
char code = char.ToLower(text[i + 1]);
@ -68,6 +70,8 @@ namespace MinecraftClient.Tui
currentColor = brush;
bold = false;
italic = false;
underline = false;
strikethrough = false;
}
else
{
@ -75,10 +79,14 @@ namespace MinecraftClient.Tui
{
case 'l': bold = true; break;
case 'o': italic = true; break;
case 'n': underline = true; break;
case 'm': strikethrough = true; break;
case 'r':
currentColor = Brushes.White;
bold = false;
italic = false;
underline = false;
strikethrough = false;
break;
}
}
@ -89,7 +97,7 @@ namespace MinecraftClient.Tui
}
if (start < text.Length)
AddRun(tb, text[start..], currentColor, bold, italic);
AddRun(tb, text[start..], currentColor, bold, italic, underline, strikethrough);
if (tb.Inlines?.Count == 0)
{
@ -100,16 +108,29 @@ namespace MinecraftClient.Tui
return tb;
}
private static void AddRun(TextBlock tb, string text, IBrush color, bool bold, bool italic)
private static void AddRun(TextBlock tb, string text, IBrush color,
bool bold, bool italic, bool underline, bool strikethrough)
{
if (text.Length == 0) return;
tb.Inlines ??= new InlineCollection();
TextDecorationCollection? decorations = null;
if (underline || strikethrough)
{
decorations = [];
if (underline)
decorations.Add(new TextDecoration { Location = TextDecorationLocation.Underline });
if (strikethrough)
decorations.Add(new TextDecoration { Location = TextDecorationLocation.Strikethrough });
}
tb.Inlines.Add(new Run(text)
{
Foreground = color,
FontWeight = bold ? FontWeight.Bold : FontWeight.Normal,
FontStyle = italic ? FontStyle.Italic : FontStyle.Normal,
TextDecorations = decorations,
});
}
}

View file

@ -0,0 +1,296 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Layout;
using Avalonia.Media;
namespace MinecraftClient.Tui
{
internal static class ServerStatusPanelBuilder
{
private const int MaxSamplePlayers = 10;
private const int FaviconDisplaySize = 16;
internal static Border Build(Protocol.ServerStatusInfo info)
{
var contentPanel = new DockPanel { Background = Brushes.Black };
if (info.FaviconBase64 is not null)
{
var iconGrid = BuildFaviconGrid(info.FaviconBase64, FaviconDisplaySize);
DockPanel.SetDock(iconGrid, Dock.Left);
contentPanel.Children.Add(iconGrid);
}
var infoPanel = new StackPanel
{
Orientation = Orientation.Vertical,
Margin = new Thickness(1, 0, 0, 0),
};
AddMotd(infoPanel, info);
AddAddress(infoPanel, info);
AddVersion(infoPanel, info);
AddConnectingAs(infoPanel, info);
AddPing(infoPanel, info);
AddPlayers(infoPanel, info);
AddSamplePlayers(infoPanel, info);
contentPanel.Children.Add(infoPanel);
return new Border
{
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)),
BorderThickness = new Thickness(1),
Background = new SolidColorBrush(Color.FromArgb(240, 20, 20, 20)),
Padding = new Thickness(1, 0),
Child = contentPanel,
Margin = new Thickness(0, 1),
};
}
private static void AddMotd(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (string.IsNullOrEmpty(info.MotdRaw))
return;
try
{
string motdFormatted = Protocol.Message.ChatParser.ParseText(info.MotdRaw);
foreach (string line in motdFormatted.Split('\n'))
panel.Children.Add(McColorParser.CreateColoredTextBlock(line, TextWrapping.NoWrap));
}
catch
{
panel.Children.Add(new TextBlock
{
Text = info.MotdRaw,
Foreground = Brushes.White,
TextWrapping = TextWrapping.NoWrap,
});
}
}
private static void AddAddress(StackPanel panel, Protocol.ServerStatusInfo info)
{
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_server));
row.Inlines.Add(Value(info.Host, McColors.Aqua));
row.Inlines.Add(new Run($":{info.Port}") { Foreground = McColors.Gray });
panel.Children.Add(row);
}
private static void AddVersion(StackPanel panel, Protocol.ServerStatusInfo info)
{
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_version));
row.Inlines.Add(Value(info.VersionName, McColors.Aqua));
row.Inlines.Add(new Run(" (") { Foreground = McColors.Gray });
row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_protocol, info.ProtocolVersion))
{ Foreground = McColors.Gray });
row.Inlines.Add(new Run(")") { Foreground = McColors.Gray });
panel.Children.Add(row);
}
private static void AddConnectingAs(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (info.ResolvedProtocol == 0 || info.ResolvedProtocol == info.ProtocolVersion)
return;
string resolvedMcVer = Protocol.ProtocolHandler.ProtocolVersion2MCVer(info.ResolvedProtocol);
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_connecting_as));
row.Inlines.Add(Value(resolvedMcVer, McColors.Green));
row.Inlines.Add(new Run(" (") { Foreground = McColors.Gray });
row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_protocol, info.ResolvedProtocol))
{ Foreground = McColors.Gray });
row.Inlines.Add(new Run(")") { Foreground = McColors.Gray });
panel.Children.Add(row);
}
private static void AddPing(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (info.PingMs < 0)
return;
var pingColor = info.PingMs < 100
? McColors.Green
: info.PingMs < 300
? McColors.Yellow
: McColors.Red;
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_ping));
row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_ping_ms, info.PingMs))
{ Foreground = pingColor });
panel.Children.Add(row);
}
private static void AddPlayers(StackPanel panel, Protocol.ServerStatusInfo info)
{
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_players));
row.Inlines.Add(Value($"{info.OnlinePlayers}", McColors.Green));
row.Inlines.Add(new Run("/") { Foreground = McColors.Gray });
row.Inlines.Add(Value($"{info.MaxPlayers}", McColors.Red));
panel.Children.Add(row);
}
private static void AddSamplePlayers(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (info.SamplePlayers.Count == 0)
return;
panel.Children.Add(new TextBlock
{
Text = Translations.mcc_server_info_label_online,
Foreground = McColors.Gray,
Margin = new Thickness(0, 1, 0, 0),
});
int shown = Math.Min(info.SamplePlayers.Count, MaxSamplePlayers);
for (int i = 0; i < shown; i++)
{
panel.Children.Add(new TextBlock
{
Text = $" {info.SamplePlayers[i].Name}",
Foreground = McColors.Green,
});
}
if (info.SamplePlayers.Count > shown)
{
panel.Children.Add(new TextBlock
{
Text = $" {string.Format(Translations.mcc_server_info_sample_more, info.SamplePlayers.Count - shown)}",
Foreground = McColors.Gray,
});
}
}
private static Run Label(string text) =>
new(text + " ") { Foreground = McColors.Gray };
private static Run Value(string text, IBrush color) =>
new(text) { Foreground = color };
#region Favicon Rendering
private static Grid BuildFaviconGrid(string base64Png, int displaySize)
{
byte[] pngBytes;
try
{
pngBytes = Convert.FromBase64String(base64Png);
}
catch
{
return new Grid();
}
int srcWidth, srcHeight;
byte[] rgba;
try
{
(srcWidth, srcHeight, rgba) = DecodePngToRgba(pngBytes);
}
catch
{
return new Grid();
}
int cellCols = displaySize;
int cellRows = displaySize / 2;
var grid = new Grid();
for (int c = 0; c < cellCols; c++)
grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Auto));
for (int r = 0; r < cellRows; r++)
grid.RowDefinitions.Add(new RowDefinition(1, GridUnitType.Auto));
for (int row = 0; row < cellRows; row++)
{
for (int col = 0; col < cellCols; col++)
{
int topPixelY = row * 2;
int bottomPixelY = row * 2 + 1;
var topColor = SamplePixel(rgba, srcWidth, srcHeight, col, topPixelY, cellCols, displaySize);
var bottomColor = SamplePixel(rgba, srcWidth, srcHeight, col, bottomPixelY, cellCols, displaySize);
var cell = new TextBlock
{
Text = "\u2580",
Foreground = new SolidColorBrush(topColor),
Background = new SolidColorBrush(bottomColor),
Padding = new Thickness(0),
Margin = new Thickness(0),
};
Grid.SetRow(cell, row);
Grid.SetColumn(cell, col);
grid.Children.Add(cell);
}
}
return grid;
}
private static Color SamplePixel(byte[] rgba, int srcW, int srcH, int dstX, int dstY, int dstW, int dstH)
{
int srcX = dstX * srcW / dstW;
int srcY = dstY * srcH / dstH;
srcX = Math.Clamp(srcX, 0, srcW - 1);
srcY = Math.Clamp(srcY, 0, srcH - 1);
int idx = (srcY * srcW + srcX) * 4;
if (idx + 3 >= rgba.Length)
return Color.FromRgb(0, 0, 0);
byte r = rgba[idx];
byte g = rgba[idx + 1];
byte b = rgba[idx + 2];
byte a = rgba[idx + 3];
return a < 128 ? Color.FromRgb(0, 0, 0) : Color.FromRgb(r, g, b);
}
private static (int Width, int Height, byte[] Rgba) DecodePngToRgba(byte[] png)
{
using var image = new ImageMagick.MagickImage(png);
int w = (int)image.Width;
int h = (int)image.Height;
using var pixels = image.GetPixelsUnsafe();
var rgba = new byte[w * h * 4];
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
var pixel = pixels.GetPixel(x, y)!;
int idx = (y * w + x) * 4;
var color = pixel.ToColor()!;
rgba[idx] = (byte)(color.R >> 8);
rgba[idx + 1] = (byte)(color.G >> 8);
rgba[idx + 2] = (byte)(color.B >> 8);
rgba[idx + 3] = (byte)(color.A >> 8);
}
}
return (w, h, rgba);
}
#endregion
private static class McColors
{
public static readonly IBrush Gray = new SolidColorBrush(Color.FromRgb(170, 170, 170));
public static readonly IBrush Aqua = new SolidColorBrush(Color.FromRgb(85, 255, 255));
public static readonly IBrush Green = new SolidColorBrush(Color.FromRgb(85, 255, 85));
public static readonly IBrush Red = new SolidColorBrush(Color.FromRgb(255, 85, 85));
public static readonly IBrush Yellow = new SolidColorBrush(Color.FromRgb(255, 255, 85));
}
}
}