Skip to content
led-ticker

two_row widget

The two_row widget renders two independent text strings on the same canvas: a held top row for a stable identifier (handle, headline, brand tag) and a scrolling bottom row for promotional copy that can be longer than the canvas width. When the bottom row fits, it stays fixed using bottom_align; when it overflows, it scrolls left automatically.

Designed for the bigsign in slideshow mode at scale = 2, where the logical canvas is 128 pixels wide — enough for most social handles without scrolling.

two_row at scale=1 — top: @example (IG magenta), bottom: scrolling promo (cream); rendered locally so the marquee completes one full pass
two_row at scale=1 — top: @example (IG magenta), bottom: scrolling promo (cream); rendered locally so the marquee completes one full pass

Minimal example

[[playlist.section.widget]]
type = "two_row"
top_text = "@yourhandle"
bottom_text = "Open daily 10–6 · all levels welcome"

Top row

Option Type Default Description
top_text string required Text for the held top row. Inline :slug: emoji are rendered as pixel art.
top_color RGB list / string / table [255, 255, 0] Top-row color. Constant [r,g,b], "rainbow", "color_cycle", "random", or {style="gradient", from=[...], to=[...]}.
top_align string "center" Horizontal alignment when text fits: "left", "center", or "right".
top_font string (same as font) Per-row font override. Falls back to font when unset.
top_font_size int (BDF cell height) Real-pixel font size for hires top_font. Required when top_font is a hires font name.
top_font_threshold int 0–255 128 Rasterization threshold for hires top_font. Lower = thicker glyphs.
top_text_y_offset int 0 Vertical nudge for the top row's text in logical rows. Negative = up.
top_emoji_y_offset int 0 Vertical nudge for the top row's emoji in logical rows. Set equal to top_text_y_offset to shift the whole row together.
top_bg_color RGB list none Per-band background fill for the top row only. Painted over any bg_color.

Bottom row

Option Type Default Description
bottom_text string required Text for the scrolling bottom row. Scrolls left when it overflows the canvas width.
bottom_color RGB list / string / table [255, 255, 0] Bottom-row color. Same value types as top_color.
bottom_align string "center" Alignment when bottom text fits without scrolling. Ignored when text overflows.
bottom_font string (same as font) Per-row font override for the bottom row. Falls back to font when unset.
bottom_font_size int (BDF cell height) Real-pixel font size for hires bottom_font. Required when bottom_font is a hires font name.
bottom_font_threshold int 0–255 128 Rasterization threshold for hires bottom_font. Lower = thicker glyphs.
bottom_text_y_offset int 0 Vertical nudge for the bottom row's text in logical rows. Negative = up.
bottom_emoji_y_offset int 0 Vertical nudge for the bottom row's emoji in logical rows.
bottom_bg_color RGB list none Per-band background fill for the bottom row only. Painted over any bg_color.
bottom_text_wrap bool false Seamless wrap mode for the bottom row. When true, the bottom row repeats with a separator between copies and at least one full copy is on the panel at every tick. Top row never wraps. Only allowed in mode = "slideshow".
bottom_text_separator string " • " (when bottom_text_wrap = true) Glyph(s) drawn between bottom-row repeats in wrap mode. "" falls back to a two-space gap. Rendered in the bottom row's font.
bottom_text_separator_color color spec inherit bottom_color Color for the bottom separator in wrap mode. Whole-string provider (one hue per frame); accepts the same value types as bottom_color. Inherits bottom_color (NOT font_color).
bottom_text_loops int 0 Minimum number of full wrap cycles before the section can transition. One cycle = bottom_text + separator. Requires bottom_text_wrap = true (rule 28). On gif / image widgets the equivalent field is text_loops.

Layout

Option Type Default Description
top_row_height int none Give the top band exactly N logical rows; bottom gets the remainder. Default None splits 50/50. Use with a small font on top + a larger bottom_font to achieve a compact tag + wide marquee. Must be > 0 and < canvas.height.
padding int 6 Horizontal padding added to the bottom row's cursor position when scrolling (spacing between repeats).

scale and content_height are section-level fields, not widget fields. Typical bigsign deployments use scale = 2 (doubles logical canvas width to 128 px so handles fit on the top row) and content_height = 16 (hard ceiling: content_height × scale ≤ panel_h_real). See display concepts for details.

