Skip to content

TilePanelWidget

An infinite, pannable and zoomable tile map that renders content from a pluggable data source.

TilePanel displays a scrollable grid of tiles fetched from an ITileDataSource. Users navigate with arrow keys, zoom with +/- or mouse scroll, and can interact with clickable points of interest overlaid on the map. It follows a controlled component pattern — you own the camera state and update it in response to events.

Basic Usage

Create a TilePanel by providing an ITileDataSource and wiring up pan/zoom handlers:

csharp
using Hex1b;
using Hex1b.Data;
using Hex1b.Layout;
using Hex1b.Theming;
using Hex1b.Widgets;

var state = new MapState();
var dataSource = new GridTileDataSource();

await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithHex1bApp((app, options) => ctx => ctx.VStack(v => [
        v.Text($"Camera: ({state.CameraX:F1}, {state.CameraY:F1})  Zoom: {state.ZoomLevel}"),
        v.TilePanel(dataSource, state.CameraX, state.CameraY, state.ZoomLevel)
            .OnPan(e =>
            {
                state.CameraX += e.DeltaX;
                state.CameraY += e.DeltaY;
            })
            .OnZoom(e => state.ZoomLevel = e.NewZoomLevel)
    ]))
    .Build();

await terminal.RunAsync();

class MapState
{
    public double CameraX { get; set; }
    public double CameraY { get; set; }
    public int ZoomLevel { get; set; }
}

class GridTileDataSource : ITileDataSource
{
    public Size TileSize => new(3, 1);

    public ValueTask<TileData[,]> GetTilesAsync(
        int tileX, int tileY, int tilesWide, int tilesTall,
        CancellationToken cancellationToken = default)
    {
        var tiles = new TileData[tilesWide, tilesTall];
        for (int y = 0; y < tilesTall; y++)
        {
            for (int x = 0; x < tilesWide; x++)
            {
                var tx = tileX + x;
                var ty = tileY + y;
                var isEven = (tx + ty) % 2 == 0;
                tiles[x, y] = new TileData(
                    FormatCoord(tx, ty),
                    isEven ? Hex1bColor.FromRgb(100, 180, 255) : Hex1bColor.FromRgb(180, 180, 180),
                    isEven ? Hex1bColor.FromRgb(20, 40, 80) : Hex1bColor.FromRgb(30, 50, 30));
            }
        }
        return ValueTask.FromResult(tiles);
    }

    static string FormatCoord(int x, int y)
    {
        var s = $"{x},{y}";
        return s.Length <= 3 ? s.PadRight(3) : s[..3];
    }
}

Navigation

Use arrow keys to pan, +/- to zoom, and Home to reset. Mouse drag pans the map, and scroll wheel zooms toward the cursor position.

Creating a Data Source

TilePanel gets its content from an ITileDataSource implementation. The interface has two members:

  • TileSize — the dimensions of each tile in characters at zoom level 0
  • GetTilesAsync() — fetches a rectangular region of tiles

Here's a minimal data source that renders a coordinate grid:

csharp

Async Loading

GetTilesAsync is called on a background thread, never blocking the UI. While tiles load, empty placeholders are shown. When tiles arrive, the panel automatically redraws.

TileData

Each tile is represented by a TileData record struct:

csharp
public readonly record struct TileData(
    string Content,       // Text content to render
    Hex1bColor Foreground, // Foreground color
    Hex1bColor Background  // Background color
);

The Content string length should match TileSize.Width. When zooming, content is automatically scaled to fill the effective tile width.

Camera Control

TilePanel uses a controlled component pattern. You own the camera state and update it via event handlers:

csharp
var cameraX = 0.0;
var cameraY = 0.0;
var zoom = 0;

ctx.TilePanel(dataSource, cameraX, cameraY, zoom)
    .OnPan(e =>
    {
        cameraX += e.DeltaX;
        cameraY += e.DeltaY;
    })
    .OnZoom(e => zoom = e.NewZoomLevel)

Pan Events

OnPan fires when the user presses arrow keys or drags the mouse. The TilePanelPanEventArgs provides:

PropertyTypeDescription
DeltaXdoubleHorizontal pan delta in tile units
DeltaYdoubleVertical pan delta in tile units

Arrow keys produce integer deltas (1 for normal, 5 for Shift+Arrow). Mouse drag produces fractional deltas based on pixel distance and current zoom.

Zoom Events

OnZoom fires when the user presses +/- keys or scrolls the mouse wheel. The TilePanelZoomEventArgs provides:

PropertyTypeDescription
NewZoomLevelintThe suggested zoom level after the change
DeltaintThe zoom delta (+1 or -1)
PivotXdoubleTile-space X coordinate of the zoom pivot
PivotYdoubleTile-space Y coordinate of the zoom pivot

For mouse scroll, the pivot is at the cursor position — useful for implementing zoom-toward-cursor:

