07 TextMode – Terminal Frontend

14 min read

Drop a working terminal into a scene: scrollback, drag scrolling, text selection, and an optional command line with history and tab completion, all over the VT emulator.

Summary

TextModeTerminal is the terminal you actually put in a game. The emulator underneath it knows how to turn bytes into cells, but it has no opinion about scrollback display, mouse dragging, text selection, or letting the player type. This is the layer that adds all of that and builds its own view, so one constructor call and one call to Initialize produce a working terminal under the RectTransform you nominate.

It runs in one of two modes, and the distinction matters. Log mode is the one most games want: output accumulates, old lines go into scrollback, the player can drag or scroll back through history, and an optional command line sits at the bottom. Full-screen mode hands the whole grid to the emulator, because a program that draws its own interface needs it. You can switch between them at runtime and the scrollback survives the trip, so a game can show a log, drop into a full-screen editor, and come back.

The command line is optional and pluggable. Turn it on and it provides a prompt, a cursor, input filtering, and an event that fires when the player submits. History and tab completion are interfaces rather than implementations, so you can hand it your own command registry and get completion over your game’s verbs instead of writing a text field from scratch.

To place a terminal in a scene without writing code, add the TextModeTerminalHost component to a RectTransform under a Canvas, or let the TextMode Wizard set one up (see Getting Started). The host holds the terminal settings in the Inspector, builds the terminal when the scene starts, updates it every frame and destroys it with the component.

By design, this component does not depend on the Scylla Input module. It reads the keyboard directly, which means a terminal works in a project that never installed Input, and the debug console can rely on it without dragging a dependency along.

Use the terminal frontend for:

  • An in-game command prompt, cheat console, or admin panel.
  • A diegetic computer the player types at as part of the fiction.
  • A scrolling log the player can drag through and copy text out of.
  • Hosting a program that draws its own full-screen interface.
  • Anything that wants terminal behavior without you assembling scrollback, selection and input handling by hand.

Features

  • A scene component. TextModeTerminalHost places a terminal in a scene from the Inspector and reports each submitted command through a C# event and an Inspector event.
  • Builds its own view. Give it a parent RectTransform and settings; it creates the UITextModeView, the grid, and the renderer underneath.
  • Two modes. Log mode with scrollback for output, full-screen mode for programs that own the display. Switching preserves scrollback.
  • History in full-screen mode. Lines a program scrolls off the top of the screen are kept and can be scrolled back through, with a status line pinned in place while the rest of the view moves.
  • Scrollback with momentum. Scroll by line, jump to top or bottom, drag with the mouse, and apply a velocity for a flick gesture that decays naturally.
  • Text selection. Click and drag to select, select all, and copy to the system clipboard. See Selection and Mouse.
  • Optional command line. A prompt with a cursor, character filtering, and clear-after-submit, raising an event with the submitted string.
  • Pluggable history. IInputHistory handles up and down navigation; a ring-buffer implementation ships, and your own can replace it.
  • Pluggable completion. IAutoCompleter handles tab completion in both directions, with a suggestion hint rendered under the prompt.
  • Clickable hyperlinks. Ctrl+click on an OSC 8 link in full-screen mode opens it in the browser, and a host can handle links itself through an event.
  • Key encoding. Arrows, function keys, Home and End, Page Up and Down, and modifier combinations are encoded into the escape sequences a hosted program expects.
  • No Input module dependency. Reads the keyboard directly, so it works in any project.
  • Theme aware. Names a theme and follows it, or names none and keeps the colors its host gave it. See Color Themes.

Log mode and full-screen mode

The two modes exist because the two jobs are genuinely different, and choosing the wrong one is the most common source of confusion.

Log modeFull-screen mode
Who owns the screenThe terminal. Lines accumulate and scroll.The hosted program. It positions the cursor and draws wherever it likes.
ScrollbackActive. Old lines are retained and reachable by scrolling.Lines scrolled off the top of the primary screen are kept and reachable with ScrollFullScreenBy. The alternate screen has none.
Command lineAvailable at the bottom.Off. The program handles its own input.
Typical useA cheat console, a log window, a chat panel, a diegetic readout.A hosted editor, a menu-driven program, anything using the alternate screen.
How you write to itAppendLine, or Feed for bytes.Feed, letting escape sequences drive the display.