Shared

Option Type Default Description
font string "5x8" Default font for both rows when top_font / bottom_font are unset. BDF alias (e.g. "5x8", "6x12") or hires font name (e.g. "Inter-Bold").
bg_color RGB list none Background fill for the full panel before text. Per-band top_bg_color / bottom_bg_color paint on top of this.
border "rainbow" | "color_cycle" | "lightbulbs" | [r,g,b] | {style="...", ...} none Perimeter border ring — five styles (rainbow chase, color cycle, constant, bands, lightbulbs); see /concepts/borders/. Paints at physical panel resolution (bypasses ScaledCanvas), so the border traces the real panel edge, not the logical canvas edge.
centered handle + inline emoji on top, left-aligned promo on the bottom (bigsign canvas, scale=2)
centered handle + inline emoji on top, left-aligned promo on the bottom (bigsign canvas, scale=2)
[[playlist.section]]
mode = "slideshow"
scale = 2
content_height = 16
[[playlist.section.widget]]
type = "two_row"
top_text = "@firebirdyoga.demo :instagram:"
top_color = [225, 48, 108]
top_align = "center"
bottom_text = "Vinyasa · yin · hot power · restorative · slow flow · all levels"
bottom_color = [255, 240, 200]
bottom_align = "left"

Asymmetric row split — bold tag on top, larger marquee below

Section titled “Asymmetric row split — bold tag on top, larger marquee below”
Inter-Bold @ 14 handle on a 10-row top band, Inter-Bold @ 22 marquee on the 14-row bottom band
Inter-Bold @ 14 handle on a 10-row top band, Inter-Bold @ 22 marquee on the 14-row bottom band

top_row_height = 10 gives the top band 10 logical rows; the bottom gets the remainder (14). The top font’s line-height has to fit the band — Inter-Bold @ 14 is 9 logical rows at scale=2, which fits the 10-row band. To go more aggressive (top_row_height = 6 for a thinner tag), pair it with a smaller hi-res font like Inter-Regular @ 8 — but Regular at that size gets hard to read at typical viewing distances, so the bolder pairing here is the recommended starting point.

[[playlist.section]]
mode = "slideshow"
scale = 2
content_height = 24
[[playlist.section.widget]]
type = "two_row"
top_text = "@firebird.demo"
top_row_height = 10
top_font = "Inter-Bold"
top_font_size = 14
top_color = [225, 48, 108]
bottom_text = "Vinyasa • Yin • Hot Power • Restorative • Slow Flow • All Levels Welcome"
bottom_font = "Inter-Bold"
bottom_font_size = 22
bottom_color = [255, 240, 200]

Per-row font hierarchy (Inter-Bold handle + Inter-Regular promo)

Section titled “Per-row font hierarchy (Inter-Bold handle + Inter-Regular promo)”
Inter-Bold @ 18 on top, Inter-Regular @ 14 on the scrolling row — establishes a clear visual hierarchy
Inter-Bold @ 18 on top, Inter-Regular @ 14 on the scrolling row — establishes a clear visual hierarchy

Hi-res fonts at these sizes need taller bands than the default 8 logical rows. Bump content_height from 16 to 24 so each row gets 12 logical rows — Inter-Bold @ 18 measures 12 logical at scale=2 and only fits if the band is at least that tall.

[[playlist.section]]
mode = "slideshow"
scale = 2
content_height = 24
[[playlist.section.widget]]
type = "two_row"
top_text = "@firebird.demo"
top_font = "Inter-Bold"
top_font_size = 18
top_color = [225, 48, 108]
bottom_text = "Firebird Yoga — now booking spring sessions — your first class is free."
bottom_font = "Inter-Regular"
bottom_font_size = 14
bottom_color = [255, 240, 200]

Hi-res emoji on the top row — :instagram: renders as the full 32×32 sprite when the band can fit it

Section titled “Hi-res emoji on the top row — :instagram: renders as the full 32×32 sprite when the band can fit it”
Inter-Bold @ 22 handle next to the hi-res `:instagram:` sprite on a 16-row top band, scrolling promo on the 8-row bottom
Inter-Bold @ 22 handle next to the hi-res `:instagram:` sprite on a 16-row top band, scrolling promo on the 8-row bottom

