Add TUI support for /map

This commit is contained in:
BruceChen 2026-04-05 17:41:32 +08:00
parent 871bc7651f
commit 0dde017ecc
4 changed files with 254 additions and 2 deletions

View file

@ -4,6 +4,7 @@ using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Threading;
using Brigadier.NET;
using Brigadier.NET.Builder;
using ImageMagick;
@ -11,6 +12,7 @@ using MinecraftClient.CommandHandler;
using MinecraftClient.CommandHandler.Patch;
using MinecraftClient.Mapping;
using MinecraftClient.Scripting;
using MinecraftClient.Tui;
using Tomlet.Attributes;
namespace MinecraftClient.ChatBots
@ -142,7 +144,12 @@ namespace MinecraftClient.ChatBots
SaveToFile(map);
if (Config.Render_In_Console)
RenderInConsole(map);
{
if (ConsoleIO.Backend is TuiConsoleBackend)
RenderInTui(map);
else
RenderInConsole(map);
}
return r.SetAndReturn(CmdResult.Status.Done);
}
@ -213,7 +220,12 @@ namespace MinecraftClient.ChatBots
SaveToFile(map);
if (Config.Render_In_Console)
RenderInConsole(map);
{
if (ConsoleIO.Backend is TuiConsoleBackend)
RenderInTui(map);
else
RenderInConsole(map);
}
}
}
@ -342,6 +354,24 @@ namespace MinecraftClient.ChatBots
}
}
private static void RenderInTui(McMap map)
{
var view = TuiConsoleBackend.Instance?.GetView();
if (view is null)
return;
Dispatcher.UIThread.Post(() =>
{
if (view.HasOverlay && view.OverlayContent is MapOverlay existing)
{
existing.UpdateMap(map);
return;
}
view.ShowOverlay(new MapOverlay(map));
});
}
private static void RenderInConsole(McMap map)
{
StringBuilder sb = new();

View file

@ -1682,6 +1682,24 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Map #{0} ({1}x{2}).
/// </summary>
internal static string bot_map_tui_header {
get {
return ResourceManager.GetString("bot.map.tui_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Press Escape or E to close.
/// </summary>
internal static string bot_map_tui_controls {
get {
return ResourceManager.GetString("bot.map.tui_controls", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to replay command.
/// </summary>

View file

@ -660,6 +660,12 @@ cooldown: {6}</value>
<data name="bot.map.sent_to_telegram" xml:space="preserve">
<value>Sent a rendered image of a map with an id '{0}' to the Telegram via Telegram Bridge chat bot!</value>
</data>
<data name="bot.map.tui_header" xml:space="preserve">
<value>Map #{0} ({1}x{2})</value>
</data>
<data name="bot.map.tui_controls" xml:space="preserve">
<value>Press Escape or E to close</value>
</data>
<data name="bot.replayCapture.cmd" xml:space="preserve">
<value>replay command</value>
</data>

View file

@ -0,0 +1,198 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using MinecraftClient.ChatBots;
namespace MinecraftClient.Tui
{
/// <summary>
/// Fullscreen overlay that renders a Minecraft map using Unicode half-block
/// characters with Avalonia rich-text inlines. Each terminal cell displays two
/// vertical pixels via the upper-half-block glyph: foreground = top pixel,
/// background = bottom pixel. Title and controls text are embedded into the
/// border lines themselves to maximize the map display area.
/// </summary>
internal sealed class MapOverlay : Panel
{
private readonly ScrollViewer _scrollViewer;
private readonly TextBlock _mapBlock;
private readonly TextBlock _headerBlock;
private readonly TextBlock _controlsBlock;
public MapOverlay(McMap map)
{
ArgumentNullException.ThrowIfNull(map);
HorizontalAlignment = HorizontalAlignment.Stretch;
VerticalAlignment = VerticalAlignment.Stretch;
Focusable = true;
Background = Brushes.Black;
_mapBlock = new TextBlock
{
TextWrapping = TextWrapping.NoWrap,
Padding = new Thickness(0),
Margin = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Center,
};
_scrollViewer = new ScrollViewer
{
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Content = _mapBlock,
};
var border = new Border
{
BorderBrush = Brushes.White,
BorderThickness = new Thickness(1),
Padding = new Thickness(0),
Child = _scrollViewer,
};
_headerBlock = new TextBlock
{
Foreground = Brushes.White,
Background = Brushes.Black,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Top,
Padding = new Thickness(1, 0),
};
_controlsBlock = new TextBlock
{
Text = Translations.bot_map_tui_controls,
Foreground = Brushes.Gray,
Background = Brushes.Black,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Bottom,
Padding = new Thickness(1, 0),
};
Children.Add(border);
Children.Add(_headerBlock);
Children.Add(_controlsBlock);
AttachedToVisualTree += (_, _) =>
{
AddHandler(KeyDownEvent, OnTunnelKeyDown, RoutingStrategies.Tunnel);
Focus();
};
DetachedFromVisualTree += (_, _) =>
{
RemoveHandler(KeyDownEvent, OnTunnelKeyDown);
};
UpdateMap(map);
}
public void UpdateMap(McMap map)
{
_headerBlock.Text = string.Format(Translations.bot_map_tui_header, map.MapId, map.Width, map.Height);
if (map.Colors is null || map.Width == 0 || map.Height == 0)
return;
RenderMapInlines(map);
}
/// <summary>
/// Renders the map pixel data as Avalonia <see cref="Run"/> inlines using
/// the upper-half-block character. Each terminal row represents two pixel
/// rows: foreground color = upper pixel, background color = lower pixel.
/// Adjacent cells with identical colors are batched into a single Run.
/// </summary>
private void RenderMapInlines(McMap map)
{
int w = map.Width;
int h = map.Height;
byte[] colors = map.Colors!;
_mapBlock.Inlines ??= [];
_mapBlock.Inlines.Clear();
for (int py = 0; py < h; py += 2)
{
if (py > 0)
_mapBlock.Inlines.Add(new LineBreak());
IBrush? batchFg = null;
IBrush? batchBg = null;
int batchLen = 0;
for (int px = 0; px < w; px++)
{
ColorRGBA top = MapColors.ColorByteToRGBA(colors[px + py * w]);
ColorRGBA bot = (py + 1 < h)
? MapColors.ColorByteToRGBA(colors[px + (py + 1) * w])
: top;
var fg = new SolidColorBrush(Color.FromRgb(top.R, top.G, top.B));
var bg = new SolidColorBrush(Color.FromRgb(bot.R, bot.G, bot.B));
if (batchLen > 0 && ColorsEqual(batchFg!, fg) && ColorsEqual(batchBg!, bg))
{
batchLen++;
}
else
{
if (batchLen > 0)
FlushBatch(_mapBlock, batchFg!, batchBg!, batchLen);
batchFg = fg;
batchBg = bg;
batchLen = 1;
}
}
if (batchLen > 0)
FlushBatch(_mapBlock, batchFg!, batchBg!, batchLen);
}
}
private static void FlushBatch(TextBlock tb, IBrush fg, IBrush bg, int count)
{
tb.Inlines!.Add(new Run(new string('\u2580', count))
{
Foreground = fg,
Background = bg,
});
}
private static bool ColorsEqual(IBrush a, IBrush b)
{
if (a is SolidColorBrush sa && b is SolidColorBrush sb)
return sa.Color == sb.Color;
return false;
}
private void OnTunnelKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key is Key.Escape or Key.E)
{
TuiConsoleBackend.Instance?.DismissOverlay();
e.Handled = true;
}
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Key is Key.Escape or Key.E)
{
TuiConsoleBackend.Instance?.DismissOverlay();
e.Handled = true;
return;
}
base.OnKeyDown(e);
}
}
}