Skip to content

Waveform Widget

The Waveform widget is a real-time strip-chart / scope plot — one or more scrolling or sweeping traces over a live or historical value buffer, the bedside-monitor and signal-generator display. Init installs the whole look (theme-tracked background/grid/axis, a settable size) with the SCROLL display mode; add_channel() attaches a caller-owned sample-value ring (zero heap — the widget never copies or allocates), while add_channel_live() attaches a SMALL ring meant to be drained every frame rather than sized to the full plot width, trading history for RAM. push()/the emit helpers write new samples; set_range()/set_grid() pick the value window and division count, set_channel_range()/ set_channel_style() override those per channel for multi-parameter plots, and set_mode() switches between SCROLL (the whole trace slides left) and SWEEP (a monitor-style write cursor with an erase gap, cheaper to draw, no scrollable history). set_freeze() holds the display while the buffer keeps recording; while frozen, ugui_waveform_on_event() accepts KEY_UP/DOWN to zoom the Y-range and KEY_LEFT/RIGHT to pan back through recorded history (SCROLL mode only). add_label() draws a text overlay pinned inside the plot (a lead name, a LIVE/FROZEN readout, a time marker) without needing a separate widget.

Every example below is complete — the file-scope storage, the callbacks and the build calls — beside the exact frame it renders on a 480×320 panel. Each compiles against nothing but the released library: drop it into the Application Skeleton and it runs as-is. The icons come from inc/icons/, the lexend fonts from inc/fonts/ — both ship with the library.

Examples

Default: Low-RAM Live ECG (Sweep)

The simplest live monitor: init() + add_channel_live() with a SMALL input ring, then push. The default SWEEP mode draws a bedside-monitor cursor across the full plot width; the ring only has to hold the samples that arrive between two draws (~128 bytes here) instead of a full-width column buffer (~640 bytes) — what you push is what you see, at the cost of not surviving a full repaint.

Default: Low-RAM Live ECG (Sweep)

/* --- widget IDs: only need to be unique within the screen (10+ leaves
 *     room for the IDs your app already uses, e.g. the root's) --- */
enum {
    WDGT_ID_DEMO = 10
};

/* --- file-scope storage --- */
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_screen_t  s_screen;
static ugui_widget_t   s_root;
#define ECG_RING  64u
static ugui_widget_t       s_plot;
static ugui_waveform_ext_t s_plot_ext;
static int16_t             s_buf_ecg[ECG_RING];
#define BTN_H   34
#define BTN_GAP 8
#define BTN_Y   (UGUI_SCREEN_H - BTN_H - BTN_GAP)
#define PLOT_X  10
#define PLOT_Y  38
#define PLOT_W  (UGUI_SCREEN_W - 2 * 10)
#define PLOT_H  (BTN_Y - PLOT_Y - BTN_GAP)
#endif

/* --- build it (in your screen's init) --- */
ugui_widget_waveform_init(&s_root, &s_plot, &s_plot_ext);
ugui_widget_set_area(&s_plot, PLOT_X, PLOT_Y, PLOT_W, PLOT_H);
ugui_widget_set_id  (&s_plot, WDGT_ID_DEMO);
(void)ugui_widget_waveform_add_channel_live(&s_plot, ugui_color_hex(0x00E07Au),
                                            s_buf_ecg, ECG_RING);
ugui_screen_add_widget(&s_screen, &s_plot);

Y-Range, Grid & Built-In Zoom

set_range() picks the value window mapped top..bottom and set_grid() sets the division count (10×6 here). The Zoom +/− buttons drive the widget's OWN zoom handler by feeding it KEY_UP/KEY_DOWN through ugui_waveform_on_event() — exactly what a keypad would do — so the visible Y-range shrinks or grows around the trace without touching the data.

Y-Range, Grid & Built-In Zoom

/* --- widget IDs: only need to be unique within the screen (10+ leaves
 *     room for the IDs your app already uses, e.g. the root's) --- */
enum {
    WDGT_ID_DEMO = 10
};