Switching with SwitchToFullScreen and SwitchToLogMode keeps the scrollback buffer, so returning from a full-screen program puts the player back where they were.

Recipes

Place a terminal in a scene without code

Problem. You want a terminal in the scene that the player can type commands into, and you would rather set it up in the Inspector than build it from code.

Solution. Add a TextModeTerminalHost to a RectTransform under a Canvas, either through Add Component > Scylla > TextMode > TextMode Terminal or with the Terminal / Shell setup in the TextMode Wizard. The terminal fills the RectTransform, minus the padding in its settings. In Log mode with the command line enabled, the host reads the keyboard into the command line and reports each submitted command, so game code only has to answer it.

[SerializeField] private TextModeTerminalHost _host;

private void Start()
{
    _host.CommandSubmitted += OnCommandSubmitted;
}

private void OnCommandSubmitted(string command)
{
    /* The terminal exists from the first frame of Play mode, so it is safe to write to here. */
    _host.Terminal.AppendLine($"Unknown command: {command}", new Color32(255, 80, 40, 255));
}

The same event is also available as Command Submitted in the Inspector. Color Theme decides where the colors come from: the project theme set on the TextMode configuration, which the terminal follows when it changes, a theme of its own named in the settings, or no theme at all.

Put a working terminal in a scene from code

Problem. You want a command console the player can open with a key, type into, and scroll back through. Building that from a text field and a scroll rect is a day of work you would rather not spend.

Solution. Construct the terminal against a parent transform, initialize it, and drive its update.

/* Start from the defaults and change only what your game cares about. CreateDefault fills in
   every field, so you are never partially initialized. */
var settings = TextModeTerminalSettings.CreateDefault();
settings.FontSize = 18f;
settings.ScrollbackCapacity = 2000;
settings.CLIEnabled = true;
settings.CommandPrompt = "> ";

/* The terminal builds its own view under the parent you give it. */
_terminal = new TextModeTerminal(_panelTransform, settings);
_terminal.Initialize();

/* It needs a per-frame tick for input, blinking and scroll momentum. */
private void Update()
{
    _terminal.Update();
}

/* Dispose releases the view and the GameObject it created. */
private void OnDestroy()
{
    _terminal.Dispose();
}

Wire the command line to your game’s commands

Problem. You have a command registry already, and you want the terminal’s prompt to run those commands rather than reimplementing a parser.

Solution. Subscribe to the submit event and write results back with AppendLine.

/* Fires when the player presses Enter, with whatever they typed. */
_terminal.CLI.OnCommandSubmitted += command =>
{
    /* Echo the command so the log reads like a transcript. */
    _terminal.AppendLine($"> {command}", ANSIColor.BrightBlack);

    var result = _commandRegistry.Execute(command);

    _terminal.AppendLine(result.Message,
        result.Succeeded ? ANSIColor.White : ANSIColor.BrightRed);
};

/* Escape is raised separately, which is the natural hook for closing the console. */
_terminal.CLI.OnEscapePressed += () => SetConsoleOpen(false);

Give the prompt history and tab completion

Problem. Players expect up-arrow to recall the last command and Tab to complete a verb. Writing both is fiddly, especially completion that cycles.

Solution. Assign an IInputHistory and an IAutoCompleter. A history implementation ships; completion is where you plug in your own command names.

/* The bundled history keeps a bounded list and handles up and down navigation, including
   restoring the partially typed line when the player navigates back down past the newest. */
_terminal.CLI.History = new InputHistory(capacity: 50);

/* Completion is yours, because only your game knows its verbs. Complete returns the next
   candidate, and backwards lets Shift+Tab cycle the other way. */
_terminal.CLI.AutoCompleter = new CommandCompleter(_commandRegistry);

Host a program that draws its own screen

Problem. Your game embeds a fictional text editor, and it needs the whole grid. In log mode its cursor movements fight the scrolling log.

Solution. Switch to full-screen mode for the duration and back afterwards.

/* The program now owns the grid. Scrollback is retained but not displayed. */
_terminal.SwitchToFullScreen();

/* Feed the program's output. It will position the cursor and redraw as it likes. */
_terminal.Feed(programOutputBytes);

