mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
feat: Added a /tab command to display the list of online players
This commit is contained in:
commit
76c3403700
12 changed files with 729 additions and 2 deletions
50
MinecraftClient/Commands/Tab.cs
Normal file
50
MinecraftClient/Commands/Tab.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,9 @@ namespace MinecraftClient
|
||||||
private readonly Dictionary<Guid, PlayerInfo> onlinePlayers = new();
|
private readonly Dictionary<Guid, PlayerInfo> onlinePlayers = new();
|
||||||
|
|
||||||
private static bool commandsLoaded = false;
|
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 readonly Queue<string> chatQueue = new();
|
||||||
private static DateTime nextMessageSendTime = DateTime.MinValue;
|
private static DateTime nextMessageSendTime = DateTime.MinValue;
|
||||||
|
|
@ -1550,6 +1553,86 @@ namespace MinecraftClient
|
||||||
return null;
|
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()
|
public PlayerKeyPair? GetPlayerKeyPair()
|
||||||
{
|
{
|
||||||
return playerKeyPair;
|
return playerKeyPair;
|
||||||
|
|
@ -4260,6 +4343,11 @@ namespace MinecraftClient
|
||||||
/// <param name="footer">Footer</param>
|
/// <param name="footer">Footer</param>
|
||||||
public void OnTabListHeaderAndFooter(string header, string footer)
|
public void OnTabListHeaderAndFooter(string header, string footer)
|
||||||
{
|
{
|
||||||
|
lock (tabListHeaderFooterLock)
|
||||||
|
{
|
||||||
|
tabListHeader = header;
|
||||||
|
tabListFooter = footer;
|
||||||
|
}
|
||||||
DispatchBotEvent(bot => bot.OnTabListHeaderAndFooter(header, footer));
|
DispatchBotEvent(bot => bot.OnTabListHeaderAndFooter(header, footer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2138,7 +2138,7 @@ namespace MinecraftClient.Protocol.Handlers
|
||||||
|
|
||||||
// Consume all action-selected fields to keep entry boundaries aligned.
|
// 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
|
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
|
if (protocolVersion >= MC_1_21_4_Version && (actionBitset & 1 << 7) > 0) // Actions bit 7: update hat
|
||||||
dataTypes.ReadNextBool(packetData);
|
dataTypes.ReadNextBool(packetData);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ namespace MinecraftClient.Protocol
|
||||||
|
|
||||||
public bool Listed = true;
|
public bool Listed = true;
|
||||||
|
|
||||||
|
public int TabListOrder;
|
||||||
|
|
||||||
// Entity info
|
// Entity info
|
||||||
|
|
||||||
public Mapping.Entity? entity;
|
public Mapping.Entity? entity;
|
||||||
|
|
@ -73,6 +75,7 @@ namespace MinecraftClient.Protocol
|
||||||
Uuid = uuid;
|
Uuid = uuid;
|
||||||
Gamemode = -1;
|
Gamemode = -1;
|
||||||
Ping = 0;
|
Ping = 0;
|
||||||
|
TabListOrder = 0;
|
||||||
lastMessageVerified = true;
|
lastMessageVerified = true;
|
||||||
precedingSignature = null;
|
precedingSignature = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1002,6 +1002,12 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be
|
||||||
<data name="Console.Minimap.CaveMode" xml:space="preserve">
|
<data name="Console.Minimap.CaveMode" xml:space="preserve">
|
||||||
<value>Cave rendering mode: "auto" (detect ceiling), "on" (always cave view), "off" (always surface view).</value>
|
<value>Cave rendering mode: "auto" (detect ceiling), "on" (always cave view), "off" (always surface view).</value>
|
||||||
</data>
|
</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">
|
<data name="Main.General.AuthlibUser" xml:space="preserve">
|
||||||
<value>Yggdrasil authlib multi-user selection.</value>
|
<value>Yggdrasil authlib multi-user selection.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|
|
||||||
|
|
@ -4178,6 +4178,78 @@ namespace MinecraftClient {
|
||||||
return ResourceManager.GetString("cmd.list.players", resourceCulture);
|
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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to log some text to the console..
|
/// Looks up a localized string similar to log some text to the console..
|
||||||
|
|
@ -4746,7 +4818,7 @@ namespace MinecraftClient {
|
||||||
return ResourceManager.GetString("cmd.teams.team_no_members", resourceCulture);
|
return ResourceManager.GetString("cmd.teams.team_no_members", resourceCulture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Place a block or open chest.
|
/// Looks up a localized string similar to Place a block or open chest.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -6896,6 +6968,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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to You're now under {0} effect (Duration: {1})..
|
/// Looks up a localized string similar to You're now under {0} effect (Duration: {1})..
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -1447,6 +1447,30 @@ Note that parameters in '[]' are optional.</value>
|
||||||
<data name="cmd.list.players" xml:space="preserve">
|
<data name="cmd.list.players" xml:space="preserve">
|
||||||
<value>PlayerList: {0}</value>
|
<value>PlayerList: {0}</value>
|
||||||
</data>
|
</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">
|
<data name="cmd.log.desc" xml:space="preserve">
|
||||||
<value>log some text to the console.</value>
|
<value>log some text to the console.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|
@ -2437,6 +2461,9 @@ see item details.</value>
|
||||||
<data name="tui.inventory.item_count" xml:space="preserve">
|
<data name="tui.inventory.item_count" xml:space="preserve">
|
||||||
<value>{0} items</value>
|
<value>{0} items</value>
|
||||||
</data>
|
</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">
|
<data name="bot.effect.gained" xml:space="preserve">
|
||||||
<value>You're now under {0} effect (Duration: {1}).</value>
|
<value>You're now under {0} effect (Duration: {1}).</value>
|
||||||
</data>
|
</data>
|
||||||
|
|
|
||||||
|
|
@ -1114,6 +1114,9 @@ namespace MinecraftClient
|
||||||
[TomlPrecedingComment("$Console.Minimap$")]
|
[TomlPrecedingComment("$Console.Minimap$")]
|
||||||
public MinimapConfig Minimap = new();
|
public MinimapConfig Minimap = new();
|
||||||
|
|
||||||
|
[TomlPrecedingComment("$Console.TabList$")]
|
||||||
|
public TabListConfig TabList = new();
|
||||||
|
|
||||||
public void OnSettingUpdate()
|
public void OnSettingUpdate()
|
||||||
{
|
{
|
||||||
var backend = ConsoleIO.Backend;
|
var backend = ConsoleIO.Backend;
|
||||||
|
|
@ -1305,6 +1308,13 @@ namespace MinecraftClient
|
||||||
Tui.MinimapControl.MinRefreshMs, Tui.MinimapControl.MaxRefreshMs);
|
Tui.MinimapControl.MinRefreshMs, Tui.MinimapControl.MaxRefreshMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TomlDoNotInlineObject]
|
||||||
|
public class TabListConfig
|
||||||
|
{
|
||||||
|
[TomlInlineComment("$Console.TabList.ShowTeams$")]
|
||||||
|
public bool ShowTeams = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
310
MinecraftClient/TabList/TabListFormatter.cs
Normal file
310
MinecraftClient/TabList/TabListFormatter.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
92
MinecraftClient/Tui/TabListOverlay.cs
Normal file
92
MinecraftClient/Tui/TabListOverlay.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1183,6 +1183,40 @@ Coordinate = { x = 145, y = 64, z = 2045 }
|
||||||
|
|
||||||
- **Default:** `30`
|
- **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`
|
#### `Max_Displayed_Suggestions`
|
||||||
|
|
||||||
- **Description:**
|
- **Description:**
|
||||||
|
|
|
||||||
|
|
@ -879,6 +879,32 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
|
||||||
|
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary><code>set</code></summary>
|
<summary><code>set</code></summary>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue