Files
Tushar Rohilla e5295f36aa Add Afterglow 1.0.6, a PS5 internet radio and music visualizer
222 stations in 17 genres, 56 visualizer scenes, Ogg and Opus playback, station logos, crossfade, My Music from USB, a command box and live BPM sync.
2026-10-16 00:59:10 -04:00

29 KiB

Data

Back to the component guide

The data page of the Component Library

Back to the component guide

Components that present structured information: a sortable table, labelled values, an activity feed, three charts, a calendar and the small status widgets a console screen keeps in a corner. Each has a public style (a ui::Theme plus its own knobs), set_bounds(rect), update(dt) and draw(canvas); the ones that take the focus also have handle(input, feedback), which returns a ui::Event and plays the component's cues. Values never jump: new data eases in with the theme's speed (style.omega()) and bounce (style.damping()), and with style.reduced_motion nothing travels.

Header Components
ui/components/table.hpp Table
ui/components/detail_list.hpp DetailList
ui/components/timeline.hpp Timeline
ui/components/chart.hpp BarChart, LineChart, DonutChart, Legend
ui/components/calendar.hpp Calendar, the date helpers
ui/components/status.hpp Countdown, StorageBar, StatusBar
ui/components/data_common.hpp What they share: numbers, series colours, readouts

The gallery page is src/concepts/components/data_page.cpp: all of them on one dashboard.

Shared: focus, edges and panels

  • set_active(bool) says whether the component has the screen's focus. Without it the highlight stays as a faint mark of where the focus will return. A filled highlight does not fade well, so an inactive component shows a tint instead (ui::draw_focus).

  • style.exits (ui::EdgeExits) names the edges that hand the focus back to the screen. At such an edge handle() returns Event::none and exit() names the direction; every other edge refuses softly. A dashboard chains components this way:

    const ui::Event event = table.handle(input, feedback);
    if (event == ui::Event::none && table.exit() == Direction::right)
        focus_the_chart();
    
  • style.panel draws the theme's panel behind the component. Without it the component sits on the page and uses the page's text colours (Painter::page_text()), which differ from the panel's in some themes. Countdown and StorageBar have no panel of their own; tell them where they sit with style.on_panel.

Shared: numbers, series colours, readouts

ui/components/data_common.hpp:

Helper Does
draw_number, number_width, fit_number Text in the monospaced face, so figures line up and do not jitter. A theme whose label face is a bitmap or handwriting keeps its own face
format_value(value, decimals) A value as short text: 950, 1.5k, 2M
series_color(theme, n, on_panel) The nth colour of a chart series, storage category or player slot: the theme's primary, accent, success, warning and danger colours and mixes of them, duplicates removed, each pulled toward the text colour until it shows on the ground
visible_on(theme, color, on_panel) Any colour made visible on the ground by the same rule
inks(paint, on_panel), ground_color, rule_color The text colours, the ground and a hairline colour for a panel or the page
draw_block, draw_marker A bar or segment in the theme's material; a dot in the theme's shape (square in square languages)
draw_readout The small bubble a focused bar or point speaks through
draw_focus A gliding highlight that turns faint while the component is not active
row_visibility How visible a row is at the edge of a scrolling view: hidden until half of it shows

Everywhere a style has a status or takes series colours it also accepts a color: give it an alpha above 0 to use any colour instead.

Table

A data grid for leaderboards and file lists. Columns are data (a title, a fixed width or a share of what is left, an alignment, a numeric flag, an optional slot), the header stays put, rows scroll under one gliding highlight, and sorting moves every row to its new place instead of redrawing the list. A leading rank column, a pinned row ("you") and selection marks are knobs.

ui::Table table;
table.style.theme = theme;
table.style.rank = true;
table.style.pinned_id = my_id;

std::vector<ui::TableColumn> columns(2);
columns[0].title = "Player";
columns[0].strong = true;                  // flex 1 by default
columns[1].title = "Score";
columns[1].width = 110;
columns[1].align = gfx::Align::right;
columns[1].numeric = true;
table.set_columns(columns);