/* When the program exits, hand the display back. The log is where the player left it. */
_terminal.SwitchToLogMode();

Let the player scroll and copy

Problem. A player wants to scroll back to an error and copy it out to paste into a bug report.

Solution. Scrolling and selection are already there; wire them to your input.

/* Mouse wheel scrolls by lines. Negative scrolls toward older content. */
_terminal.ScrollBy(-Mathf.RoundToInt(scrollDelta * LINES_PER_NOTCH));

/* A flick gesture: hand it a velocity and it decays on its own, so you do not animate it. */
_terminal.ApplyScrollVelocity(flickVelocity);

/* Drag selection is three calls matching the three phases of a drag. */
_terminal.HandleDragStart(pointerPosition);
_terminal.HandleDrag(pointerPosition);
_terminal.HandleDragEnd(pointerPosition);

/* And the copy the player is actually after. */
if (_terminal.HasSelection)
{
    _terminal.CopySelectionToClipboard();
}

Scroll back through a full-screen session

Problem. A shell runs in full-screen mode, and its output has scrolled off the top. The player wants to read it again without the shell’s status line scrolling away with it.

Solution. Pin the status row, then scroll the view on the keys and the wheel your game uses. Output that keeps arriving leaves the view on the same lines, and the live screen comes back with one call.

/* The bottom row belongs to the present, so it stays put while the rest scrolls back. */
_terminal.PinnedBottomRows = 1;

/* Shift+Page Up and Page Down page through the history; positive values go back in time. */
_terminal.ScrollFullScreenBy(pageUp ? rows - 2 : -(rows - 2));

/* Typing goes to the live screen, so return to it first. */
if (_terminal.FullScreenScrollOffset > 0)
{
    _terminal.ScrollFullScreenToBottom();
}

A program that switches to the alternate screen takes the view back to the live screen, since the alternate screen has no history.

Show and hide the console without rebuilding it

Problem. Opening the console should be instant, and rebuilding the terminal every time the player toggles it wastes the scrollback along with the time.

Solution. SetVisible toggles the view without touching the terminal’s state.

/* Construct once, at startup. */
private void Start()
{
    BuildTerminal();
    _terminal.SetVisible(false);
}

/* Toggling is free, and the scrollback, the command history and the current input all survive. */
private void SetConsoleOpen(bool open)
{
    _terminal.SetVisible(open);

    if (open)
    {
        _terminal.CLI.Focus();
    }
    else
    {
        _terminal.CLI.Unfocus();
    }
}

API Sketch

class TextModeTerminal : IDisposable {
    TextModeTerminal(RectTransform parent, TextModeTerminalSettings settings);

    void Initialize();
    void Update();                       /* drive from MonoBehaviour.Update */
    void Dispose(); void Destroy();

    UITextModeView View; TerminalEmulator Emulator; TerminalCLI CLI;
    GameObject RootObject; RectTransform RootTransform;
    bool IsFullScreenMode; bool HasSelection;
    float ScrollPosition, ViewportHeight;

    void AppendLine(...);                /* log-mode output */
    void Feed(...);                      /* byte or string input to the emulator */
    void Clear();
    void SetVisible(bool visible);
    void UpdateSettings(TextModeTerminalSettings settings);
    void UpdateFont(TMP_FontAsset fontAsset); /* rebuilds the view and restamps the emulator */

    void SwitchToFullScreen(); void SwitchToLogMode();

    int FullScreenScrollOffset, FullScreenScrollbackLength;
    int PinnedBottomRows { get; set; }
    void ScrollFullScreenBy(int lines); void ScrollFullScreenToBottom();

    void ScrollBy(int lines); void ScrollTo(float position);
    void ScrollToTop(); void ScrollToBottom();
    void ApplyScrollVelocity(float velocity);

    void HandleDragStart(Vector2 p); void HandleDrag(Vector2 p); void HandleDragEnd(Vector2 p);
    void SelectAll(); void CopySelectionToClipboard();

    event Action<string> OnHyperlinkClicked; /* Ctrl+click on a link in full-screen mode */
}