/* --- file-scope storage --- */
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_screen_t  s_screen;
static ugui_widget_t   s_root;
static ugui_widget_t       s_plot;
static ugui_waveform_ext_t s_plot_ext;
static int16_t             s_buf[UGUI_SCREEN_W];
#define BTN_H   34
#define BTN_GAP 8
#define BTN_Y   (UGUI_SCREEN_H - BTN_H - BTN_GAP)
#define PLOT_X  10
#define PLOT_Y  38
#define PLOT_W  (UGUI_SCREEN_W - 2 * 10)
#define PLOT_H  (BTN_Y - PLOT_Y - BTN_GAP)
#endif

/* --- build it (in your screen's init) --- */
ugui_widget_waveform_init(&s_root, &s_plot, &s_plot_ext);
ugui_widget_set_area(&s_plot, PLOT_X, PLOT_Y, PLOT_W, PLOT_H);
ugui_widget_set_id  (&s_plot, WDGT_ID_DEMO);
ugui_widget_waveform_set_range(&s_plot, -120, 120);   /* value window      */
ugui_widget_waveform_set_grid (&s_plot, 10u, 6u);     /* 10 x 6 divisions  */
(void)ugui_widget_waveform_add_channel(&s_plot, ugui_color_hex(0x00C8FFu),
                                       s_buf, (uint16_t)UGUI_SCREEN_W);
ugui_widget_set_event_cb(&s_plot, NULL,
                         UGUI_EMASK_KEY_CONFIRM | UGUI_EMASK_KEY_UP | UGUI_EMASK_KEY_DOWN);
ugui_screen_add_widget(&s_screen, &s_plot);

Three Channels, One Plot

Three live traces (ECG, SpO2, respiration) share one plot, phase-locked to a single simulated acquisition clock. SpO2 gets its own set_channel_range() (0..120, not ±100) and a thicker anti-aliased set_channel_style(); the button shows/hides the respiration trace via set_channel_active() without touching the other two.

Three Channels, One Plot

/* --- widget IDs: only need to be unique within the screen (10+ leaves
 *     room for the IDs your app already uses, e.g. the root's) --- */
enum {
    WDGT_ID_DEMO = 10
};

/* --- file-scope storage --- */
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_screen_t  s_screen;
static ugui_widget_t   s_root;
static ugui_widget_t       s_plot;
static ugui_waveform_ext_t s_plot_ext;
static int16_t             s_buf_ecg[UGUI_SCREEN_W];
static int16_t             s_buf_spo2[UGUI_SCREEN_W];
static int16_t             s_buf_resp[UGUI_SCREEN_W];
#define CH_SPO2  1u
#define BTN_H   32
#define BTN_GAP 8
#define BTN_Y   (UGUI_SCREEN_H - BTN_H - BTN_GAP)
#define PLOT_X  10
#define PLOT_Y  38
#define PLOT_W  (UGUI_SCREEN_W - 2 * 10)
#define PLOT_H  (BTN_Y - PLOT_Y - BTN_GAP)
#endif

/* --- build it (in your screen's init) --- */
ugui_widget_waveform_init(&s_root, &s_plot, &s_plot_ext);
ugui_widget_set_area(&s_plot, PLOT_X, PLOT_Y, PLOT_W, PLOT_H);
ugui_widget_set_id  (&s_plot, WDGT_ID_DEMO);
ugui_widget_waveform_set_range (&s_plot, -100, 100);
ugui_widget_waveform_set_grid  (&s_plot, 8u, 4u);
ugui_widget_waveform_set_colors(&s_plot, ugui_color_hex(0x0A0A14u),
                                ugui_color_hex(0x1E2630u), ugui_color_hex(0x3C4450u));

(void)ugui_widget_waveform_add_channel(&s_plot, ugui_color_hex(0x00E07Au),
                                       s_buf_ecg, (uint16_t)UGUI_SCREEN_W);   /* ch0 ECG  */
(void)ugui_widget_waveform_add_channel(&s_plot, ugui_color_hex(0x00C8FFu),
                                       s_buf_spo2, (uint16_t)UGUI_SCREEN_W);  /* ch1 SpO2 */
