Text Styling

Ply has a built-in rich text system that lets you color, animate, and transform individual characters.

Requires the text-styling feature flag:

[dependencies]
ply-engine = { version = "1.0", features = ["text-styling"] }

🔗Syntax

Wrap styled text in {tag|content}. The tag goes before the pipe, the content after:

Use underscores to chain parameters:

Tags can nest. Inner tags override outer ones if they conflict:

Escaping: Use \ to insert literal {, }, |, or \:

🔗Properties

Static attributes on the wrapped text. These are optimized in rendering.

🔗color

Sets the text fill color. Accepts hex (#RRGGBB), RGB tuples ((r,g,b)), or named colors.

Named colors: white, black, lightgray, darkgray, red, orange, yellow, lime, green, cyan, lightblue, blue, purple, magenta, brown, pink (case insensitive).

🔗opacity

Makes text semi-transparent:

Properties combine, nest opacity inside a color tag:

🔗Effects

Per-character visual effects that create movement, shadows, or gradients.

🔗wave

Vertical sine wave:

ParameterWhat it doesDefault
wWavelength in characters3
fFrequency (cycles/sec)0.5
sSpeed (chars/sec) — overrides f
aAmplitude (ratio of font size)0.3
pPhase offset (0–1)0
rDirection rotation in degrees0

🔗pulse

Characters grow and shrink in a wave:

ParameterWhat it doesDefault
wWavelength in characters2
fFrequency (cycles/sec)0.6
sSpeed (chars/sec) — overrides f
aScale amplitude0.15
pPhase offset (0–1)0

🔗swing

Pendulum rotation per character:

ParameterWhat it doesDefault
wWavelength in characters3
fFrequency (swings/sec)0.5
sSpeed (chars/sec) — overrides f
aAmplitude in degrees8
pPhase offset (0–1)0

🔗jitter

Random character displacement:

ParameterWhat it doesDefault
radiiHorizontal,vertical offset (font ratio)0.5,0.5
rotationRotation of the jitter ellipse (degrees)0

🔗gradient

Cycling color gradient across characters:

The default is a full rainbow. Custom stops use position:color pairs:

ui.text("{gradient_stops=0:#FF0000,5:#FFC32C_speed=2|Fire text}", |t| t.font_size(24));
ParameterWhat it doesDefault
stopsComma-separated pos:colorrainbow
speedScroll speed (chars/sec)1

🔗shadow

Draws a duplicate behind each character:

ui.text("{shadow_color=#000000_offset=-0.1,0.1|Shadowed}", |t| t.font_size(24));
ParameterWhat it doesDefault
colorShadow colorblack
offsetX,Y offset (font ratio)-0.3,0.3
scaleShadow size multiplier1.0

🔗transform

Static per-character transform:

ParameterWhat it doesDefault
translateX,Y offset (font size ratio)0,0
scaleX,Y size multiplier1.0
rotateRotation in degrees0

🔗hide

Prevents rendering entirely. Useful for reserving space or with animations:

🔗Animations

Time-based transitions tracked by a unique id. Every animation needs either in (appear) or out (disappear).

🔗type

Typewriter reveal:

ParameterWhat it doesDefault
in/outDirection (required)
idUnique identifier (required)
speedCharacters per second8
delayDelay before starting (seconds)0
cursorCharacter to show as cursornone

Show a blinking cursor while typing:

🔗fade

Opacity transition, character by character:

ParameterWhat it doesDefault
in/outDirection (required)
idUnique identifier (required)
speedCharacters per second3
trailGradient length in characters3
delayDelay before starting (seconds)0

🔗scale

Pop-in or pop-out by scaling each character:

ParameterWhat it doesDefault
in/outDirection (required)
idUnique identifier (required)
speedCharacters per second3
trailGradient length in characters3
delayDelay before starting (seconds)0

🔗Combining styles

Stack tags to combine effects. Effects compose: transforms accumulate, colors override:

🔗Styled text input

The text styling syntax works inside text inputs too. When text-styling is enabled, you can add styles and have the user interact with and see it rendered live.

Use .no_styles_movement() so the cursor skips over style tag boundaries, this is useful when you are highlighting code:

ui.element()
  .id("styled_editor")
  .width(grow!())
  .height(fixed!(200.0))
  .background_color(0x1A1A28)
  .corner_radius(6.0)
  .text_input(|t| t
    .multiline(true)
    .font_size(14)
    .text_color(0xDDDDDD)
    .no_styles_movement()
  )
  .empty();

🔗Live highlighting

Build a highlighter that converts plain text to styled text, then apply it on every frame. Use the styling module:

use ply_engine::text_input::styling;

fn highlight(plain: &str) -> String {
  plain.split(' ').map(|word| {
    if word.starts_with('#') {
      format!("{{color=#FFC32C|{}}}", styling::escape_str(word))
    } else {
      styling::escape_str(word)
    }
  }).collect::<Vec<_>>().join(" ")
}

Apply each frame:

let raw = ply.get_text_value("styled_editor").to_string();
if !raw.is_empty() {
  let plain = styling::strip_styling(&raw);
  let highlighted = highlight(&plain);

  if raw != highlighted {
    let cursor = ply.get_cursor_pos("styled_editor");
    let content_pos = styling::cursor_to_content(&raw, cursor);
    ply.set_text_value("styled_editor", &highlighted);
    let new_cursor = styling::content_to_cursor(&highlighted, content_pos, false);
    ply.set_cursor_pos("styled_editor", new_cursor);
  }
}

🔗styling functions

FunctionWhat it does
escape_str(s)Escapes all style delimiters in a string
strip_styling(s)Removes all style tags, returning plain content
cursor_to_content(s, pos)Converts cursor pos to content character index
content_to_cursor(s, pos, snap_to_content)Converts content character index to cursor pos. When snap_to_content is true, the cursor skips structural positions like } and lands on the next visible character.

🔗Processing order

When multiple tags are active on the same text, they are processed in this order:

  1. hide
  2. type (animation)
  3. fade (animation)
  4. scale (animation)
  5. transform
  6. wave
  7. pulse
  8. swing
  9. jitter
  10. gradient
  11. opacity
  12. color
  13. shadow

Later entries override earlier ones if they affect the same property (color, opacity).

🔗Next steps

Shaders & Effects