Added a /tab command

This commit is contained in:
Anon 2026-04-04 16:17:49 +02:00
parent a12ba29c45
commit c8286f60c1
12 changed files with 729 additions and 2 deletions

View file

@ -0,0 +1,50 @@
using Avalonia.Threading;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Tui;
namespace MinecraftClient.Commands
{
public class Tab : Command
{
public override string CmdName => "tab";
public override string CmdUsage => "tab";
public override string CmdDesc => Translations.cmd_tab_desc;
public override void RegisterCommand(CommandDispatcher<CmdResult> dispatcher)
{
dispatcher.Register(l => l.Literal("help")
.Then(l => l.Literal(CmdName)
.Executes(r => GetUsage(r.Source)))
);
dispatcher.Register(l => l.Literal(CmdName)
.Executes(r => ShowTab(r.Source))
.Then(l => l.Literal("_help")
.Executes(r => GetUsage(r.Source))
.Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
);
}
private int GetUsage(CmdResult r) => r.SetAndReturn(GetCmdDescTranslated());
private static int ShowTab(CmdResult r)
{
McClient handler = CmdResult.currentHandler!;
var snapshot = handler.GetTabListSnapshot();
if (ConsoleIO.Backend is TuiConsoleBackend)
{
var view = TuiConsoleBackend.Instance?.GetView();
if (view is null)
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_tab_tui_unavailable);
Dispatcher.UIThread.Post(() => view.ShowOverlay(new TabListOverlay(handler)));
return r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_tab_tui_opened);
}
return r.SetAndReturn(CmdResult.Status.Done, TabListFormatter.Render(snapshot));
}
}
}

View file

@ -38,6 +38,9 @@ namespace MinecraftClient
private readonly Dictionary<Guid, PlayerInfo> onlinePlayers = new();
private static bool commandsLoaded = false;
private readonly Lock tabListHeaderFooterLock = new();
private string tabListHeader = string.Empty;
private string tabListFooter = string.Empty;
private readonly Queue<string> chatQueue = new();
private static DateTime nextMessageSendTime = DateTime.MinValue;
@ -1530,6 +1533,86 @@ namespace MinecraftClient
return null;
}
internal TabListSnapshot GetTabListSnapshot()
{
List<(Guid Uuid, string Name, string DisplayName, int Gamemode, int Ping, int TabListOrder, bool Listed)> players;
lock (onlinePlayers)
{
players = onlinePlayers
.Select(static pair => (
pair.Key,
pair.Value.Name,
pair.Value.DisplayName ?? string.Empty,
pair.Value.Gamemode,
pair.Value.Ping,
pair.Value.TabListOrder,
pair.Value.Listed))
.ToList();
}
Dictionary<string, PlayerTeam> teamSnapshot;
lock (teams)
{
teamSnapshot = teams.ToDictionary(
static pair => pair.Key,
static pair =>
{
var sourceTeam = pair.Value;
var copy = new PlayerTeam
{
Name = sourceTeam.Name,
DisplayName = sourceTeam.DisplayName,
AllowFriendlyFire = sourceTeam.AllowFriendlyFire,
SeeFriendlyInvisibles = sourceTeam.SeeFriendlyInvisibles,
NameTagVisibility = sourceTeam.NameTagVisibility,
CollisionRule = sourceTeam.CollisionRule,
Color = sourceTeam.Color,
Prefix = sourceTeam.Prefix,
Suffix = sourceTeam.Suffix
};
foreach (string member in sourceTeam.Members)
copy.Members.Add(member);
return copy;
},
StringComparer.OrdinalIgnoreCase);
}
string header;
string footer;
lock (tabListHeaderFooterLock)
{
header = tabListHeader;
footer = tabListFooter;
}
var entries = players
.Select(player =>
{
PlayerTeam? team = teamSnapshot.Values.FirstOrDefault(
team => team.Members.Contains(player.Name));
string displayName = !string.IsNullOrWhiteSpace(player.DisplayName)
? player.DisplayName
: TabListFormatter.FormatTeamMemberName(player.Name, team);
return new TabListEntry(
player.Uuid,
player.Name,
displayName,
team?.Name ?? string.Empty,
!string.IsNullOrWhiteSpace(team?.DisplayName) ? team.DisplayName : team?.Name ?? string.Empty,
player.Gamemode,
player.Ping,
player.TabListOrder,
player.Listed);
})
.ToList();
return new TabListSnapshot(header, footer, entries);
}
public PlayerKeyPair? GetPlayerKeyPair()
{
return playerKeyPair;
@ -4165,6 +4248,11 @@ namespace MinecraftClient
/// <param name="footer">Footer</param>
public void OnTabListHeaderAndFooter(string header, string footer)
{
lock (tabListHeaderFooterLock)
{
tabListHeader = header;
tabListFooter = footer;
}
DispatchBotEvent(bot => bot.OnTabListHeaderAndFooter(header, footer));
}

View file

@ -2137,7 +2137,7 @@ namespace MinecraftClient.Protocol.Handlers
// Consume all action-selected fields to keep entry boundaries aligned.
if (protocolVersion >= MC_1_21_2_Version && (actionBitset & 1 << 6) > 0) // Actions bit 6: update list order
dataTypes.ReadNextVarInt(packetData);
player.TabListOrder = dataTypes.ReadNextVarInt(packetData);
if (protocolVersion >= MC_1_21_4_Version && (actionBitset & 1 << 7) > 0) // Actions bit 7: update hat
dataTypes.ReadNextBool(packetData);

View file

@ -22,6 +22,8 @@ namespace MinecraftClient.Protocol
public bool Listed = true;
public int TabListOrder;
// Entity info
public Mapping.Entity? entity;
@ -73,6 +75,7 @@ namespace MinecraftClient.Protocol
Uuid = uuid;
Gamemode = -1;
Ping = 0;
TabListOrder = 0;
lastMessageVerified = true;
precedingSignature = null;
}