(void)ugui_widget_waveform_add_channel(&s_plot, ugui_color_hex(0xFFA640u),
                                       s_buf_resp, (uint16_t)UGUI_SCREEN_W);  /* ch2 resp */

/* SpO2 is a 0..120 positive signal — give it its own scale. */
ugui_widget_waveform_set_channel_range(&s_plot, CH_SPO2, 0, 120);
/* Make the pleth a thicker anti-aliased trace so it reads as the "premium"
 * channel; ECG + resp stay on the fast 1 px Bresenham path. */
ugui_widget_waveform_set_channel_style(&s_plot, CH_SPO2, ugui_color_hex(0x00C8FFu), 2u);

ugui_widget_set_event_cb(&s_plot, NULL,
                         UGUI_EMASK_KEY_CONFIRM | UGUI_EMASK_KEY_UP | UGUI_EMASK_KEY_DOWN);
ugui_screen_add_widget(&s_screen, &s_plot);

SCROLL vs SWEEP Display Modes

The same live ECG feed as the default screen, but the button flips set_mode() between SCROLL (strip-chart: the whole trace slides left, newest at the right edge) and SWEEP (bedside-monitor style: the trace stays put while a write cursor advances with a short erase gap ahead of it) — SWEEP is cheaper to draw since no history scrolls.

SCROLL vs SWEEP Display Modes

/* --- widget IDs: only need to be unique within the screen (10+ leaves
 *     room for the IDs your app already uses, e.g. the root's) --- */
enum {
    WDGT_ID_DEMO = 10
};

/* --- file-scope storage --- */
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_screen_t  s_screen;
static ugui_widget_t   s_root;
static ugui_widget_t       s_plot;
static ugui_waveform_ext_t s_plot_ext;
static int16_t             s_buf[UGUI_SCREEN_W];
#define BTN_H   34
#define BTN_GAP 8
#define BTN_Y   (UGUI_SCREEN_H - BTN_H - BTN_GAP)
#define PLOT_X  10
#define PLOT_Y  38
#define PLOT_W  (UGUI_SCREEN_W - 2 * 10)
#define PLOT_H  (BTN_Y - PLOT_Y - BTN_GAP)
#endif

/* --- build it (in your screen's init) --- */
ugui_widget_waveform_init(&s_root, &s_plot, &s_plot_ext);
ugui_widget_set_area(&s_plot, PLOT_X, PLOT_Y, PLOT_W, PLOT_H);
ugui_widget_set_id  (&s_plot, WDGT_ID_DEMO);
(void)ugui_widget_waveform_add_channel(&s_plot, ugui_color_hex(0x00E07Au),
                                       s_buf, (uint16_t)UGUI_SCREEN_W);
ugui_widget_set_event_cb(&s_plot, NULL,
                         UGUI_EMASK_KEY_CONFIRM | UGUI_EMASK_KEY_UP | UGUI_EMASK_KEY_DOWN);
ugui_screen_add_widget(&s_screen, &s_plot);

Freeze, Pan & In-Plot Labels

add_label() draws two labels over the trace: a static lead name ("II") and a live LIVE/FROZEN readout the screen rewrites in place. Freeze holds the display while the buffer keeps recording; while frozen, the pan buttons feed the widget KEY_LEFT/KEY_RIGHT to scroll back and forth through the captured history (a SCROLL-mode feature — the buffer here is sized to several screens of scroll-back).

Freeze, Pan & In-Plot Labels

/* --- widget IDs: only need to be unique within the screen (10+ leaves
 *     room for the IDs your app already uses, e.g. the root's) --- */
enum {
    WDGT_ID_DEMO = 10
};

/* --- fonts: each is DEFINED in inc/fonts/<name>.h — #include that
 *     header in exactly one .c of your app; elsewhere just: --- */
extern const ugui_font_t lexend_14pt_2bpp;

