Folio Docs
Fogspire/Folio
git clone https://github.com/Fogspire/Folio.git Plugins/Folio

A CRT terminal in the world

Build a working computer terminal on a curved CRT screen in a level: a login form you can type into, radio channels, a pop-up window you can drag, resize and close with an animation, and a bin you can drop files into. Then put it on a mesh with scan lines and a phosphor glow.

The video was made prior to the creation of Folio Studio

One document and one component. No Blueprint graph, no widget tree; the only C++ points an input action at the component.

The finished thing is examples/crt_terminal.rml. Open it beside this page.

Shell
./bin/win64/uipreview.exe examples/crt_terminal.rml --root . --size 1200x900

1. The shape of the problem

The screen is an oval, wider than tall. The document is a rectangle, because a render target and a projection both are.

The corners of the document are not on the mesh, so nothing may be laid out there.

The oval is a radial falloff rather than border-radius: 50%: a hard edge would show on the mesh, and the falloff reads as glass there and as an oval on a flat surface.

RCSS
body {
	font-family: Roboto Mono;
	font-size: 32dp;
	color: #7dff9b;
	background-color: transparent;
	width: 100%;
	height: 100%;
	box-sizing: border-box;
	position: relative;
	overflow: hidden;
}

width: 100% and height: 100% both matter. A document is laid out shrink-to-fit against its context regardless of display, so without them the body is content-height and the background stops partway down.

Content sits inside the ellipse's inscribed rectangle, which is 1/sqrt(2) of each axis, about 70%:

RCSS
.screen {
	position: absolute;
	left: 15%; top: 15%;
	width: 70%; height: 70%;
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
}

Then each row gets its own width, so the lines nearest the top and bottom stay clear of the curve:

RCSS
.narrow { width: 62%; }
.mid    { width: 84%; }
.wide   { width: 100%; }

The result is a lens-shaped block of text, not a centred rectangle.


2. Text you can type into

RML
<div class="wide row">
	<span class="label">OPERATOR</span>
	<input type="text" class="field" id="operator" maxlength="24"/>
</div>
<div class="wide row">
	<span class="label">ACCESS</span>
	<input type="password" class="field" id="access" maxlength="16"/>
</div>

input is one of the few real element types. Caret, selection, editing and clipboard come from RmlUi, and a field on a monitor in a room behaves like a field on a menu.

Style the caret with caret-color, and the focus state with :focus-visible, never :focus, so the ring stays hidden during mouse use.

While a field has focus, letters are letters. Folio's WASD and IJKL navigation schemes stand down automatically, so typing works. See input and navigation.


3. Channels and buttons

RML
<div class="channel">
	<input type="radio" name="channel" id="ch_a" checked/>
	<span>ALPHA</span>
</div>

Radios share a name and RmlUi handles the exclusivity. The label is the sibling <span>, which also makes the control count as named for the accessibility check; see accessibility.

RML
<button id="engage">ENGAGE</button>

<button> is not a button. It is a generic element like div, with no focus behaviour and no click handling until it gets tab-index: auto:

RCSS
button { tab-index: auto; nav: auto; }

With that, Enter, Space and the gamepad confirm button all synthesise a click.


4. A window you can move and resize

No code:

RML
<div id="win" class="window">
	<handle class="titlebar" move_target="win">DISK UTILITY</handle>
	<div class="close" folio-close="win" folio-label="Close">X</div>
	<div class="window-body">…</div>
	<handle class="grip" size_target="win"></handle>
</div>
RCSS
/* Absolute, because a handle writes left, top, width and height, and those mean nothing to a
   flex item. */
.window { position: absolute; left: 12%; top: 22%; width: 420dp; height: 260dp; }
.grip   { position: absolute; right: 0; bottom: 0; width: 20dp; height: 20dp; }

<handle> is a real element type. move_target moves the element with that id and size_target resizes it.


5. Icons you can drag, and a bin

RML
<div id="icon_a" class="desk-icon" drag="drag-drop" folio-drag="move" style="left: 28dp; top: 66dp;">
	<span class="glyph"></span>CORE.DAT
</div>

<div class="bin" folio-drop="remove">
	<span class="glyph"></span>RECYCLE
</div>

drag: drag-drop, not drag: drag. RmlUi only looks for a drop target when the dragged element's drag is drag-drop or clone. With plain drag the bin never hears about it and folio-drop never runs.

folio-drag="move" is what moves it. drag alone fires events and leaves the element where it was. The element must be position: absolute, because folio-drag writes left and top.

Style the states, or a player cannot tell whether a drop lands:

