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— aVec<Span>that represents one rendered line.Text— aVec<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 LineFeedQueries 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-awaresplit_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.
Hyperlinks — OSC 8
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;
}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
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.