/* --- file-scope storage --- */
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_screen_t  s_screen;
static ugui_widget_t   s_root;
static ugui_widget_t       s_plot;
static ugui_waveform_ext_t s_plot_ext;
#define PAN_SCREENS 3
static int16_t             s_buf[PAN_SCREENS * UGUI_SCREEN_W];
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_text_cfg_t     s_lbl_lead;
static ugui_text_cfg_t     s_lbl_state;
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
#define BTN_H   34
#define BTN_GAP 8
#define BTN_Y   (UGUI_SCREEN_H - BTN_H - BTN_GAP)
#define PLOT_X  10
#define PLOT_Y  38
#define PLOT_W  (UGUI_SCREEN_W - 2 * 10)
#define PLOT_H  (BTN_Y - PLOT_Y - BTN_GAP)
#endif

/* --- local helpers & event callbacks --- */
static void add_plot_label(ugui_text_cfg_t* cfg, const char* str, ugui_color_t color,
                           int16_t x, int16_t y, int16_t w, int16_t h, ugui_align_t align)
{
    ugui_text_cfg_init(cfg, str, &lexend_14pt_2bpp, color, align,
                       (ugui_point_t){ 0, 0 }, 0u);
    cfg->area = (ugui_rect_t){ x, y, w, h };
    (void)ugui_widget_waveform_add_label(&s_plot, cfg);
}

/* --- build it (in your screen's init) --- */
ugui_widget_waveform_init(&s_root, &s_plot, &s_plot_ext);
ugui_widget_set_area(&s_plot, PLOT_X, PLOT_Y, PLOT_W, PLOT_H);
ugui_widget_set_id  (&s_plot, WDGT_ID_DEMO);
(void)ugui_widget_waveform_add_channel(&s_plot, ugui_color_hex(0x00E07Au),
                                       s_buf, (uint16_t)(PAN_SCREENS * UGUI_SCREEN_W));
/* Pan/history is a SCROLL feature (a sweep cursor keeps no scrollable
 * history); the widget defaults to SWEEP, so switch to SCROLL here. */
ugui_widget_waveform_set_mode(&s_plot, UGUI_WAVEFORM_MODE_SCROLL, 0u);
#ifdef UGUI_ENABLE_FONT
add_plot_label(&s_lbl_lead,  "II",   ugui_color_hex(0x00E07Au),
               6, 4, 40, 16, (ugui_align_t)(UGUI_ALIGN_LEFT | UGUI_ALIGN_V_CENTER));
add_plot_label(&s_lbl_state, "LIVE", ugui_color_hex(0x00E07Au),
               PLOT_W - 76, 4, 70, 16, (ugui_align_t)(UGUI_ALIGN_RIGHT | UGUI_ALIGN_V_CENTER));
#endif
ugui_widget_set_event_cb(&s_plot, NULL,
                         UGUI_EMASK_KEY_CONFIRM | UGUI_EMASK_KEY_UP | UGUI_EMASK_KEY_DOWN
                         | UGUI_EMASK_KEY_LEFT | UGUI_EMASK_KEY_RIGHT | UGUI_EMASK_SLIDE);
ugui_screen_add_widget(&s_screen, &s_plot);

Four-Lane Patient Monitor

A real bedside monitor gives each parameter its own lane: FOUR independent waveform widgets (not channels in one plot) stacked and sized proportionally to the panel height, all fed from one simulated acquisition clock so the two ECG leads stay beat-aligned. Each lane runs in SWEEP mode with its own colour, scale and in-widget lead label; Freeze holds every lane at once.

Four-Lane Patient Monitor

/* --- widget IDs: only need to be unique within the screen (10+ leaves
 *     room for the IDs your app already uses, e.g. the root's) --- */
enum {
    WDGT_ID_DEMO = 10, WDGT_ID_SLOT0, WDGT_ID_SLOT1,
    WDGT_ID_SLOT2
};

/* --- fonts: each is DEFINED in inc/fonts/<name>.h — #include that
 *     header in exactly one .c of your app; elsewhere just: --- */
extern const ugui_font_t lexend_14pt_2bpp;

