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

@ -4173,6 +4173,78 @@ namespace MinecraftClient {
}
}
/// <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..
/// </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;
}
}
}

View file

@ -1183,6 +1183,40 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `30`
### Console TabList section
- **Section header:** `Console.TabList`
- **Description:**
Settings for the `/tab` command and the live tab overlay in TUI mode.
<details>
<summary>Tab list settings</summary>
#### `ShowTeams`
- **Description:**
Show a separate team column in `/tab` output.
This is disabled by default so `/tab` stays closer to the in-game player list and keeps the output compact. Team formatting still applies to player names even when the extra column is hidden.
When enabled, MCC shows the team display name when the server provides one. If the server only sends an internal team identifier, MCC hides that noise instead of printing a raw UUID-like value.
- **Type:** `boolean`
- **Default:** `false`
- **Example:**
```toml
[Console.TabList]
ShowTeams = true
```
</details>
#### `Max_Displayed_Suggestions`
- **Description:**

View file

@ -879,6 +879,32 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
</details>
<details>
<summary><code>tab</code></summary>
- **Description:**
Show the current player tab list in a more detailed format than `/list`.
In the classic console, `/tab` prints a colored table with ping and player names. Team prefixes, suffixes, and display names are applied when the server sends them.
In TUI mode, `/tab` opens a live overlay that refreshes automatically while it is visible. Press `Esc` to close it.
If you want a separate team column, enable [Console.TabList.ShowTeams](configuration.md#showteams).
- **Usage:**
```
/tab
```
- **Notes:**
- `/tab` uses the tab list information sent by the server, so players hidden from the server tab list will not appear here.
- The TUI overlay follows the live player list, so joins, leaves, ping updates, and scoreboard team updates show up without reopening it.
</details>
<details>
<summary><code>set</code></summary>