Table of Contents

Writing an engine integration

IcyUI draws and reads input through a few interfaces, and an engine integration implements them. This guide lists what one provides, in the order you build it, using FNA as the target. FNA has the XNA API, so the skeleton looks like the MonoGame integration, which is the complete reference: IcyUI.MonoGame on GitHub.

Note

IcyUI.FNA is a stub today; there is no FNA package yet. This guide describes how an integration is built, not a shipped one. FNA-specific details, such as its native libraries, are out of scope.

You need a good grasp of the engine you target and of Canvas, the host and the element tree. A game never needs any of this: it only uses an integration that exists.

What an integration provides

Piece Interface Does
Drawing IRenderContext Draws textured quads inside a sprite batch, makes textures, reports the viewport and display scale.
Input IInputSystem Turns keyboard, mouse, gamepad and touch state into IcyUI's events; text input and the clipboard.
Assets IAssetContextFactory, IPlatformAssetImporter<T> Opens the engine's content and turns image streams into textures.
Host IIcyHost A game component that connects the three to the game loop and owns the canvases.
Entry point UseIcyUI(this Game, ...) Builds the configuration and adds the host.

IcyUI's core never references the engine; everything above is the only code that does.

1. The render context

IRenderContext draws only textured quads: the canvas calls Begin, then Draw for each rectangle, glyph and image, then End. A solid color is a tinted draw of WhiteTexture.

/// <summary>What an engine gives IcyUI to draw with. Everything IcyUI draws is textured quads in this order: Begin, many Draw, End.</summary>
public abstract class EngineRenderContext : IRenderContext
{
    public abstract event EventHandler? ViewportResize;

    public abstract IRenderOptions Options { get; }      // opacity, scissor rectangle: the engine applies them to what follows

    public abstract ITexture WhiteTexture { get; }       // solid fills are tinted draws of this

    public abstract Transform2D Transform { get; set; }  // applied to every Draw

    public abstract Size ViewportSize { get; }           // the back buffer, in pixels

    public abstract void Begin();                        // open a sprite batch (straight alpha, scissor test on)

    public abstract void Draw(ITexture texture, in TextureRenderingOptions options);   // one quad

    public abstract void End();                          // submit the batch

    public abstract void Flush();                        // End then Begin, when a state changes mid-frame (an effect, the scissor)

    public abstract ITexture CreateTexture<TColor>(int width, int height, TColor[] data)
        where TColor : unmanaged;                        // font atlases and generated images

    public abstract void ApplyEffect(IEffect effect);

    public abstract void ClearEffects();

    public abstract IEffect GetBuiltInEffect(EffectCode code);

    public abstract void Dispose();
}

Points that went wrong before, so look at them in the MonoGame version:

  • Straight alpha. IcyUI's textures aren't premultiplied, so the sprite batch must blend with straight alpha; the default premultiplied blend makes anti-aliased text edges too bright.
  • Scissor test. Clipping uses the scissor rectangle, and the rasterizer state must enable the scissor test or every clip is silently ignored.
  • Flush is a no-op outside Begin/End; it ends and restarts the batch when the scissor or an effect changes.
  • Display scale. Report the monitor's DPI scale (DisplayScale, DisplayScaleChanged), polled each update, so scaling works. Core supplies the platform providers.

2. Input

/// <summary>What an engine gives IcyUI to read input from. Each device turns the engine's state into IcyUI's events.</summary>
public abstract class EngineInputSystem : IInputSystem, IUpdateableInput
{
    public abstract IInputEventSystem Events { get; }    // the events canvases subscribe to: pointer, keys, navigation, text

    public abstract IKeyboardInput? Keyboard { get; }

    public abstract IMouseInput Mouse { get; }

    public abstract IGamepadInput? Gamepad { get; }      // null when the engine has no gamepads

    public abstract ITouchInput? Touch { get; }          // null when the engine reports no touch