/* --- file-scope storage --- */
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_screen_t  s_screen;
static ugui_widget_t   s_root;
static ugui_widget_t       s_ecg1;  static ugui_waveform_ext_t s_ecg1_ext;  static int16_t s_buf_ecg1[UGUI_SCREEN_W];
static ugui_widget_t       s_ecg2;  static ugui_waveform_ext_t s_ecg2_ext;  static int16_t s_buf_ecg2[UGUI_SCREEN_W];
static ugui_widget_t       s_resp;  static ugui_waveform_ext_t s_resp_ext;  static int16_t s_buf_resp[UGUI_SCREEN_W];
static ugui_widget_t       s_spo2;  static ugui_waveform_ext_t s_spo2_ext;  static int16_t s_buf_spo2[UGUI_SCREEN_W];
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_text_cfg_t s_lbl_ecg1;
static ugui_text_cfg_t s_lbl_ecg2;
static ugui_text_cfg_t s_lbl_resp;
static ugui_text_cfg_t s_lbl_spo2;
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
#define LANE_MARGIN 6
#define LANE_X      LANE_MARGIN
#define LANE_W      (UGUI_SCREEN_W - 2 * LANE_MARGIN)
#define ROW_H       30
#define ROW_GAP     6
#define ROW_Y       (UGUI_SCREEN_H - ROW_H - ROW_GAP)
#define LANE_GAP    4
#endif

/* --- local helpers & event callbacks --- */
static void make_lane(ugui_widget_t* w, ugui_waveform_ext_t* ext, int16_t* buf,
                      ugui_text_cfg_t* lbl, uint8_t id, int16_t y, int16_t h,
                      ugui_color_t color, int16_t ymin, int16_t ymax, const char* name)
{
    ugui_widget_waveform_init(&s_root, w, ext);
    ugui_widget_set_area(w, LANE_X, y, LANE_W, h);
    ugui_widget_set_id  (w, id);
    ugui_widget_waveform_set_range (w, ymin, ymax);
    ugui_widget_waveform_set_grid  (w, 0u, 0u);     /* clean lane: trace only */
    ugui_widget_waveform_set_colors(w, ugui_color_hex(0x05060Cu),
                                    ugui_color_hex(0x141A22u), ugui_color_hex(0x222C36u));
    (void)ugui_widget_waveform_add_channel(w, color, buf, (uint16_t)UGUI_SCREEN_W);
    ugui_widget_waveform_set_mode(w, UGUI_WAVEFORM_MODE_SWEEP, 6u);
#ifdef UGUI_ENABLE_FONT
    ugui_text_cfg_init(lbl, name, &lexend_14pt_2bpp, color,
                       (ugui_align_t)(UGUI_ALIGN_LEFT | UGUI_ALIGN_V_CENTER),
                       (ugui_point_t){ 0, 0 }, 0u);
    lbl->area = (ugui_rect_t){ 4, 1, 64, 14 };
    (void)ugui_widget_waveform_add_label(w, lbl);
#else
    (void)lbl; (void)name;
#endif
    ugui_widget_set_event_cb(w, NULL, UGUI_EMASK_KEY_CONFIRM);
    ugui_screen_add_widget(&s_screen, w);
}

/* --- build it (in your screen's init) --- */
{
    const int16_t top   = (int16_t)38;
    const int16_t bot   = (int16_t)(ROW_Y - LANE_GAP);     /* lanes bottom */
    const int16_t avail = (int16_t)(bot - top - 3 * LANE_GAP);
    const int16_t unit  = (int16_t)(avail / 10);
    const int16_t ecg_h = (int16_t)(unit * 3);
    const int16_t sml_h = (int16_t)(unit * 2);
    int16_t y = top;

    make_lane(&s_ecg1, &s_ecg1_ext, s_buf_ecg1, &s_lbl_ecg1, WDGT_ID_DEMO,
              y, ecg_h, ugui_color_hex(0x00E07Au), -100, 100, "ECG II");
    y = (int16_t)(y + ecg_h + LANE_GAP);
    make_lane(&s_ecg2, &s_ecg2_ext, s_buf_ecg2, &s_lbl_ecg2, WDGT_ID_SLOT0,
              y, ecg_h, ugui_color_hex(0x40E0C0u), -100, 100, "ECG V");
    y = (int16_t)(y + ecg_h + LANE_GAP);
    make_lane(&s_resp, &s_resp_ext, s_buf_resp, &s_lbl_resp, WDGT_ID_SLOT1,
              y, sml_h, ugui_color_hex(0xFFD200u), -100, 100, "RESP");
    y = (int16_t)(y + sml_h + LANE_GAP);
    make_lane(&s_spo2, &s_spo2_ext, s_buf_spo2, &s_lbl_spo2, WDGT_ID_SLOT2,
              y, (int16_t)(bot - y), ugui_color_hex(0x00C8FFu), 0, 120, "SpO2");
}

