diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs
index 2a4846fa..cd59d351 100644
--- a/MinecraftClient/Resources/Translations/Translations.Designer.cs
+++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs
@@ -1683,7 +1683,7 @@ namespace MinecraftClient {
}
///
- /// Looks up a localized string similar to Map #{0} ({1}x{2}).
+ /// Looks up a localized string similar to Map #{0} ({1}x{2}) [{3}%].
///
internal static string bot_map_tui_header {
get {
@@ -1692,7 +1692,7 @@ namespace MinecraftClient {
}
///
- /// Looks up a localized string similar to Press Escape or E to close.
+ /// Looks up a localized string similar to Scroll=Zoom Drag=Pan +/-=Zoom Arrows=Pan Esc/E=Close.
///
internal static string bot_map_tui_controls {
get {
diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx
index 350f1c30..5a9a5da7 100644
--- a/MinecraftClient/Resources/Translations/Translations.resx
+++ b/MinecraftClient/Resources/Translations/Translations.resx
@@ -661,10 +661,10 @@ 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})
+ Map #{0} ({1}x{2}) [{3}%]
- Press Escape or E to close
+ Scroll=Zoom Drag=Pan +/-=Zoom Arrows=Pan Esc/E=Close
replay command
diff --git a/MinecraftClient/Tui/MapOverlay.cs b/MinecraftClient/Tui/MapOverlay.cs
index bb45d938..2211cd8f 100644
--- a/MinecraftClient/Tui/MapOverlay.cs
+++ b/MinecraftClient/Tui/MapOverlay.cs
@@ -2,7 +2,6 @@ using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
-using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
@@ -12,18 +11,42 @@ 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.
+ /// Fullscreen overlay that renders a Minecraft map with interactive zoom and pan.
+ /// Uses Unicode half-block characters where each terminal cell displays two vertical
+ /// pixels (foreground = top, background = bottom). Supports mouse wheel zoom with
+ /// center-anchoring, mouse drag panning, and keyboard navigation.
+ /// At 100% one terminal column = one map pixel wide; at 200% two columns = one pixel.
///
internal sealed class MapOverlay : Panel
{
- private readonly ScrollViewer _scrollViewer;
+ private const double ZoomFactor = 1.25;
+ private const double MaxScale = 2.0;
+ private const double KeyPanStep = 4.0;
+ private const double OffsetEpsilon = 0.5;
+
private readonly TextBlock _mapBlock;
private readonly TextBlock _headerBlock;
private readonly TextBlock _controlsBlock;
+ private readonly TextBlock _cornerTL;
+ private readonly TextBlock _cornerTR;
+ private readonly TextBlock _cornerBL;
+ private readonly TextBlock _cornerBR;
+
+ private McMap _map = null!;
+ private double _scale = 1.0;
+ private double _offsetX;
+ private double _offsetY;
+ private double _fitScale = 1.0;
+
+ private bool _isDragging;
+ private double _dragStartX;
+ private double _dragStartY;
+ private double _dragStartOffsetX;
+ private double _dragStartOffsetY;
+ private bool _initialLayoutDone;
+
+ private static readonly IBrush IndicatorActive = Brushes.Yellow;
+ private static readonly IBrush IndicatorDim = new SolidColorBrush(Color.FromRgb(60, 60, 60));
public MapOverlay(McMap map)
{
@@ -40,13 +63,7 @@ namespace MinecraftClient.Tui
Padding = new Thickness(0),
Margin = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Center,
- };
-
- _scrollViewer = new ScrollViewer
- {
- HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
- VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
- Content = _mapBlock,
+ VerticalAlignment = VerticalAlignment.Center,
};
var border = new Border
@@ -54,7 +71,7 @@ namespace MinecraftClient.Tui
BorderBrush = Brushes.White,
BorderThickness = new Thickness(1),
Padding = new Thickness(0),
- Child = _scrollViewer,
+ Child = _mapBlock,
};
_headerBlock = new TextBlock
@@ -76,64 +93,204 @@ namespace MinecraftClient.Tui
Padding = new Thickness(1, 0),
};
+ _cornerTL = CreateCornerIndicator(HorizontalAlignment.Left, VerticalAlignment.Top, "\u25E4");
+ _cornerTR = CreateCornerIndicator(HorizontalAlignment.Right, VerticalAlignment.Top, "\u25E5");
+ _cornerBL = CreateCornerIndicator(HorizontalAlignment.Left, VerticalAlignment.Bottom, "\u25E3");
+ _cornerBR = CreateCornerIndicator(HorizontalAlignment.Right, VerticalAlignment.Bottom, "\u25E2");
+
Children.Add(border);
Children.Add(_headerBlock);
Children.Add(_controlsBlock);
+ Children.Add(_cornerTL);
+ Children.Add(_cornerTR);
+ Children.Add(_cornerBL);
+ Children.Add(_cornerBR);
AttachedToVisualTree += (_, _) =>
{
AddHandler(KeyDownEvent, OnTunnelKeyDown, RoutingStrategies.Tunnel);
+ AddHandler(TextInputEvent, OnTunnelTextInput, RoutingStrategies.Tunnel);
Focus();
};
DetachedFromVisualTree += (_, _) =>
{
RemoveHandler(KeyDownEvent, OnTunnelKeyDown);
+ RemoveHandler(TextInputEvent, OnTunnelTextInput);
};
- UpdateMap(map);
+ _map = map;
+ _scale = double.MinValue;
+ UpdateHeaderText();
+ }
+
+ private static TextBlock CreateCornerIndicator(HorizontalAlignment hAlign, VerticalAlignment vAlign, string glyph)
+ {
+ return new TextBlock
+ {
+ Text = glyph,
+ Background = Brushes.Black,
+ Foreground = IndicatorDim,
+ HorizontalAlignment = hAlign,
+ VerticalAlignment = vAlign,
+ Padding = new Thickness(0),
+ };
}
public void UpdateMap(McMap map)
{
- _headerBlock.Text = string.Format(Translations.bot_map_tui_header, map.MapId, map.Width, map.Height);
+ _map = map;
if (map.Colors is null || map.Width == 0 || map.Height == 0)
return;
- RenderMapInlines(map);
+ RecalculateFitScale();
+ _scale = _fitScale;
+ _offsetX = 0;
+ _offsetY = 0;
+ UpdateHeaderText();
+ RenderViewport();
}
- ///
- /// 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)
+ private void UpdateHeaderText()
{
- int w = map.Width;
- int h = map.Height;
- byte[] colors = map.Colors!;
+ int zoomPercent = _scale > 0 ? (int)Math.Round(_scale * 100) : 0;
+ _headerBlock.Text = string.Format(Translations.bot_map_tui_header,
+ _map.MapId, _map.Width, _map.Height, zoomPercent);
+ }
+
+ protected override void OnSizeChanged(SizeChangedEventArgs e)
+ {
+ base.OnSizeChanged(e);
+
+ if (_map?.Colors is null || _map.Width == 0 || _map.Height == 0)
+ return;
+
+ RecalculateFitScale();
+
+ if (!_initialLayoutDone)
+ {
+ _scale = _fitScale;
+ _offsetX = 0;
+ _offsetY = 0;
+ _initialLayoutDone = true;
+ }
+ else
+ {
+ _scale = Math.Clamp(_scale, _fitScale, MaxScale);
+ }
+
+ ClampOffset();
+ UpdateHeaderText();
+ RenderViewport();
+ }
+
+ #region Scale / Offset
+
+ private void GetViewportCells(out int viewW, out int viewH)
+ {
+ viewW = Math.Max(1, (int)Bounds.Width - 2);
+ viewH = Math.Max(1, (int)Bounds.Height - 2);
+ }
+
+ private void RecalculateFitScale()
+ {
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ double scaleX = (double)viewW / _map.Width;
+ double scaleY = (double)viewPixelsH / _map.Height;
+ _fitScale = Math.Min(scaleX, scaleY);
+
+ if (_fitScale > MaxScale)
+ _fitScale = MaxScale;
+ }
+
+ private void ClampOffset()
+ {
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ double visibleMapW = viewW / _scale;
+ double visibleMapH = viewPixelsH / _scale;
+
+ double maxOffX = Math.Max(0, _map.Width - visibleMapW);
+ double maxOffY = Math.Max(0, _map.Height - visibleMapH);
+
+ _offsetX = Math.Clamp(_offsetX, 0, maxOffX);
+ _offsetY = Math.Clamp(_offsetY, 0, maxOffY);
+ }
+
+ private void ZoomAtCenter(double factor)
+ {
+ if (_map?.Colors is null) return;
+
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ double centerMapX = _offsetX + (viewW / 2.0) / _scale;
+ double centerMapY = _offsetY + (viewPixelsH / 2.0) / _scale;
+
+ double newScale = Math.Clamp(_scale * factor, _fitScale, MaxScale);
+
+ if ((_scale < 1.0 - 1e-9 && newScale > 1.0 + 1e-9) ||
+ (_scale > 1.0 + 1e-9 && newScale < 1.0 - 1e-9))
+ newScale = 1.0;
+
+ if (Math.Abs(newScale - _scale) < 1e-12)
+ return;
+
+ _scale = newScale;
+
+ _offsetX = centerMapX - (viewW / 2.0) / _scale;
+ _offsetY = centerMapY - (viewPixelsH / 2.0) / _scale;
+ ClampOffset();
+ UpdateHeaderText();
+ RenderViewport();
+ }
+
+ #endregion
+
+ #region Rendering
+
+ private void RenderViewport()
+ {
+ if (_map?.Colors is null || _map.Width == 0 || _map.Height == 0)
+ return;
+
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ int mapW = _map.Width;
+ int mapH = _map.Height;
+ byte[] colors = _map.Colors;
+
+ int renderCols = Math.Min(viewW, (int)Math.Ceiling(mapW * _scale));
+ int renderPixelRows = Math.Min(viewPixelsH, (int)Math.Ceiling(mapH * _scale));
+ int renderTextRows = (renderPixelRows + 1) / 2;
_mapBlock.Inlines ??= [];
_mapBlock.Inlines.Clear();
- for (int py = 0; py < h; py += 2)
+ double invScale = 1.0 / _scale;
+
+ for (int r = 0; r < renderTextRows; r++)
{
- if (py > 0)
+ if (r > 0)
_mapBlock.Inlines.Add(new LineBreak());
IBrush? batchFg = null;
IBrush? batchBg = null;
int batchLen = 0;
- for (int px = 0; px < w; px++)
+ for (int c = 0; c < renderCols; c++)
{
- ColorRGBA top = MapColors.ColorByteToRGBA(colors[px + py * w]);
- ColorRGBA bot = (py + 1 < h)
- ? MapColors.ColorByteToRGBA(colors[px + (py + 1) * w])
- : top;
+ int srcX = Math.Clamp((int)(_offsetX + c * invScale), 0, mapW - 1);
+ int srcTopY = Math.Clamp((int)(_offsetY + (r * 2) * invScale), 0, mapH - 1);
+ int srcBotY = Math.Clamp((int)(_offsetY + (r * 2 + 1) * invScale), 0, mapH - 1);
+
+ ColorRGBA top = MapColors.ColorByteToRGBA(colors[srcX + srcTopY * mapW]);
+ ColorRGBA bot = MapColors.ColorByteToRGBA(colors[srcX + srcBotY * mapW]);
var fg = new SolidColorBrush(Color.FromRgb(top.R, top.G, top.B));
var bg = new SolidColorBrush(Color.FromRgb(bot.R, bot.G, bot.B));
@@ -145,7 +302,7 @@ namespace MinecraftClient.Tui
else
{
if (batchLen > 0)
- FlushBatch(_mapBlock, batchFg!, batchBg!, batchLen);
+ FlushBatch(batchFg!, batchBg!, batchLen);
batchFg = fg;
batchBg = bg;
@@ -154,13 +311,15 @@ namespace MinecraftClient.Tui
}
if (batchLen > 0)
- FlushBatch(_mapBlock, batchFg!, batchBg!, batchLen);
+ FlushBatch(batchFg!, batchBg!, batchLen);
}
+
+ UpdateCornerIndicators();
}
- private static void FlushBatch(TextBlock tb, IBrush fg, IBrush bg, int count)
+ private void FlushBatch(IBrush fg, IBrush bg, int count)
{
- tb.Inlines!.Add(new Run(new string('\u2580', count))
+ _mapBlock.Inlines!.Add(new Run(new string('\u2580', count))
{
Foreground = fg,
Background = bg,
@@ -174,6 +333,80 @@ namespace MinecraftClient.Tui
return false;
}
+ private void UpdateCornerIndicators()
+ {
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ double visibleW = viewW / _scale;
+ double visibleH = viewPixelsH / _scale;
+
+ bool moreLeft = _offsetX > OffsetEpsilon;
+ bool moreTop = _offsetY > OffsetEpsilon;
+ bool moreRight = _offsetX + visibleW < _map.Width - OffsetEpsilon;
+ bool moreBottom = _offsetY + visibleH < _map.Height - OffsetEpsilon;
+
+ _cornerTL.Foreground = (moreLeft || moreTop) ? IndicatorActive : IndicatorDim;
+ _cornerTR.Foreground = (moreRight || moreTop) ? IndicatorActive : IndicatorDim;
+ _cornerBL.Foreground = (moreLeft || moreBottom) ? IndicatorActive : IndicatorDim;
+ _cornerBR.Foreground = (moreRight || moreBottom) ? IndicatorActive : IndicatorDim;
+ }
+
+ #endregion
+
+ #region Mouse interaction
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ double factor = e.Delta.Y > 0 ? ZoomFactor : 1.0 / ZoomFactor;
+ ZoomAtCenter(factor);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ var props = e.GetCurrentPoint(this).Properties;
+ if (!props.IsLeftButtonPressed)
+ return;
+
+ _isDragging = true;
+ var pos = e.GetPosition(this);
+ _dragStartX = pos.X;
+ _dragStartY = pos.Y;
+ _dragStartOffsetX = _offsetX;
+ _dragStartOffsetY = _offsetY;
+ e.Pointer.Capture(this);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ if (!_isDragging) return;
+
+ var pos = e.GetPosition(this);
+ double dxCells = pos.X - _dragStartX;
+ double dyCells = pos.Y - _dragStartY;
+
+ _offsetX = _dragStartOffsetX - dxCells / _scale;
+ _offsetY = _dragStartOffsetY - dyCells * 2.0 / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ if (!_isDragging) return;
+
+ _isDragging = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ }
+
+ #endregion
+
+ #region Keyboard interaction
+
private void OnTunnelKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key is Key.Escape or Key.E)
@@ -183,16 +416,72 @@ namespace MinecraftClient.Tui
}
}
+ private void OnTunnelTextInput(object? sender, TextInputEventArgs e)
+ {
+ if (e.Text is "+" or "=")
+ {
+ ZoomAtCenter(ZoomFactor);
+ e.Handled = true;
+ }
+ else if (e.Text is "-")
+ {
+ ZoomAtCenter(1.0 / ZoomFactor);
+ e.Handled = true;
+ }
+ }
+
protected override void OnKeyDown(KeyEventArgs e)
{
- if (e.Key is Key.Escape or Key.E)
+ switch (e.Key)
{
- TuiConsoleBackend.Instance?.DismissOverlay();
- e.Handled = true;
- return;
+ case Key.Escape:
+ case Key.E:
+ TuiConsoleBackend.Instance?.DismissOverlay();
+ e.Handled = true;
+ return;
+
+ case Key.Add:
+ ZoomAtCenter(ZoomFactor);
+ e.Handled = true;
+ return;
+
+ case Key.Subtract:
+ ZoomAtCenter(1.0 / ZoomFactor);
+ e.Handled = true;
+ return;
+
+ case Key.Left:
+ _offsetX -= KeyPanStep / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ return;
+
+ case Key.Right:
+ _offsetX += KeyPanStep / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ return;
+
+ case Key.Up:
+ _offsetY -= KeyPanStep / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ return;
+
+ case Key.Down:
+ _offsetY += KeyPanStep / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ return;
}
base.OnKeyDown(e);
}
+
+ #endregion
}
}