Skip to Content
ftui-textSpans & segments

Spans and Segments

At the end of the text pipeline, you need to hand the renderer something it can draw. That “something” is a stack of four types that trade off ergonomics and precision:

  • Segment — the low-level atomic unit: text, optional style, optional hyperlink, optional control codes.
  • Span — ergonomic builder over a single styled run, no control codes.
  • Line — a Vec<Span> that represents one rendered line.
  • Text — a Vec<Line> for multi-line styled content.

Widgets build Text values; the renderer consumes Segments.

Segment — the atomic unit

pub struct Segment<'a> { pub text: Cow<'a, str>, pub style: Option<Style>, pub link: Option<Cow<'a, str>>, // OSC 8 hyperlink URL pub control: Option<SmallVec<[ControlCode; 2]>>, } pub enum ControlCode { CarriageReturn, LineFeed, Bell, Backspace, Tab, Home, /* … */ }

Constructors:

Segment::text("plain text") Segment::styled("hello", Style::new().bold()) Segment::control(ControlCode::LineFeed) Segment::newline() // shorthand for LineFeed

Queries that matter for layout:

segment.as_str() -> &str segment.is_empty() -> bool segment.has_text() -> bool // text present, not control segment.is_control() -> bool segment.is_newline() -> bool segment.cell_length() -> usize // uses the width cache segment.cell_length_with(|g| …) // custom width fn segment.split_at_cell(pos) -> (Segment, Segment) // grapheme-aware

split_at_cell splits on grapheme boundaries, never on byte offsets. A segment containing "café" split at cell 3 produces "caf" + "é", not a half-formed UTF-8 sequence.

A segment with link: Some(url) is rendered inside an OSC 8 escape sequence on terminals that support it. See the hyperlinks widget layer for the link-registry contract the runtime wires up.

Span — the ergonomic builder

Span is a Segment without the control-code / hyperlink complexity. It’s what you type in widget code 99 % of the time.

pub struct Span<'a> { pub content: Cow<'a, str>, pub style: Option<Style>, pub link: Option<Cow<'a, str>>, } impl<'a> Span<'a> { pub fn raw(content: impl Into<Cow<'a, str>>) -> Self; pub fn styled(content: impl Into<Cow<'a, str>>, style: Style) -> Self; }
span_hello.rs
use ftui_text::Span; use ftui_style::{Style, Color}; use ftui_render::cell::PackedRgba; let status = Span::styled( "OK", Style::new().bold().fg(PackedRgba::rgb(0, 200, 0)), ); let prefix = Span::raw("Status: ");

Line — spans in order

pub struct Line<'a> { pub spans: Vec<Span<'a>>, } impl<'a> Line<'a> { pub fn from_spans(spans: impl IntoIterator<Item = Span<'a>>) -> Self; pub fn raw(content: impl Into<Cow<'a, str>>) -> Self; pub fn styled(content: impl Into<Cow<'a, str>>, style: Style) -> Self; }

A Line is one visual row after layout. Widget authors build lines, pushing spans left-to-right; the width is the sum of each span’s cell_length().

Text — lines in order

pub struct Text<'a> { pub lines: Vec<Line<'a>>, } impl<'a> Text<'a> { pub fn raw(content: impl Into<Cow<'a, str>>) -> Self; pub fn styled(content: impl Into<Cow<'a, str>>, style: Style) -> Self; pub fn from_spans(spans: impl IntoIterator<Item = Span<'a>>) -> Self; pub fn height(&self) -> usize; pub fn height_as_u16(&self) -> u16; }

from_spans puts every span on one line; split source text on \n and build one Line per row when you want multi-line output.

The three layers, visualized

Widget author sees: Renderer sees: ───────────────────── ────────────── Text Vec<Segment> └── Vec<Line> ┌─────────┐┌────────┐┌───────┐ └── Vec<Span> ──▶ │ text ││control ││text │ └── content, style│ + style ││ codes ││+link │ └─────────┘└────────┘└───────┘

The translation Text → Vec<Segment> is mechanical: each span becomes one segment; line breaks become ControlCode::LineFeed segments.

Worked example — a multi-line styled status

multi_line.rs
use ftui_text::{Line, Span, Text}; use ftui_style::{Style}; use ftui_render::cell::PackedRgba; let green = Style::new().fg(PackedRgba::rgb(0, 200, 0)).bold(); let red = Style::new().fg(PackedRgba::rgb(200, 0, 0)).bold(); let text = Text { lines: vec![ Line::from_spans([ Span::raw("Build: "), Span::styled("passing", green), ]), Line::from_spans([ Span::raw("Tests: "), Span::styled("7 failed", red), ]), ], }; assert_eq!(text.height(), 2);

Zero-copy in practice

Cow<'a, str> means static &'static str literals never allocate. Dynamic content allocates once, when you compose the span. That single allocation is reused on every re-render — widgets typically cache their own Text between frames and only rebuild on model change.

Pitfalls

cell_length is not len(). A span containing "café" has .len() >= 5 (5 or 6 bytes depending on NFC/NFD) but cell_length of 4. Layout math uses cell_length.

Control codes in Segment are for the low-level pipeline. Widget authors should not construct segments with raw control codes; use Line::from_spans(...) and push spans. The renderer handles the newline conversion.

split_at_cell returns grapheme-correct pieces, not byte-correct pieces. If you save the byte offsets of the split and try to use them on the original segment later, you’re asking for mojibake. Use the returned segments directly.

Where to go next