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);
+ }
+ }
+}