The hi-res sprite is 32 real pixels tall. At scale = 2 it’s 16 logical pixels, which exactly matches top_row_height = 16 — the sprite anchors at the top of the band with no bleed into the bottom row. Smaller bands fall back to the 8×8 lo-res sprite automatically.

[[playlist.section]]
mode = "slideshow"
scale = 2
content_height = 24
[[playlist.section.widget]]
type = "two_row"
top_text = ":instagram: @firebirdyoga.demo"
top_row_height = 16
top_font = "Inter-Bold"
top_font_size = 22
top_color = [225, 48, 108]
top_align = "left"
bottom_text = "Vinyasa • yin • hot power • restorative • slow flow • all levels"
bottom_color = [255, 240, 200]
bottom_align = "left"
held magenta `BREAKING` on top, cyan `tap to subscribe` wrapping continuously on the bottom with a rainbow `*` separator
held magenta `BREAKING` on top, cyan `tap to subscribe` wrapping continuously on the bottom with a rainbow `*` separator

Setting bottom_text_wrap = true runs the bottom row as a seamless marquee — chasing itself across the canvas with a separator between repeats. Top row stays held at top_align; only the bottom row wraps. Engages even when the bottom text fits — predictable.

[[playlist.section.widget]]
type = "two_row"
top_text = "BREAKING"
top_color = [225, 48, 108]
bottom_text = "tap to subscribe"
bottom_text_wrap = true
bottom_text_separator = " * " # default: " • "
bottom_text_separator_color = "rainbow" # default: inherit bottom_color
FieldTypeDefaultMeaning
bottom_text_wrapboolfalseToggle seamless wrap on the bottom row. Requires bottom_text non-empty.
bottom_text_separatorstring" • " (when bottom_text_wrap = true)Glyph(s) between bottom-row repeats. "" falls back to a two-space gap.
bottom_text_separator_colorcolor specinherit bottom_colorColor for the bottom separator; whole-string provider (one hue per frame).
bottom_text_loopsint0Minimum full wrap cycles before the section can transition. 0 = no minimum. Requires bottom_text_wrap = true.

Notes:

  • bottom_text_wrap always wraps when set, even if the bottom text fits the canvas.
  • Top row never wraps (no top_text_wrap field exists).
  • The separator color inherits bottom_color (NOT font_color).
  • bottom_text_wrap is only allowed in mode = "slideshow". In ticker / one_at_a_time modes, validation refuses it.

Scroll-through mode (one pass, offscreen to offscreen)

Section titled “Scroll-through mode (one pass, offscreen to offscreen)”
held magenta `NOW PLAYING` on top, cyan song title flies through offscreen-to-offscreen on the bottom. `hold_time = 10.0` drives the pass count via max-of math — no `bottom_text_loops` set, so the engine fills the section duration with as many passes as fit
held magenta `NOW PLAYING` on top, cyan song title flies through offscreen-to-offscreen on the bottom. `hold_time = 10.0` drives the pass count via max-of math — no `bottom_text_loops` set, so the engine fills the section duration with as many passes as fit

Setting bottom_text_scroll = "scroll_through" forces the bottom row to scroll offscreen-to-offscreen: text starts fully off the right edge, scrolls left, ends fully off the left edge. The widget exits after the computed number of passes (controlled by bottom_text_loops and hold_time — see “Pass count” below; default is one pass). bottom_align is ignored.

Use it when you want the “marquee runs through and is done” feel — a now-playing line that flies across and the section moves on — instead of the held-when-fits / scroll-when-overflows default.

[[playlist.section.widget]]
type = "two_row"
top_text = "NOW PLAYING"
bottom_text = "Some Long Song Title - Album Name - Artist (2026)"
bottom_text_scroll = "scroll_through"
FieldTypeDefaultMeaning
bottom_text_scrollstring"marquee"Bottom-row scroll style. "marquee" (default) preserves existing behavior; "scroll_through" forces single-pass offscreen-to-offscreen on every visit.

Behavior table:

ModeWhen bottom fitsWhen bottom overflows
"marquee"held at bottom_aligncursor-driven single-pass (last char flush right)
"scroll_through"forced scroll-throughforced scroll-through