ui::TableRow row;
row.id = 7;                                // stable: keys position, focus and mark
row.cells = {{"Mara Voss"}, {"48210", 48210.0}};
table.set_rows({row /* ... */});
table.set_bounds({96, 300, 620, 400});
table.sort_by(1, ui::SortOrder::descending, false);
...
if (input.is_pressed(Action::north))
    table.cycle_sort(feedback);            // the dedicated sort action
else if (table.handle(input, feedback) == ui::Event::activated)
    open(table.focus());
table.update(dt);
table.draw(canvas);

Navigation: up and down move through the rows. Up from the first row enters the header zone: there left and right move a column cursor over the sortable columns and confirm sorts by that column; down returns to the rows. cycle_sort() does the same from anywhere: in the header it turns the cursor's column over, among the rows it steps through every sortable column and order. A numeric column sorts by TableCell::number and starts with the largest; a text column sorts by its text, ignoring case, and starts with A. Sorting is stable.

set_rows() matches rows by id: a row that was there before keeps its place on screen, its mark and the focus, so new data slides into place. focus() is an index into rows(); focus_place() and place_of(row) give places in the shown order.

TableColumn:

Field Default Effect
title empty The header text
width 0 A fixed width; 0 shares the remaining width by flex
flex 1 This column's share of the width the fixed columns leave
align left Of the header and the cells
numeric false Cells in the number face; sorts by TableCell::number
strong false Cells in the label face and the full text colour (a name column)
sortable true The header cursor stops here and cycle_sort() includes it
cell none A slot that draws the cell instead of its text

TableStyle:

Knob Default Effect
row_height 52 Height of a row
header_height 46 Height of the header
padding 18 Inside the table, left and right
column_gap 18 Between columns
panel_padding 12 Between the panel and the rows
rank_width 52 Width of the rank column
mark_width 44 Width of the selection mark column
pinned_gap 8 Between the rows and the pinned row
text_size 22 Cells
header_size 17 Column titles
highlight tint The row highlight (ui::HighlightStyle); the header cursor is a tint, or the theme's ring when this is ring
lines zebra plain, zebra (every second row on a faint band), dividers (hairlines)
panel true A themed panel behind the table
header true The sticky header row; without it there is no header zone
rank false A leading column with each row's place in the shown order
scroll_thumb true Shown only when the rows overflow
edge_fade 0.5 A row cut by the view is hidden until this share of it shows; 0 never fades
multi_select false Confirm marks the row (Event::changed) instead of activating it
pinned_id -1 The id of a row repeated in a well under the others, with its current rank
unsorted_step false Turning a column over passes through "unsorted"
exits none Edges that hand the focus back
entrance_step 0.03 Seconds between rows arriving on enter(); 0 for none

Slots: TableColumn::cell(canvas, cell, row, focus).

Events: moved (a row, or the column cursor), changed (the order, or a mark), activated, cancelled, refused.

Cues: move (pitch falls down the list and rises along the header), change (higher for ascending, lower for descending; higher for marking, lower for unmarking), activate with a short rumble, cancel, refuse.

Notes: a disabled row (TableRow::disabled) takes the focus and refuses confirm. selected(row), set_selected(row, bool) and selected_count() read and write the marks. With reduced motion a sort places the rows at once.

DetailList

Key and value pairs: an "About" page, a spec sheet, the details of a save. Three layouts, section titles, long values that wrap or are cut, and rows marked focusable that can be confirmed (to copy a value, to open what it names).

ui::DetailList about;
about.style.layout = ui::DetailLayout::grid;
about.style.columns = 3;
about.set_items({
    {"Application", "", true},                // a section title
    {"Version", "1.4.2", false, true},        // focusable
    {"Renderer", "OpenGL 4.6 core, instanced signed-distance shapes"},
});
about.set_bounds({96, 300, 700, 320});
...
if (about.handle(input, feedback) == ui::Event::activated)
    copy(about.items()[about.focus()].value);

The focus visits only the focusable pairs, by position: up and down in every layout, left and right too in the grid. A list with nothing focusable scrolls with up and down instead. Focusable pairs carry a small "two squares" mark that flashes when confirmed.