Two slow-trending parameters share one plot on independent scales: set_channel_range() maps blood pressure (80..160 mmHg) and SpO2 (70..100%) each to the full plot height — one sample is pushed per second, so this shot fast-forwards a few minutes to show the trend actually forming. The Y-axis scale numbers are plain box widgets in side strips OUTSIDE the waveform widget; the time markers are in-widget labels via add_label(). A Freeze button (not pictured) holds the trend while the buffer keeps recording.

Medical History Trends (Dual Y-Axis)

/* --- widget IDs: only need to be unique within the screen (10+ leaves
 *     room for the IDs your app already uses, e.g. the root's) --- */
enum {
    WDGT_ID_DEMO = 10
};

/* --- fonts: each is DEFINED in inc/fonts/<name>.h — #include that
 *     header in exactly one .c of your app; elsewhere just: --- */
extern const ugui_font_t lexend_14pt_2bpp;

/* --- file-scope storage --- */
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_screen_t  s_screen;
static ugui_widget_t   s_root;
static ugui_widget_t       s_plot;
static ugui_waveform_ext_t s_plot_ext;
#define BUF_SAMPLES  512u
static int16_t s_buf_bp  [BUF_SAMPLES];
static int16_t s_buf_spo2[BUF_SAMPLES];
#define CH_BP    0u
#define CH_SPO2  1u
#define BP_MIN    80
#define BP_MAX   160
#define SPO2_MIN  70
#define SPO2_MAX 100
#define BP_TICK_N   5u
static ugui_widget_t  s_bp_tick[BP_TICK_N];
static ugui_box_ext_t s_bp_tick_ext[BP_TICK_N];
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_text_cfg_t s_bp_tick_txt[BP_TICK_N];
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static const int16_t k_bp_val [BP_TICK_N] = { 160, 140, 120, 100,  80 };
static const char*   k_bp_str [BP_TICK_N] = {"160","140","120","100"," 80"};
#define SPO2_TICK_N  4u
static ugui_widget_t  s_spo2_tick[SPO2_TICK_N];
static ugui_box_ext_t s_spo2_tick_ext[SPO2_TICK_N];
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_text_cfg_t s_spo2_tick_txt[SPO2_TICK_N];
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static const int16_t k_spo2_val [SPO2_TICK_N] = { 100,  90,  80,  70 };
static const char*   k_spo2_str [SPO2_TICK_N] = {"100"," 90"," 80"," 70"};
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
static ugui_text_cfg_t s_lbl_bp;    /* "BP mmHg"  — top-left of plot       */
static ugui_text_cfg_t s_lbl_spo2;  /* "SpO2 %"   — below BP label         */
static ugui_text_cfg_t s_lbl_t3m;   /* time marker at midpoint (≈ 3 min ago)*/
static ugui_text_cfg_t s_lbl_t1m;   /* time marker at 3/4 point (≈ 1 min ago)*/
#endif
#if defined(UGUI_WIDGET_ENABLE_WAVEFORM)
#define MED_COL_BG    ugui_color_hex(0x05060Cu)  /* fixed dark medical bg   */
#define MED_COL_GRID  ugui_color_hex(0x0F1A24u)
#define MED_COL_AXIS  ugui_color_hex(0x222C36u)
#define SCALE_W      30   /* px: width of left / right scale strip          */
#define SCALE_LBL_H  14   /* px: height of one tick label box               */
#define ROW_H        28   /* px: bottom control row                         */
#define ROW_GAP       6   /* px: gap above control row                      */
#define CARD_PAD      4   /* px: gap between header and plot                */
#define PLOT_X  ((int16_t)(10 + SCALE_W + 2))
#define PLOT_Y  ((int16_t)(38 + CARD_PAD))
#define PLOT_W  ((int16_t)(UGUI_SCREEN_W - 2 * 10 - 2 * (SCALE_W + 2)))
#define PLOT_H  ((int16_t)(UGUI_SCREEN_H - PLOT_Y - ROW_H - ROW_GAP - CARD_PAD))
#define RIGHT_SCALE_X  ((int16_t)(PLOT_X + PLOT_W + 2))
#endif