Notes:

  • Mutually exclusive with bottom_text_wrap = true — pick one. Scroll-through repeats N offscreen-to-offscreen passes; wrap is a seamless tiled marquee.

  • Requires non-empty bottom_text.

  • Only valid in mode = "slideshow". In ticker / one_at_a_time modes validation refuses it (rule 32), same as bottom_text_wrap.

  • Pass count is controlled by bottom_text_loops (default 0 = one pass per visit). Set bottom_text_loops = N to make the widget run N full passes before the section transitions:

    bottom_text_scroll = "scroll_through"
    bottom_text_loops = 3 # scroll on / scroll off, three times
  • hold_time acts as a floor. When both hold_time and bottom_text_loops are set, the engine uses the LONGER of the two (“max” semantics): n_passes = max(bottom_text_loops or 1, ceil((hold_time × 1000 / scroll_step_ms) / cycle_width)). To compute the crossover: hold_time × 1000 / scroll_step_ms (tick count from hold; hold_time is seconds, scroll_step_ms is milliseconds) vs bottom_text_loops × cycle_width_logical_px (tick count from loops, since the engine advances exactly one logical pixel per tick). Whichever is bigger wins. This matches how gif and image widgets handle the same field.

`bottom_text_loops = 2` paired with `bottom_text_wrap = true` — section's `hold_time = 1.0` would normally cut the marquee mid-pass, but the loops floor extends the visit to two complete wrap cycles before transition
`bottom_text_loops = 2` paired with `bottom_text_wrap = true` — section's `hold_time = 1.0` would normally cut the marquee mid-pass, but the loops floor extends the visit to two complete wrap cycles before transition

Number of full bottom-row cycles the widget plays before the section transitions. Default 0 means “one pass” — preserved as the baseline behavior. The field requires EITHER bottom_text_wrap = true (seamless tiled marquee, one cycle = bottom_text + bottom_text_separator) OR bottom_text_scroll = "scroll_through" (one cycle = one offscreen-to-offscreen pass).

  • Without bottom_text_wrap = true or bottom_text_scroll = "scroll_through", bottom_text_loops > 0 is a validation error (rule 28) — the non-wrap, non-scroll-through bottom row scrolls once over its overflow and has no cycle to count.
  • With bottom_text_wrap = true: minimum number of full wrap cycles before the section transitions. When both hold_time and bottom_text_loops are set, the engine uses the LONGER of the two (“max” semantics). To compute the crossover: hold_time × 1000 / scroll_step_ms (tick count from hold) vs bottom_text_loops × cycle_width_logical_px (tick count from loops; the engine advances exactly one logical pixel per tick). Whichever is bigger wins.
  • With bottom_text_scroll = "scroll_through": minimum number of offscreen-to-offscreen passes. When both hold_time and bottom_text_loops are set, the engine uses the LONGER of the two (“max” semantics). To compute the crossover: hold_time × 1000 / scroll_step_ms (tick count from hold) vs bottom_text_loops × cycle_width_logical_px (tick count from loops; the engine advances exactly one logical pixel per tick). Whichever is bigger wins. This is the same shape as the wrap-mode bullet above.
  • Mirrors text_loops on the gif and image widgets in their two-row mode. The validator catches the cross-widget typo (rule 29) and points you at bottom_text_loops.

Font line-height must fit the per-row band

Section titled “Font line-height must fit the per-row band”

Each row gets a slice of content_height (50/50 by default, or per top_row_height). If a font’s logical line-height exceeds its band, the validator rejects it. Pick a smaller font_size, raise content_height, adjust top_row_height, or use a BDF alias (5x8, 6x12). Reference pairing: BDF 5x8 fits content_height = 16; Inter at sizes 14+ usually needs content_height = 24 or larger.

At scale = 4 the bigsign’s logical canvas is only 64 px wide — most @handles clip mid-word. Use scale = 2 (128 logical px) on this widget; you can mix scales across sections.

On the bigsign: content_height ≤ 16 at scale = 4, content_height ≤ 32 at scale = 2 (content_height × scale ≤ panel_height). Above the ceiling, rows near the edges clip silently. For per-row breathing room use top_text_y_offset / bottom_text_y_offset, not over-specified content_height.