DetailItem: label, value, header (a section title), focusable, numeric (the value in the number face, one line), color (the value's colour), tag.

Knob Default Effect
layout rows rows (label left, value right), stacked (label above value), grid (columns of stacked pairs)
columns 2 Columns of the grid
row_height 46 The least height of a pair in the rows layout
header_height 44 A section title
padding 16 Inside a pair, left and right
padding_y 9 Above and below a pair's text
gap 2 Between pairs
column_gap 20 Between grid columns
panel_padding 12 Between the panel and the pairs
label_share 0.4 Rows layout: the label's share of the width
stack_gap 4 Stacked and grid: between a label and its value
label_size 19 Labels
value_size 22 Values
header_size 17 Section titles
max_lines 2 Lines of a value: 1 cuts it with "...", more wraps it
highlight tint The focus highlight
panel true A themed panel behind the list
dividers true Hairlines between pairs (rows and stacked layouts)
scroll_thumb true Shown only when the pairs overflow
exits none Edges that hand the focus back

Slots: value(canvas, area, item, index, focus) draws a value instead of its text (one line high).

Events: moved, activated, cancelled, refused.

Cues: move (pitch falls down the list), activate, cancel, refuse.

Notes: wrapping needs the fonts, and only drawing has them: draw() notes how many lines each value takes and the next update() lays the list out with that. Call measure(fonts) after set_items() or a restyle when the very first frame must already be right (the gallery does, because its pictures are taken without a frame before them).

Timeline

A vertical activity feed: a rail with one node per entry, coloured by what the entry reports, with the time, a title, a few words and room for a card of yours. Group titles ("Today", "Earlier") break the rail. On enter() the rail draws itself from the top and each entry appears as the tip reaches its node.

ui::Timeline feed;
std::vector<ui::TimelineEntry> entries(3);
entries[0].title = "Today";
entries[0].group = true;
entries[1].time = "21:14";
entries[1].title = "Trophy earned";
entries[1].body = "First light: finish a chapter without a lantern.";
entries[1].status = ui::Status::success;
entries[2].time = "20:02";
entries[2].title = "Played Tidewater";
entries[2].card_height = 52;               // room for the `card` slot
feed.set_entries(entries);
feed.card = [&](ui::Canvas &canvas, const gfx::Rect &area, const ui::TimelineEntry &entry,
                int index, float focus) { draw_game_card(canvas, area, focus); };
feed.set_bounds({96, 300, 560, 420});
feed.enter();

TimelineEntry: time, title, body, status (a ui::Status: the node's colour), color (overrides it), card_height, group, tag.

Knob Default Effect
time_width 86 Width of the time column
node_size 16 Diameter of a node
rail_width 3 Thickness of the rail
rail_gap 18 Between the rail and the text
entry_gap 6 Between entries
padding 14 Inside an entry, left and right
padding_y 10 Above and below an entry's text
group_height 40 A group title
panel_padding 12 Between the panel and the entries
title_size 22 Titles
body_size 19 Bodies
time_size 18 Times
group_size 17 Group titles
body_lines 2 Lines a body wraps to; 0 leaves bodies out
highlight tint The focus highlight
time column Where the time goes: column (left of the rail), line (at the end of the title's line), none
node filled filled (a dot) or hollow (a ring)
panel true A themed panel behind the feed
scroll_thumb true Shown only when the entries overflow
draw_in 0.7 Seconds the rail takes to draw itself on enter(); 0: at once
exits none Edges that hand the focus back

Slots: icon(canvas, node, entry, index, focus) draws a node instead of the dot; card(canvas, area, entry, index, focus) draws the attached card of an entry that reserved card_height.

Events: moved, activated, cancelled, refused.

Cues: move (older entries sound lower), activate with a short rumble, cancel, refuse.

Notes: the focus skips group titles. Nodes are squares in square themes. Bodies are measured like DetailList values: measure(fonts) makes the first frame exact.

BarChart

Bars per category: vertical or across, grouped side by side or stacked, with an optional figure on each bar. Left and right (up and down when the bars run across) walk the bars; the focused one keeps its colour while the others step back, and a readout says what it is. set_series() with the same shape eases every bar to its new height; enter() grows them from the axis, category by category.

ui::BarChart chart;
chart.title = "Hours played";
chart.style.axis.unit = "h";
chart.set_categories({"Mon", "Tue", "Wed"});
chart.set_series({{"Solo", {2, 4, 3}}, {"Co-op", {1, 0, 2}}});
chart.set_bounds({96, 300, 420, 280});
chart.enter();
...
chart.set_series(next_week);               // the bars ease to it

Grouped, every bar is a stop (focus() is the category, focus_series() the series). Stacked, a category is one stop and focused_value() is its total.

Every chart shares ChartStyle:

Knob Default Effect
panel true A themed panel behind the chart
padding 16 Between the panel and the chart
title_size 20 The title
legend top (side for the donut) top (on the title's line; moves under the plot when it does not fit), bottom, side (donut only), none
legend_size 17 Legend text
readout true The bubble over the focused bar or point
readout_size 18 Its text
entrance 0.6 Seconds bars take to grow, a line to draw, a donut to sweep
exits none Edges that hand the focus back

and the bar and line charts an axis (AxisStyle):

Knob Default Effect
grid true Lines across the plot at each tick
labels true Tick values and category names
ticks 4 About this many intervals; the scale ends on "nice" values (1, 2, 2.5, 5 times a power of ten)
label_size 16 Axis text
from_zero true The scale includes zero
unit empty Appended to tick values and readouts

BarChartStyle adds:

Knob Default Effect
horizontal false Bars run left to right, categories top to bottom
layout grouped grouped or stacked
slot_fill 0.68 The share of a category's room its bars take
bar_gap 3 Between the bars of a group
radius 5 Corner of a bar, at most the theme's control radius
value_labels false Each bar's value at its end (a stack's total)
grow_step 0.045 Seconds between categories starting to grow
highlight tint The plate behind the focused bar or category

Slots: none. Events: moved, activated, cancelled, refused.

Cues: move (a taller bar sounds higher), activate, cancel, refuse.

Notes: bars are drawn in the theme's material (draw_block): shaded in the glossy theme, glowing in the lit one, outlined in the brutal, pixel and sketch themes. Category names that do not fit are shortened together, to three letters, two or one. nice_step(range, ticks) and nice_scale(low, high, ticks) are public if you draw an axis of your own.

LineChart

One or more series over the same steps: lines with round joints, an optional fill fading down from each line, optional dots, and a cursor that left and right move from step to step with a readout of the values under it. New data eases in; enter() draws the lines from left to right.

ui::LineChart chart;
chart.title = "Players online";
chart.set_labels({"00", "06", "12", "18"});
chart.set_series({{"Today", {310, 420, 980, 1640}}, {"Last week", {280, 510, 900, 1500}}});
chart.set_bounds({96, 300, 420, 280});

LineChartStyle (with ChartStyle and axis as above):

Knob Default Effect
shape theme straight, stepped, or theme: stepped in a pixel-art theme, straight elsewhere
line_width 3.5 Thickness of a line
area true A fill fading down from each line
area_alpha 0.34 Its opacity under the line (shared out when there are several lines)
area_step 4 Width of the strips the fill is built from
points false A dot on every value
point_size 5 Its radius; the cursor's dots are a little larger

Slots: none. Events: moved, activated, cancelled, refused.

Cues: move (panned along the chart; the pitch follows the first line up and down), activate, cancel, refuse.

Notes: the fill is built from thin vertical gradient rectangles, about width / area_step shapes per line. Step names are thinned out until they fit.

DonutChart

Shares of a whole as arcs with gaps, a figure in the middle and a legend. Left and right walk round the slices; the focused one moves out and the middle shows its value and name instead of the total.

ui::DonutChart chart;
chart.title = "Library";
chart.style.center_label = "Titles";
chart.set_slices({{"Action", 16}, {"Racing", 7}, {"Puzzle", 11}});
chart.set_bounds({96, 300, 420, 280});

DonutChartStyle (with ChartStyle as above):

Knob Default Effect
thickness 0.3 The ring's width as a share of its radius
gap 4 Pixels between slices
pop 8 How far the focused slice moves out
start_angle 0 Where the first slice starts, in radians, clockwise from 12 o'clock
center true A figure in the middle: the total, or the focused slice while the chart is active
center_label "Total" The caption under the total; left out when it does not fit the hole
center_size 0 Size of the figure; 0: sized from the ring
percent false The legend and the middle show shares instead of values
wrap true Past the last slice comes the first (an exit beats the wrap)
unit empty Appended to values

Slots: none. Events: moved, activated, cancelled, refused.

Cues: move (a larger share sounds lower), activate, cancel, refuse.

Notes: total() is the sum of the slices. The donut has no readout bubble: its middle is the readout.

Legend

What the colours of a chart mean: a row (or a column) of colour marks with labels and optional values. The charts and the StorageBar draw one themselves; use it alone to label anything else that is colour coded.

ui::Legend legend;
legend.set_items({{"Solo", ui::series_color(theme, 0)}, {"Co-op", ui::series_color(theme, 1)}});
legend.set_bounds({96, 600, 400, 24});
legend.set_highlight(1);                   // dims the others; -1 shows all alike
Knob Default Effect
text_size 17 Labels and values
swatch 12 Size of the colour mark
gap 20 Between items in a row
row_gap 8 Between rows
vertical false One item per row, values at the right edge
on_panel true Which text colours to use
align left Of a horizontal legend inside its bounds
dim 0.45 Opacity of the items that are not highlighted

Slots: none. Events: none. Cues: none.

Notes: a horizontal legend wraps; width(paint) and height(paint, width) say how much room it takes, and draw_at(canvas, rect) draws it somewhere other than its bounds.

Calendar

A month: days moved through in two dimensions under one gliding focus, today marked, one day or a range selected, dots under days that have something on, days that cannot be chosen. Left and right move by a day, up and down by a week; a move that leaves the month slides to the next one.

ui::Calendar calendar;
calendar.set_today({2026, 10, 2});
calendar.style.select = ui::CalendarSelect::range;
calendar.set_marks({{{2026, 10, 9}}, {{2026, 10, 17}, ui::Status::danger}});
calendar.disabled = [](const ui::CalendarDate &date) { return date.day == 13; };
calendar.set_bounds({96, 300, 430, 330});
...
if (calendar.handle(input, feedback) == ui::Event::changed && calendar.has_range())
    book(calendar.range_start(), calendar.range_end());

Selecting: with single, confirm selects the focused day. With range, the first confirm sets the start, the second the end (picked backwards, the two swap), and until then the range is previewed up to the focus. With none, confirm returns Event::activated.

Knob Default Effect
week_start monday The first column: monday or sunday
select single none, single, range
exits none Edges of the grid that hand the focus back instead of changing month
padding 14 Between the panel and the grid
title_height 40 The month and year
weekday_height 26 The row of weekday names
cell_gap 4 Between days
slide 36 How far a month travels when it changes
title_size 22 The month and year
weekday_size 15 Weekday names
day_size 20 Day numbers
highlight ring The focus: the theme's ring, drawn on the day's own edge
panel true A themed panel behind the calendar
outside_days true Show the neighbouring months' days in the spare cells
mark_today true A ring round today
max_marks 3 Dots shown under one day
mark_size 5 Diameter of a dot
months English The twelve month names
weekdays "Su" .. "Sa" The seven weekday names, Sunday first whatever week_start is

Slots: disabled(date) returns true for a day that cannot be chosen (it stays focusable, dimmed and struck through, and refuses confirm); day(canvas, cell, date, focus, selected) draws a day instead of its number and dots.

Events: moved, changed (the selection), activated (select is none), cancelled, refused.

Cues: move (panned by the day's column, lower later in the month), page when the month changes, change when a day or a range end is chosen (the end of a range sounds higher than its start), activate, cancel, refuse.

Notes: the grid always has six rows, so its height never jumps. An exit applies at the edge of the month's own days: the first and last column, the first week, and the last occurrence of each weekday. set_limits(first, last) keeps the focus inside a span of dates. The date helpers are exact for every Gregorian date: is_leap_year, days_in_month, weekday_of (0 is Sunday), add_days, days_between.

Countdown

Time remaining, as m:ss or as a ring that drains. It counts down by itself from start(seconds); the colour turns to the theme's warning and then danger colour under two thresholds, the figure pulses once a second near the end, and finished() says when it is over.

ui::Countdown timer;
timer.label = "Next match";
timer.set_bounds({96, 96, 240, 56});
timer.start(90.0f);
...
timer.update(dt, feedback);                // ticks the last seconds; update(dt) is silent
if (timer.finished())
    begin_match();
Knob Default Effect
shape text text, or ring: in wide bounds the ring leads and the figure follows it; in square bounds the figure sits inside the ring
text_size 36 The figure
label_size 17 The caption
thickness 7 The ring's stroke
align left Of the group inside its bounds
status accent The ring's colour while calm
color clear Overrides status when its alpha is above 0
tint_text false The calm figure takes the status colour too
on_panel false Which text colours to use
warning_at 30 Seconds left from which it shows the warning colour
danger_at 10 ... and from which the danger colour
pulse_at 5 The figure pulses (and ticks) once a second from here
hours false Always show hours (h:mm:ss); otherwise only from one hour up

Slots: none. Events: none (finished(), zone(), remaining(), text()).

Cues (only through update(dt, feedback)): step on each of the last seconds, rising as the end nears; notify at zero.

Notes: set_remaining() corrects the time without restarting; pause() and resume() hold it. The figure is rounded up, so "0:01" shows until the time is really over.

StorageBar

A drive's capacity split by what fills it: a bar of segments, the free space left as empty track, and a legend. Segments ease when the sizes change. Left and right move a focus that makes one category's segment taller and lights its legend entry.

ui::StorageBar storage;
storage.title = "Console storage";
storage.set_capacity(825.0f);
storage.set_categories({{"Games", 412}, {"Media", 96}, {"Saves", 12}});
storage.set_bounds({96, 300, 760, 84});
Knob Default Effect
height 16 The bar
segment_gap 3 Between categories
focus_grow 5 The focused segment is this much taller, above and below
text_size 20 Title and figures
legend_size 17 Legend text
spacing 10 Between the title line, the bar and the legend
header true The title and "used / capacity" on a line above the bar
legend true The categories under the bar
legend_values true ... with their sizes (dropped when names and sizes do not fit the height)
show_free true "Free" as the legend's last entry
on_panel false Which text colours to use
unit " GB" Appended to sizes
free_label "Free" The name of the free space
decimals -1 Of sizes; negative: none for whole numbers, one otherwise
exits none Edges that hand the focus back

Slots: none. Events: moved, activated, cancelled, refused.

Cues: move (panned along the bar, rising to the right), activate, cancel, refuse.

Notes: it starts inactive (a status widget usually only shows); call set_active(true) when it takes the focus. used() and free() report the sums.

StatusBar

The strip along the top of a screen: a title at one end and, at the other, the player slots, the connection, the controller's battery and the clock. It takes no input. The screen supplies the clock text: the kit does not know the time zone or the format the player chose.

ui::StatusBar bar;
bar.title = "Lantern Pass";
bar.clock = "21:47";
bar.set_battery(0.64f, false);             // level 0..1, charging
bar.set_signal(3);                         // lit bars; 0 reads as "no connection"
bar.set_player(0, true);                   // slot 1 connected, in the theme's first colour
bar.set_bounds({96, 60, 1728, 52});
Knob Default Effect
show_players true The four player slots
show_signal true The connection bars
show_battery true The battery glyph
show_clock true The clock text
battery_percent false The level as a number beside the glyph
padding 20 Inside the strip, left and right
gap 24 Between the groups
text_size 22 The title and the clock
glyph_size 22 Height of the battery, bars and player marks
signal_bars 4 Number of connection bars
panel true A themed strip behind it
low_battery 0.2 From here down the battery shows the danger colour

Slots: none. Events: none. Cues: none.

Notes: the battery level and the signal ease; a charging battery fills in the success colour and gains a bolt; a slot that connects pops in with the theme's bounce. Every glyph is drawn from shapes and is square in square themes.