/* --- local helpers & event callbacks --- */
static int16_t tick_y(int16_t val, int16_t vmin, int16_t vmax)
{
    return (int16_t)(PLOT_Y
                     + (int32_t)PLOT_H * (vmax - val) / (vmax - vmin)
                     - SCALE_LBL_H / 2);
}

static void make_scale_tick(ugui_screen_t* owner, ugui_widget_t* w,
                             ugui_box_ext_t* ext, ugui_text_cfg_t* text_cfg,
                             int16_t x, int16_t y, ugui_align_t align,
                             ugui_color_t color, const char* str)
{
    ugui_widget_box_init(&s_root, w, ext);
    ugui_widget_set_area(w, x, y, (int16_t)SCALE_W, (int16_t)SCALE_LBL_H);
    ugui_widget_box_set_style(w, true, 0u, 0u, MED_COL_BG, MED_COL_BG);
#ifdef UGUI_ENABLE_FONT
    ugui_text_cfg_init(text_cfg, str, &lexend_14pt_2bpp, color,
                       align, (ugui_point_t){ 0, 0 }, 1u);
    ugui_widget_box_set_text(w, text_cfg);
#else
    (void)text_cfg; (void)align; (void)color; (void)str;
#endif
    w->state.enabled = 0u;   /* non-interactive; don't consume touch */
    ugui_screen_add_widget(owner, w);
}

/* --- build it (in your screen's init) --- */
ugui_widget_waveform_init(&s_root, &s_plot, &s_plot_ext);
ugui_widget_set_area(&s_plot, PLOT_X, PLOT_Y, PLOT_W, PLOT_H);
ugui_widget_set_id  (&s_plot, WDGT_ID_DEMO);
ugui_widget_waveform_set_grid  (&s_plot, 4u, 4u);
ugui_widget_waveform_set_colors(&s_plot, MED_COL_BG, MED_COL_GRID,
                                MED_COL_AXIS);

/* CH0 — Blood pressure (green), anti-aliased 2 px. */
(void)ugui_widget_waveform_add_channel(&s_plot,
                                       ugui_color_hex(0x00E07Au),
                                       s_buf_bp, BUF_SAMPLES);
ugui_widget_waveform_set_channel_range(&s_plot, CH_BP, BP_MIN, BP_MAX);
ugui_widget_waveform_set_channel_style(&s_plot, CH_BP,
                                       ugui_color_hex(0x00E07Au), 2u);

/* CH1 — SpO2 (cyan), anti-aliased 2 px. */
(void)ugui_widget_waveform_add_channel(&s_plot,
                                       ugui_color_hex(0x00C8FFu),
                                       s_buf_spo2, BUF_SAMPLES);
ugui_widget_waveform_set_channel_range(&s_plot, CH_SPO2,
                                       SPO2_MIN, SPO2_MAX);
ugui_widget_waveform_set_channel_style(&s_plot, CH_SPO2,
                                       ugui_color_hex(0x00C8FFu), 2u);

#ifdef UGUI_ENABLE_FONT
/* Channel labels — drawn inside the plot, top-left corner. */
ugui_text_cfg_init(&s_lbl_bp, "BP mmHg", &lexend_14pt_2bpp,
                   ugui_color_hex(0x00E07Au),
                   (ugui_align_t)(UGUI_ALIGN_LEFT | UGUI_ALIGN_V_CENTER),
                   (ugui_point_t){ 0, 0 }, 0u);
s_lbl_bp.area = (ugui_rect_t){ 4, 2, 80, 14 };
(void)ugui_widget_waveform_add_label(&s_plot, &s_lbl_bp);