    public abstract IClipboard Clipboard { get; }

    public abstract bool IsInitialized { get; }

    public abstract void Initialize();                   // hook the engine: window text input, device state

    public abstract void Update(TimeSpan deltaTime);     // poll the devices; called once per game update

    public abstract bool IsDeviceAvailable<T>()
        where T : IInputDeviceListener;

    public abstract T GetInputDevice<T>()
        where T : IInputDeviceListener;

    public abstract bool TryGetInputDevice<T>([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? device)
        where T : IInputDeviceListener;

    public abstract IEnumerator<IInputDeviceListener> GetEnumerator();

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}

Each device implements its interface by reading the engine's state in Update and raising IcyUI's events (pressed, released, moved, navigation). Text input comes from the engine's window (character events, IME) rather than from key presses. Leave a device null if the engine can't report it; the canvas copes. The clipboard is the one piece with an OS dependency: pick it with the core's clipboard selector instead of writing one per engine.

3. Assets

An asset context opens content by URI (the engine's content pipeline, files, embedded resources), and an importer turns an image stream into the engine's texture. Register both on the asset configuration; IcyUI.MonoGame has a MonoGameAssetContextFactory and a TextureImporter of a few lines each.

4. The host

The host connects the pieces to the game loop. It draws last, so the UI is on top of the game:

/// <summary>The game component that runs IcyUI: input and display scale in Update, every canvas in Draw, after the game's own drawing.</summary>
public sealed class EngineIcyHost : DrawableGameComponent, IIcyHost
{
    public EngineIcyHost(Game game, IcyConfiguration configuration)
        : base(game)
    {
        Configuration = configuration;
        MainCanvas = new Canvas(configuration) { IsInputEnabled = true };
        Canvases = [MainCanvas];
        DrawOrder = int.MaxValue;                        // drawn last, so the UI is on top of the game
    }

    public IcyConfiguration Configuration { get; }

    public Canvas MainCanvas { get; }

    public IList<Canvas> Canvases { get; }

    public override void Initialize()
    {
        Configuration.Input.Initialize();
        base.Initialize();
    }

    public override void Update(GameTime gameTime)
    {
        if (Configuration.Input is IUpdateableInput updateable)
            updateable.Update(gameTime.ElapsedGameTime);
        base.Update(gameTime);
    }

    public override void Draw(GameTime gameTime)
    {
        for (int i = 0; i < Canvases.Count; i++)     // by index: UI code may add a canvas during the frame
            Canvases[i].Render();
        base.Draw(gameTime);
    }
}

5. The entry point

The extension builds the configuration with the engine's defaults, then runs the game's lambda so it can replace any of them, then adds the host:

/// <summary>The entry point a game calls: build the configuration, let the game adjust it, add the host.</summary>
public static class EngineBuildingExtensions
{
    public static EngineIcyHost UseIcyUI(this Game game, Action<IConfigurationBuilder> configure)
    {
        var builder = new IcyConfigurationBuilder();
        builder.ConfigureRendering(CreateRenderContext(game))     // your IRenderContext
               .ConfigureInput(CreateInputSystem(game))           // your IInputSystem
               .ConfigureAssets();
        builder.AddMarkupSupport();
        builder.UseDefaultTheme();
        configure(builder);                                       // the game's lambda runs after the defaults, so it can replace them

        var host = new EngineIcyHost(game, builder.Build());
        game.Components.Add(host);
        return host;
    }

    private static IRenderContext CreateRenderContext(Game game) => throw new NotImplementedException();

    private static IInputSystem CreateInputSystem(Game game) => throw new NotImplementedException();
}

Throw if the game calls it twice, and offer GetIcyHost and GetIcyCanvas so code can reach the host later; the MonoGame version shows both.

What you built

The five pieces an engine needs, in the order that lets you test each: draw a rectangle, read a click, load a texture, then run a markup page through the host.

Next