class TextModeTerminalHost : MonoBehaviour, ITextModeViewHost {
    TextModeTerminal Terminal;           /* null outside Play mode */
    UITextModeView View;
    TextModeTerminalStartMode StartMode { get; set; }   /* Log or FullScreen */
    TextModeTerminalColorTheme ColorTheme { get; set; } /* Project, Own or None */
    bool FocusCommandLine { get; set; }
    TMP_FontAsset FontAsset { get; set; }
    TextModeTerminalSettings Settings { get; set; }
    event Action<string> CommandSubmitted;
}

class TerminalCLI {
    event Action<string> OnCommandSubmitted;
    event Action OnEscapePressed;
    IInputHistory History { get; set; }
    IAutoCompleter AutoCompleter { get; set; }
    void Focus(); void Unfocus(); void Clear();
}

interface IInputHistory {
    string NavigateUp(string currentInput);
    string NavigateDown();
    void ResetNavigation();
    void Add(string entry);
}

interface IAutoCompleter {
    string Complete(string input, bool backwards = false);
    int GetSuggestions(Span<string> destination);
    void Reset();
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Settings

TextModeTerminalSettings is a struct. Start from CreateDefault and override what you need; constructing one field by field leaves the rest at zero, which is rarely what you want.

GroupSettingsNotes
TextFontSize, LineSpacing, CharSpacing, Padding, CellBorderPadding insets the view from the parent rectangle in pixels; that space is outside the grid. CellBorder frames the content with whole empty cells that belong to the grid, so effects such as the CRT cover them, while the terminal’s columns, wrapping and cursor stay inside. The border cells always show the grid’s background color. FillPadding, on in CreateDefault, asks whoever places the terminal to fill the padding with the same color; TextModeTerminalHost does, and with it off the padding is transparent.
ColorsDefaultForeground, DefaultBackground, SelectionColor, SelectionTextColorA transparent background lets whatever is behind the terminal show through, which is how an overlay console sits over a game.
CursorCursorStyle, CursorBlinking, CursorBlinkRate, CursorColorSix styles; blinking is animated in the shader and costs nothing per frame.
BlinkTextBlinkRateSeparate from the cursor so the two need not agree.
ThemeThemeNameNames a palette to follow. Left empty, the terminal keeps the colors its host set, which is how the debug console preserves its own styling. A DefaultBackground equal to the theme’s own background follows theme changes; any other background, a transparent one included, is left alone.
ScrollbackScrollbackCapacity, AutoScroll, EnableDragScrollAuto-scroll keeps the view pinned to the newest line unless the player has scrolled away.
CLICLIEnabled, CommandPrompt, CLITextColor, FilteredCharacters, ClearAfterSubmitFiltered characters are rejected on input, which is how you keep control characters or separators out of a command.
SelectionSelectionEnabledOff disables drag selection entirely, which suits a display the player should not be able to copy from.
HyperlinksOpenHyperlinksOnCtrlClickOn by default. Ctrl+click opens http, https and mailto links in the browser. Turn it off and handle OnHyperlinkClicked to decide yourself what a link does.

Best Practices

  • Start from CreateDefault. The settings type is a struct, so a hand-built one starts at zero for everything you did not set, including font size.
  • Dispose it. The terminal owns a GameObject. OnDestroy calling Dispose is the pattern.
  • Drive Update yourself. The terminal is not a MonoBehaviour, so it can live wherever you want; the cost is that it needs a tick. TextModeTerminalHost does this for you, and disposes the terminal as well.
  • Choose the mode deliberately. A hosted program in log mode fights the scroll; a log in full-screen mode loses its history display. Switching is cheap, so switch rather than compromising.
  • Toggle visibility rather than rebuilding. SetVisible keeps scrollback, history and the current input line intact.
  • Use AppendLine for log output and Feed for streams. AppendLine understands lines and scrollback; Feed goes to the emulator and expects terminal semantics.
  • Plug in completion rather than pre-filtering input. Rejecting characters is a blunt tool; players actually want a completer that knows your verbs.
  • Leave ThemeName empty when the host owns the colors. That is the opt-out. It keeps a theme change from repainting a console that was styled on purpose.
  • Cap scrollback to something you have tested. It is a ring buffer, so the memory is bounded and allocated up front; a very large capacity is paid for whether or not the player scrolls.
  • Filter characters sparingly. FilteredCharacters is for things that would break your parser, not for validation you can do better after submit.

Pitfalls