From 0dde017ecc66ba52f328c39ddaac34a9667f690e Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 5 Apr 2026 17:41:32 +0800 Subject: [PATCH] Add TUI support for /map --- MinecraftClient/ChatBots/Map.cs | 34 ++- .../Translations/Translations.Designer.cs | 18 ++ .../Resources/Translations/Translations.resx | 6 + MinecraftClient/Tui/MapOverlay.cs | 198 ++++++++++++++++++ 4 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 MinecraftClient/Tui/MapOverlay.cs diff --git a/MinecraftClient/ChatBots/Map.cs b/MinecraftClient/ChatBots/Map.cs index ff5d1830..a686c39a 100644 --- a/MinecraftClient/ChatBots/Map.cs +++ b/MinecraftClient/ChatBots/Map.cs @@ -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(); diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index 9a9f0879..2a4846fa 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -1682,6 +1682,24 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Map #{0} ({1}x{2}). + /// + internal static string bot_map_tui_header { + get { + return ResourceManager.GetString("bot.map.tui_header", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Press Escape or E to close. + /// + internal static string bot_map_tui_controls { + get { + return ResourceManager.GetString("bot.map.tui_controls", resourceCulture); + } + } + /// /// Looks up a localized string similar to replay command. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 18b25cea..350f1c30 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -660,6 +660,12 @@ cooldown: {6} Sent a rendered image of a map with an id '{0}' to the Telegram via Telegram Bridge chat bot! + + Map #{0} ({1}x{2}) + + + Press Escape or E to close + replay command diff --git a/MinecraftClient/Tui/MapOverlay.cs b/MinecraftClient/Tui/MapOverlay.cs new file mode 100644 index 00000000..bb45d938 --- /dev/null +++ b/MinecraftClient/Tui/MapOverlay.cs @@ -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 +{ + /// + /// 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. + /// + 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); + } + + /// + /// Renders the map pixel data as Avalonia 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. + /// + 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); + } + } +}