ugui_text_cfg_init(&s_lbl_spo2, "SpO2 %", &lexend_14pt_2bpp,
                   ugui_color_hex(0x00C8FFu),
                   (ugui_align_t)(UGUI_ALIGN_LEFT | UGUI_ALIGN_V_CENTER),
                   (ugui_point_t){ 0, 0 }, 0u);
s_lbl_spo2.area = (ugui_rect_t){ 4, 18, 80, 14 };
(void)ugui_widget_waveform_add_label(&s_plot, &s_lbl_spo2);

/* Time markers — pinned to the bottom of the plot.
 * In SCROLL mode at 1 Hz the mid-point (x = PLOT_W/2) holds data
 * approximately PLOT_W/2 seconds old; labels are relative approximations. */
ugui_text_cfg_init(&s_lbl_t3m, "T-3m", &lexend_14pt_2bpp,
                   ugui_color_hex(0x3C4450u),
                   (ugui_align_t)(UGUI_ALIGN_H_CENTER | UGUI_ALIGN_V_CENTER),
                   (ugui_point_t){ 0, 0 }, 0u);
s_lbl_t3m.area = (ugui_rect_t){
    (int16_t)(PLOT_W / 2 - 20),
    (int16_t)(PLOT_H - 16),
    40, 14
};
(void)ugui_widget_waveform_add_label(&s_plot, &s_lbl_t3m);

ugui_text_cfg_init(&s_lbl_t1m, "T-1m", &lexend_14pt_2bpp,
                   ugui_color_hex(0x3C4450u),
                   (ugui_align_t)(UGUI_ALIGN_H_CENTER | UGUI_ALIGN_V_CENTER),
                   (ugui_point_t){ 0, 0 }, 0u);
s_lbl_t1m.area = (ugui_rect_t){
    (int16_t)(PLOT_W * 3 / 4 - 20),
    (int16_t)(PLOT_H - 16),
    40, 14
};
(void)ugui_widget_waveform_add_label(&s_plot, &s_lbl_t1m);
#endif /* UGUI_ENABLE_FONT */

ugui_widget_set_event_cb(&s_plot, NULL,
                         UGUI_EMASK_KEY_CONFIRM |
                         UGUI_EMASK_KEY_UP | UGUI_EMASK_KEY_DOWN);
ugui_screen_add_widget(&s_screen, &s_plot);

/* ================================================================
 * Left scale — BP ticks (right-aligned, flush to waveform left edge)
 * ================================================================ */
for (uint8_t i = 0u; i < BP_TICK_N; i++) {
    make_scale_tick(&s_screen,
                    &s_bp_tick[i], &s_bp_tick_ext[i],
#ifdef UGUI_ENABLE_FONT
                    &s_bp_tick_txt[i],
#else
                    NULL,
#endif
                    (int16_t)10,
                    tick_y(k_bp_val[i], BP_MIN, BP_MAX),
                    (ugui_align_t)(UGUI_ALIGN_RIGHT | UGUI_ALIGN_V_CENTER),
                    ugui_color_hex(0x00E07Au),
                    k_bp_str[i]);
}

/* ================================================================
 * Right scale — SpO2 ticks (left-aligned, flush to waveform right edge)
 * ================================================================ */
for (uint8_t i = 0u; i < SPO2_TICK_N; i++) {
    make_scale_tick(&s_screen,
                    &s_spo2_tick[i], &s_spo2_tick_ext[i],
#ifdef UGUI_ENABLE_FONT
                    &s_spo2_tick_txt[i],
#else
                    NULL,
#endif
                    RIGHT_SCALE_X,
                    tick_y(k_spo2_val[i], SPO2_MIN, SPO2_MAX),
                    (ugui_align_t)(UGUI_ALIGN_LEFT | UGUI_ALIGN_V_CENTER),
                    ugui_color_hex(0x00C8FFu),
                    k_spo2_str[i]);
}


From this widget to a working app

Everything above is the widget-specific code. The scaffolding it drops into — storage, HAL, main loop, display-mode bring-up — is the same for every app: copy it from the Application Skeleton.