View file

@ -999,6 +999,12 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be
<data name="Console.Minimap.CaveMode" xml:space="preserve">
<value>Cave rendering mode: "auto" (detect ceiling), "on" (always cave view), "off" (always surface view).</value>
</data>
<data name="Console.TabList" xml:space="preserve">
<value>Settings for the /tab command and live TUI tab overlay.</value>
</data>
<data name="Console.TabList.ShowTeams" xml:space="preserve">
<value>Show a separate team column in /tab output. Disabled by default for a more vanilla-like player list.</value>
</data>
<data name="Main.General.AuthlibUser" xml:space="preserve">
<value>Yggdrasil authlib multi-user selection.</value>
</data>

View file

@ -4172,6 +4172,78 @@ namespace MinecraftClient {
return ResourceManager.GetString("cmd.list.players", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to show a vanilla-like tab list. In TUI mode, opens a live overlay..
/// </summary>
internal static string cmd_tab_desc {
get {
return ResourceManager.GetString("cmd.tab.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No listed players are currently tracked..
/// </summary>
internal static string cmd_tab_no_players {
get {
return ResourceManager.GetString("cmd.tab.no_players", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Tab list ({0} players).
/// </summary>
internal static string cmd_tab_title {
get {
return ResourceManager.GetString("cmd.tab.title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Ping.
/// </summary>
internal static string cmd_tab_column_ping {
get {
return ResourceManager.GetString("cmd.tab.column_ping", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Team.
/// </summary>
internal static string cmd_tab_column_team {
get {
return ResourceManager.GetString("cmd.tab.column_team", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Player.
/// </summary>
internal static string cmd_tab_column_player {
get {
return ResourceManager.GetString("cmd.tab.column_player", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Live tab overlay opened..
/// </summary>
internal static string cmd_tab_tui_opened {
get {
return ResourceManager.GetString("cmd.tab.tui_opened", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The tab overlay is not available right now..
/// </summary>
internal static string cmd_tab_tui_unavailable {
get {
return ResourceManager.GetString("cmd.tab.tui_unavailable", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to log some text to the console..
@ -4740,7 +4812,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("cmd.teams.team_no_members", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Place a block or open chest.
/// </summary>
@ -6890,6 +6962,15 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Press Esc to close. Refreshes automatically..
/// </summary>
internal static string tui_tab_hint {
get {
return ResourceManager.GetString("tui.tab.hint", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You&apos;re now under {0} effect (Duration: {1})..
/// </summary>

View file

@ -1444,6 +1444,30 @@ Note that parameters in '[]' are optional.</value>
<data name="cmd.list.players" xml:space="preserve">
<value>PlayerList: {0}</value>
</data>
<data name="cmd.tab.desc" xml:space="preserve">
<value>show a vanilla-like tab list. In TUI mode, opens a live overlay.</value>
</data>
<data name="cmd.tab.no_players" xml:space="preserve">
<value>No listed players are currently tracked.</value>
</data>
<data name="cmd.tab.title" xml:space="preserve">
<value>Tab list ({0} players)</value>
</data>
<data name="cmd.tab.column_ping" xml:space="preserve">
<value>Ping</value>
</data>
<data name="cmd.tab.column_team" xml:space="preserve">
<value>Team</value>
</data>
<data name="cmd.tab.column_player" xml:space="preserve">
<value>Player</value>
</data>
<data name="cmd.tab.tui_opened" xml:space="preserve">
<value>Live tab overlay opened.</value>
</data>
<data name="cmd.tab.tui_unavailable" xml:space="preserve">
<value>The tab overlay is not available right now.</value>
</data>
<data name="cmd.log.desc" xml:space="preserve">
<value>log some text to the console.</value>
</data>
@ -2434,6 +2458,9 @@ see item details.</value>
<data name="tui.inventory.item_count" xml:space="preserve">
<value>{0} items</value>
</data>
<data name="tui.tab.hint" xml:space="preserve">
<value>Press Esc to close. Refreshes automatically.</value>
</data>
<data name="bot.effect.gained" xml:space="preserve">
<value>You're now under {0} effect (Duration: {1}).</value>
</data>

View file

@ -1111,6 +1111,9 @@ namespace MinecraftClient
[TomlPrecedingComment("$Console.Minimap$")]
public MinimapConfig Minimap = new();
[TomlPrecedingComment("$Console.TabList$")]
public TabListConfig TabList = new();
public void OnSettingUpdate()
{
var backend = ConsoleIO.Backend;
@ -1302,6 +1305,13 @@ namespace MinecraftClient
Tui.MinimapControl.MinRefreshMs, Tui.MinimapControl.MaxRefreshMs);
}
}
[TomlDoNotInlineObject]
public class TabListConfig
{
[TomlInlineComment("$Console.TabList.ShowTeams$")]
public bool ShowTeams = false;
}
}
}

View file

@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using MinecraftClient.Mapping;
using MinecraftClient.Scripting;
namespace MinecraftClient
{
internal sealed record TabListSnapshot(string Header, string Footer, IReadOnlyList<TabListEntry> Entries);
internal sealed record TabListEntry(
Guid Uuid,
string Name,
string DisplayName,
string TeamName,
string TeamDisplayName,
int Gamemode,
int Ping,
int TabListOrder,
bool Listed);
internal static class TabListFormatter
{
private const int MaxRowsPerColumn = 20;
private const int PingColumnWidth = 11;
private const int ColumnGapWidth = 4;
public static string FormatTeamMemberName(string playerName, PlayerTeam? team)
{
ArgumentException.ThrowIfNullOrWhiteSpace(playerName);
if (team is null)
return playerName;
var sb = new StringBuilder();
sb.Append(team.Prefix);
string colorCode = TeamColorToTag(team.Color);
if (!string.IsNullOrEmpty(colorCode))
sb.Append(colorCode);
sb.Append(playerName);
sb.Append(team.Suffix);
return sb.ToString();
}
public static string Render(TabListSnapshot snapshot, bool includeOverlayHint = false)
{
ArgumentNullException.ThrowIfNull(snapshot);
bool showTeams = Settings.Config.Console.TabList.ShowTeams;
var lines = new List<string>
{
$"§e{string.Format(Translations.cmd_tab_title, snapshot.Entries.Count)}§r"
};
AppendSection(lines, snapshot.Header);
var listedEntries = snapshot.Entries
.Where(static entry => entry.Listed && !string.IsNullOrWhiteSpace(entry.Name))
.OrderBy(static entry => entry.TabListOrder)
.ThenBy(static entry => entry.Gamemode == 3 ? 1 : 0)
.ThenBy(static entry => entry.TeamName, StringComparer.OrdinalIgnoreCase)
.ThenBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
if (listedEntries.Count == 0)
{
lines.Add($"§7{Translations.cmd_tab_no_players}§r");
}
else
{
lines.AddRange(BuildTableLines(listedEntries, showTeams));
}
AppendSection(lines, snapshot.Footer);
if (includeOverlayHint)
{
lines.Add(string.Empty);
lines.Add($"§8{Translations.tui_tab_hint}§r");
}
return string.Join('\n', lines);
}
private static void AppendSection(List<string> lines, string text)
{
if (string.IsNullOrWhiteSpace(text))
return;
foreach (string line in text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n'))
{
if (!string.IsNullOrWhiteSpace(line))
lines.Add(line);
}
}
private static List<string> BuildTableLines(IReadOnlyList<TabListEntry> entries, bool showTeams)
{
int columns = 1;
int rows = entries.Count;
while (rows > MaxRowsPerColumn)
{
columns++;
rows = (entries.Count + columns - 1) / columns;
}
int teamColumnWidth = showTeams
? Math.Max(
GetVisibleLength(Translations.cmd_tab_column_team),
entries.Max(static entry => GetVisibleLength(GetTeamLabel(entry))))
: 0;
string headerRow = BuildRow(
$"§8{Translations.cmd_tab_column_ping}§r",
showTeams ? $"§8{Translations.cmd_tab_column_team}§r" : null,
$"§8{Translations.cmd_tab_column_player}§r",
teamColumnWidth);
List<string> renderedRows = entries
.Select(entry => BuildRow(
GetPingCell(entry.Ping),
showTeams ? GetTeamLabel(entry) : null,
GetPlayerLabel(entry),
teamColumnWidth))
.ToList();
int[] columnWidths = new int[columns];
for (int column = 0; column < columns; column++)
{
int width = GetVisibleLength(headerRow);
for (int row = 0; row < rows; row++)
{
int index = row + (column * rows);
if (index >= renderedRows.Count)
break;
width = Math.Max(width, GetVisibleLength(renderedRows[index]));
}
columnWidths[column] = width;
}
var lines = new List<string> { CombineColumns(headerRow, rows, columns, columnWidths) };
for (int row = 0; row < rows; row++)
lines.Add(CombineColumns(renderedRows, row, rows, columns, columnWidths));
return lines;
}
private static string CombineColumns(string headerRow, int rows, int columns, IReadOnlyList<int> columnWidths)
{
var perColumnValues = new string[columns];
for (int column = 0; column < columns; column++)
perColumnValues[column] = headerRow;
return CombineColumns(perColumnValues, columnWidths);
}
private static string CombineColumns(IReadOnlyList<string> renderedRows, int row, int rows, int columns, IReadOnlyList<int> columnWidths)
{
var perColumnValues = new string[columns];
for (int column = 0; column < columns; column++)
{
int index = row + (column * rows);
perColumnValues[column] = index < renderedRows.Count ? renderedRows[index] : string.Empty;
}
return CombineColumns(perColumnValues, columnWidths);
}
private static string CombineColumns(IReadOnlyList<string> parts, IReadOnlyList<int> widths)
{
var sb = new StringBuilder();
for (int index = 0; index < parts.Count; index++)
{
if (index > 0)
sb.Append(' ', ColumnGapWidth);
sb.Append(PadFormattedRight(parts[index], widths[index]));
}
return sb.ToString().TrimEnd();
}
private static string BuildRow(string pingCell, string? teamCell, string playerCell, int teamColumnWidth)
{
var sb = new StringBuilder();
sb.Append(PadFormattedRight(pingCell, PingColumnWidth));
sb.Append(" ");
if (!string.IsNullOrEmpty(teamCell))
{
sb.Append(PadFormattedRight(teamCell, teamColumnWidth));
sb.Append(" ");
}
sb.Append(playerCell);
return sb.ToString();
}
private static string GetPingCell(int ping)
{
string barColor;
int filledBars;
if (ping < 0)
{
barColor = "§8";
filledBars = 0;
}
else if (ping < 150)
{
barColor = "§a";
filledBars = 5;
}
else if (ping < 300)
{
barColor = "§e";
filledBars = 4;
}
else if (ping < 600)
{
barColor = "§6";
filledBars = 3;
}
else if (ping < 1000)
{
barColor = "§c";
filledBars = 2;
}
else
{
barColor = "§4";
filledBars = 1;
}
string numericPing = ping >= 0 ? $"{Math.Min(ping, 9999),4}ms" : " ???ms";
return $"{barColor}{new string('|', filledBars)}§8{new string('.', 5 - filledBars)}§r {numericPing}";
}
private static string GetTeamLabel(TabListEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.TeamName))
return "§8-§r";
if (!string.IsNullOrWhiteSpace(entry.TeamDisplayName))
return entry.TeamDisplayName;
if (LooksLikeOpaqueTeamName(entry.TeamName))
return "§8-§r";
return entry.TeamName;
}
private static string GetPlayerLabel(TabListEntry entry)
{
string label = string.IsNullOrWhiteSpace(entry.DisplayName)
? entry.Name
: entry.DisplayName;
if (entry.Gamemode == 3)
return $"§7§o{label}§r";
return label;
}
private static string PadFormattedRight(string text, int totalWidth)
{
int visibleLength = GetVisibleLength(text);
if (visibleLength >= totalWidth)
return text;
return text + new string(' ', totalWidth - visibleLength);
}
private static int GetVisibleLength(string text) => ChatBot.GetVerbatim(text).Length;
private static bool LooksLikeOpaqueTeamName(string text)
{
if (Guid.TryParse(text, out _))
return true;
int hyphenCount = text.Count(static ch => ch == '-');
if (text.Length >= 24 && hyphenCount >= 3)
return true;
return false;
}
private static string TeamColorToTag(int color) => color switch
{
0 => "§0",
1 => "§1",
2 => "§2",
3 => "§3",
4 => "§4",
5 => "§5",
6 => "§6",
7 => "§7",
8 => "§8",
9 => "§9",
10 => "§a",
11 => "§b",
12 => "§c",
13 => "§d",
14 => "§e",
15 => "§f",
_ => string.Empty
};
}
}

View file

@ -0,0 +1,92 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
namespace MinecraftClient.Tui
{
internal sealed class TabListOverlay : Border
{
private readonly McClient _handler;
private readonly ScrollViewer _scrollViewer;
private readonly DispatcherTimer _refreshTimer;
public TabListOverlay(McClient handler)
{
ArgumentNullException.ThrowIfNull(handler);
_handler = handler;
BorderBrush = Brushes.White;
BorderThickness = new Thickness(1);
Background = Brushes.Black;
Padding = new Thickness(1);
HorizontalAlignment = HorizontalAlignment.Stretch;
VerticalAlignment = VerticalAlignment.Stretch;
Focusable = true;
_scrollViewer = new ScrollViewer
{
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Focusable = true,
};
Child = _scrollViewer;
_refreshTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Background, static (_, _) => { })
{
IsEnabled = false
};
_refreshTimer.Tick += (_, _) => Refresh();
AttachedToVisualTree += (_, _) =>
{
AddHandler(KeyDownEvent, OnTunnelKeyDown, RoutingStrategies.Tunnel);
Refresh();
_refreshTimer.Start();
Focus();
Dispatcher.UIThread.Post(() => _scrollViewer.Focus(), DispatcherPriority.Input);
};
DetachedFromVisualTree += (_, _) =>
{
RemoveHandler(KeyDownEvent, OnTunnelKeyDown);
_refreshTimer.Stop();
};
}
private void OnTunnelKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Escape)
return;
TuiConsoleBackend.Instance?.DismissOverlay();
e.Handled = true;
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
TuiConsoleBackend.Instance?.DismissOverlay();
e.Handled = true;
return;
}
base.OnKeyDown(e);
}
private void Refresh()
{
string text = TabListFormatter.Render(_handler.GetTabListSnapshot(), includeOverlayHint: true);
var block = McColorParser.CreateColoredTextBlock(text, TextWrapping.NoWrap);
block.Margin = new Thickness(0);
_scrollViewer.Content = block;
}
}
}