csharp
.OnZoom(e =>
{
    // Adjust camera to keep the pivot point stable
    var scale = Math.Pow(2, e.Delta);
    cameraX = e.PivotX + (cameraX - e.PivotX) / scale;
    cameraY = e.PivotY + (cameraY - e.PivotY) / scale;
    zoom = e.NewZoomLevel;
})

Zoom Levels

Each zoom level doubles or halves the tile render size:

ZoomLevelScaleEffect
-20.25xQuarter size tiles
-10.5xHalf size tiles
01xBase size (from TileSize)
12xDouble size tiles
24xQuadruple size tiles

Zoom is internally clamped to the range -4 to 8.

Points of Interest

Overlay clickable markers on the tile map using WithPointsOfInterest:

csharp
var pois = new List<TilePointOfInterest>
{
    new(0, 0, "📍", "Origin"),           // X, Y, Icon, Label
    new(5, 3, "🏠", "Home", myData),     // Optional Tag for user data
};

ctx.TilePanel(dataSource, cameraX, cameraY, zoom)
    .WithPointsOfInterest(pois)
    .OnPoiClicked(e =>
    {
        // Navigate to the clicked POI
        cameraX = e.PointOfInterest.X;
        cameraY = e.PointOfInterest.Y;
    })

POIs outside the visible viewport are automatically excluded from rendering. The TilePointOfInterest record provides:

PropertyTypeDescription
XdoubleX coordinate in tile space
YdoubleY coordinate in tile space
IconstringIcon character or emoji to display
Labelstring?Optional text label near the icon
Tagobject?Optional user data

Input Bindings

TilePanel registers these default keybindings, all rebindable via WithInputBindings:

InputActionActionId
Arrow keysPan by 1 tilePanUp, PanDown, PanLeft, PanRight
Shift+Arrow keysPan by 5 tilesPanUpFast, PanDownFast, PanLeftFast, PanRightFast
+ / =Zoom inZoomIn
-Zoom outZoomOut
HomeReset to originResetPosition
Mouse scrollZoom at cursorZoomIn / ZoomOut
Mouse dragPan(drag handler)

Rebinding Keys

csharp
using Hex1b.Input;
using Hex1b.Widgets;

ctx.TilePanel(dataSource, cameraX, cameraY, zoom)
    .OnPan(e => { /* ... */ })
    .OnZoom(e => { /* ... */ })
    .WithInputBindings(b =>
    {
        // Use WASD instead of arrow keys
        b.Remove(TilePanelWidget.PanUp);
        b.Remove(TilePanelWidget.PanDown);
        b.Remove(TilePanelWidget.PanLeft);
        b.Remove(TilePanelWidget.PanRight);
        b.Key(Hex1bKey.W).Triggers(TilePanelWidget.PanUp);
        b.Key(Hex1bKey.S).Triggers(TilePanelWidget.PanDown);
        b.Key(Hex1bKey.A).Triggers(TilePanelWidget.PanLeft);
        b.Key(Hex1bKey.D).Triggers(TilePanelWidget.PanRight);
    })

Theming

Customize the appearance of empty tiles and POI labels:

csharp
using Hex1b.Theming;

var theme = new Hex1bTheme("Custom")
    .Set(TilePanelTheme.EmptyTileForegroundColor, Hex1bColor.DarkGray)
    .Set(TilePanelTheme.EmptyTileBackgroundColor, Hex1bColor.Black)
    .Set(TilePanelTheme.EmptyTileCharacter, '.')
    .Set(TilePanelTheme.PoiLabelForegroundColor, Hex1bColor.Yellow)
    .Set(TilePanelTheme.PoiLabelBackgroundColor, Hex1bColor.FromRgb(40, 40, 40));

Available Theme Elements

ElementTypeDefaultDescription
EmptyTileForegroundColorHex1bColorDarkGrayForeground for tiles with no data
EmptyTileBackgroundColorHex1bColorDefaultBackground for tiles with no data
EmptyTileCharacterchar·Character used to fill empty tiles
PoiLabelForegroundColorHex1bColorWhitePOI label text color
PoiLabelBackgroundColorHex1bColorDefaultPOI label background color

Async Loading

TilePanel loads tiles asynchronously — GetTilesAsync is called on a background thread, never blocking the UI. While tiles load, empty placeholders are shown. When tiles arrive, the panel automatically redraws.

Data Source Caching

If your data source involves expensive I/O (e.g., network requests), implement caching within your ITileDataSource. The panel calls GetTilesAsync whenever the viewport changes, so a fast return path keeps the UI responsive.

Extension Methods

MethodDescription
ctx.TilePanel(dataSource)Creates a TilePanel at origin with zoom 0
ctx.TilePanel(dataSource, cameraX, cameraY, zoomLevel)Creates a TilePanel with specified camera position
  • Scroll - For scrollable content within fixed bounds
  • Surface - For custom low-level rendering
  • Float - For absolute positioning of overlays

Released under the MIT License.