RCSS
.desk-icon.folio-dragging { opacity: 0.55; }
.bin.folio-dragover       { border-color: #7dff9b; background-color: #7dff9b1a; }

folio-drop and folio-close are looked for up the tree, so dropping on the bin's glyph or its label is dropping on the bin.


6. Animating things out

Both the close and the drop set folio-closing and wait for an animation before removing anything:

RCSS
@keyframes consume { from { transform: scale(1); opacity: 1; }
                     to   { transform: scale(0.05); opacity: 0; } }
.desk-icon.folio-closing { animation: 0.35s cubic-in consume; }

@keyframes suck { to { transform: translateY(520dp) scale(0.04); opacity: 0; } }
.window.folio-closing { animation: 0.5s cubic-in suck; }

Without an animation on the class, the element goes at once.

On a surface, the render target is the document, so anything translated past its edge is clipped, and text that leaves the context is culled outright. An exit animation that slides off screen vanishes partway. Shrink towards an edge instead, as suck does.

Two animation rules that never warn:

  • Times are seconds. ms does not exist. 0.35s, never 350ms: the latter parses as a reverse adjustment factor and produces no animation at all.
  • The easing names are RmlUi's. cubic-in, cubic-out, cubic-in-out. ease-out does not exist; a misspelled name is read as a property name and silently never fires.

7. The CRT look

Three stacked overlays, all pointer-events: none so they never eat a click:

RCSS
.scanlines { decorator: shader("scanlines #7fe8ff90 0.004 0.5"); }
.vignette  { decorator: radial-gradient(circle farthest-corner at 50% 50%, #00000000 60%, #000000b0); }
.roll      { /* a slow vertical sweep, animated with transform: translateY */ }

decorator: shader(...) reaches the three effects RCSS has no syntax for: additive, scanlines and sweep. They composite premultiplied, so a colour with zero alpha adds to what is under it, identically in both renderers.

The glow on the title is a font effect; text-shadow does not exist:

RCSS
.title { font-effect: glow(3dp #2bff7a80); }

A font effect reaches about 0.104em, so 3dp of glow on 32dp text is well inside it and 3dp on 13dp text would be clamped with a warning. See text and fonts.


8. Onto a mesh

Add a Folio World Document component to an actor with the CRT mesh.

Document Pathexamples/crt_terminal.rml
Draw ModeSurface
World Sizethe screen's size in world units: for a 135.97 × 107.314 monitor, that
Pixels Per Unitpick so WorldSize × PixelsPerUnit lands near your target resolution

Surface is the only mode with layer effects: the vignette gradient, any filter and any box-shadow need an offscreen target. The price is one resample, which is why it is not the default.

Then bind the target to a material. /Folio/M_FolioSurface ships with the plugin: instance it, and feed its Element texture parameter from the component's OnSurfaceChanged event.

Full setup, including the assert you get from an oversized Pixels Per Unit: documents in the world. The material domain is in world document settings.

Scan lines in the material

The document's scan lines are a decorator, drawn flat. For scan lines that bend with the glass and roll independently of the UI, do it in the material, as the shipped M_FolioSurfaceCRT node export does.

The graph, in order:

  1. TextureCoordinate: CoordinateIndex 1, VTiling 3. UV1 so it is independent of the element's own UVs, tiled three times down the screen.
  2. Panner: that coordinate, Time from a Time node, Speed from a MakeVector2 whose Y is a ScalarParameter ScanLineSpeed. Panning only in V, at a speed the material instance sets.
  3. TextureSampleParameter2D ScanLines: the panned coordinate, sampling a crt_lines texture.
  4. Multiply: its red channel by a ScalarParameter ScanLineAlpha, so the effect has a strength dial.
  5. Lerp: A = 1, B = 0, Alpha = that multiply. This turns the scan-line mask into an exponent that is 1 where there is no line and 0 where there is.
  6. Power: Base = the Element sample's RGB, Exp = that lerp.

Driving an exponent rather than multiplying makes it look like a CRT, not a striped overlay: a power curve keeps bright phosphor bright and eats the dim areas, as the scan gap does.

The node export is at dev/M_FolioSurfaceCRT.nodes.txt: copy its contents and paste into a material graph.


9. Pointing input at it

The component takes pointer and keyboard input; you tell it where the pointer is.

C++
// Drive the pointer from where the player is looking. The trace is the game's, so it can use
// whatever channel and reach the rest of the interaction system already uses.
FHitResult Hit;
if (GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Visibility))
{
    Terminal->MovePointerToHit(Hit);
}

// Then the usual press and release from your input action.
Terminal->PressPointer(EKeys::LeftMouseButton);
Terminal->ReleasePointer(EKeys::LeftMouseButton);

// And hand it the keyboard so the fields can be typed into.
Terminal->FocusDocument();

The button is named rather than assumed, so a right click can mean something different from a left one, and ClearPointer() takes the pointer off the surface.

Pointer From Player Viewtrace from the camera rather than from a supplied position
Pointer Uses Mesh UVread the hit's UV rather than projecting onto the component's plane. On for a curved screen
Invert Pointer VUV conventions differ by exporter. If the pointer is mirrored vertically, this is the fix, not a curvature problem
Show Cursordraw a cursor on the surface, since the OS one is elsewhere

Release Focus gives the keyboard back to the game.

If the pointer is consistently offset, check Invert Pointer V before the geometry.

folio.World.ShowSurface 1 logs what the pointer is doing, to tell "nothing is driving the pointer" from "the pointer is in the wrong place".


10. Check it

Shell
./bin/win64/uipreview.exe examples/crt_terminal.rml --root . --strict --a11y

Both clean on the shipped document. Then in the editor:

folio.Reloadrebuild from disk without leaving PIE
folio.World.Verifyconfirm the world path is drawing what it should
folio.Surface.Shotwrite the surface render to a PNG, comparable with the preview's

What you have learned

  • layout under a non-rectangular constraint (§1)
  • real form controls and focus (§2, §3)
  • <handle> move and resize with no code (§4)
  • drag-drop, folio-drag, folio-drop and the state classes (§5)
  • animated removal that the engine waits for (§6)
  • shader decorators and font effects (§7)
  • a document on a mesh, and a material that treats it as a screen (§8)
  • pointer and keyboard into a world document (§9)

Where to go next