feat: Add scroll functionality to libx52util

This change adds the ability to scroll a text blob to fit within the 16
character MFD limit. The daemon can use this in the future to implement
scrolling similar to how the Windows SST software did it.
lipc-refactor
nirenjan 2026-04-25 12:54:41 -07:00
parent 4422ee89c0
commit 130a1f67de
4 changed files with 668 additions and 0 deletions

View File

@ -60,6 +60,119 @@ extern "C" {
LIBX52UTIL_API int libx52util_convert_utf8_string(const uint8_t *input, LIBX52UTIL_API int libx52util_convert_utf8_string(const uint8_t *input,
uint8_t *output, size_t *len); uint8_t *output, size_t *len);
/**
* @name MFD line scrolling
*
* Step a 16-cell window over a virtual tape of X52 MFD display bytes (same
* encoding as libx52_set_text / libx52util_convert_utf8_string output). Cell
* count follows convert semantics (multi-byte glyphs use multiple cells).
*
* **Default wrap:** unless #LIBX52UTIL_SCROLL_SINGLE_PASS is set, each
* libx52util_scroll_next() advances the window modulo the tape period so the
* marquee loops until libx52util_scroll_reset() or libx52util_scroll_free().
*
* These routines are not thread-safe; callers must serialize access to a given
* libx52util_scroll_state.
*
* @{
*/
/** Opaque scroll state allocated by libx52util_scroll_new(). */
typedef struct libx52util_scroll libx52util_scroll_state;
/**
* @brief Bitwise flags for libx52util_scroll_new().
*
* Combine enumerator values with `|`. Each enumerator is a distinct power of two.
*/
typedef enum libx52util_scroll_flags {
LIBX52UTIL_SCROLL_NONE = 0,
/** Virtual prefix of 16 ASCII spaces before converted text (scroll-in). */
LIBX52UTIL_SCROLL_IN = 1u << 0,
/** Virtual suffix of 16 ASCII spaces after converted text (scroll-out). */
LIBX52UTIL_SCROLL_OUT = 1u << 1,
/**
* Horizontal direction opposite the default ticker (default advances the window
* so content appears to move left; with this flag, content moves right).
*/
LIBX52UTIL_SCROLL_LTR = 1u << 2,
/**
* Disable looping: after one full cycle over distinct window positions, the
* window clamps at the end; further libx52util_scroll_next() returns -EAGAIN
* until libx52util_scroll_reset() or libx52util_scroll_new(). Without this flag,
* the window wraps continuously. Implies LIBX52UTIL_SCROLL_OUT.
*/
LIBX52UTIL_SCROLL_SINGLE_PASS = 1u << 3,
} libx52util_scroll_flags;
/**
* @brief Allocate scroll state from a UTF-8 string.
*
* Converts @p utf8_string with libx52util_convert_utf8_string() using an
* internal 256-byte output buffer. Converted length is in display cells, same
* as libx52util_convert_utf8_string()'s output length.
*
* @param[out] state Receives the new state; must not be NULL.
* @param[in] utf8_string NUL-terminated UTF-8 input; must not be NULL.
* @param[in] flags Bitwise OR of #libx52util_scroll_flags values.
*
* @returns 0 if the full string fit the internal buffer; -E2BIG if conversion
* stopped early but @p *state is still a valid object for scroll_next/reset/free
* (truncated prefix only). On -EINVAL or -ENOMEM, @p *state is left unchanged.
* -EINVAL: NULL @p state, NULL @p utf8_string, or invalid argument use.
* -ENOMEM: allocation failed.
*
* If the converted text length is at most 16 cells, or the full virtual tape
* (optional 16-space prefix + text + optional 16-space suffix) is at most 16
* cells long, there is no window motion: libx52util_scroll_next() returns 0
* once with a padded line, then -EAGAIN without advancing.
*/
LIBX52UTIL_API int libx52util_scroll_new(libx52util_scroll_state **state,
const uint8_t *utf8_string,
libx52util_scroll_flags flags);
/**
* @brief Free scroll state.
*
* If @p state is non-NULL and @p *state is non-NULL, frees @p *state and sets
* @p *state to NULL. If @p state is NULL, no operation is performed.
*/
LIBX52UTIL_API void libx52util_scroll_free(libx52util_scroll_state **state);
/**
* @brief Rewind the scroll window to the initial position.
*
* @param[in] state Scroll state from libx52util_scroll_new().
*
* @returns 0 on success, -EINVAL if @p state is NULL.
*/
LIBX52UTIL_API int libx52util_scroll_reset(libx52util_scroll_state *state);
/**
* @brief Emit the next 16-byte MFD line for the current window.
*
* Writes up to 16 display bytes into @p display (ASCII space padding where the
* window extends past the virtual tape). Returns 0 when the new frame differs
* from the last frame returned with 0; updates the last-emitted snapshot and
* advances the internal window for the following call. Returns -EAGAIN when the
* candidate frame equals that last snapshot: @p display is not modified, the
* snapshot is unchanged, but the window index still advances (wrap or
* single-pass clamp) so uniform runs eventually change at boundaries.
*
* When there is no scrolling (see libx52util_scroll_new): the first call
* returns 0; later calls return -EAGAIN without advancing the window.
*
* @param[in] state Scroll state; must not be NULL.
* @param[out] display 16-byte output buffer; must not be NULL.
*
* @returns 0 on a new visible frame, -EAGAIN when unchanged or static repeat,
* -EINVAL if @p state or @p display is NULL.
*/
LIBX52UTIL_API int libx52util_scroll_next(libx52util_scroll_state *state,
uint8_t display[16]);
/** @} */
/** @} */ /** @} */
#ifdef __cplusplus #ifdef __cplusplus

View File

@ -12,6 +12,7 @@ util_char_map = custom_target('util-char-map',
install_dir: [false, get_option('datadir') / 'x52d']) install_dir: [false, get_option('datadir') / 'x52d'])
lib_libx52util = library('x52util', util_char_map, 'char_map_lookup.c', lib_libx52util = library('x52util', util_char_map, 'char_map_lookup.c',
'scroll.c',
install: true, install: true,
version: libx52util_version, version: libx52util_version,
c_args: sym_hidden_cargs, c_args: sym_hidden_cargs,
@ -36,6 +37,18 @@ test('libx52util-bmp-test', libx52util_bmp_test,
protocol: 'tap', protocol: 'tap',
args: [util_char_map[1]]) args: [util_char_map[1]])
libx52util_scroll_test = executable(
'libx52util-scroll-test',
'scroll_test.c',
build_by_default: false,
include_directories: [includes, lib_libx52util.private_dir_include()],
link_with: [lib_libx52util],
dependencies: [dep_cmocka],
)
test('libx52util-scroll-test', libx52util_scroll_test,
protocol: 'tap')
benchmark('libx52util-bmp-bench', libx52util_bmp_test, benchmark('libx52util-bmp-bench', libx52util_bmp_test,
protocol: 'tap', protocol: 'tap',
args: [util_char_map[1]]) args: [util_char_map[1]])

210
libx52util/scroll.c 100644
View File

@ -0,0 +1,210 @@
/*
* Saitek X52 Pro MFD line scrolling (libx52util)
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include <errno.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <libx52/libx52util.h>
#define SCROLL_OUT_BUF 256u
#define SCROLL_WINDOW 16u
#define SCROLL_PAD 16u
struct libx52util_scroll {
uint8_t text[SCROLL_OUT_BUF];
size_t text_len;
uint32_t flags;
size_t prefix;
size_t suffix;
size_t tape_len;
size_t max_pos;
size_t pos;
bool has_last;
uint8_t last[SCROLL_WINDOW];
bool static_mode;
bool static_emitted;
bool single_pass;
bool single_pass_done;
bool ltr;
};
static uint8_t tape_byte(const struct libx52util_scroll *st, size_t index)
{
if (index < st->prefix) {
return (uint8_t)' ';
}
index -= st->prefix;
if (index < st->text_len) {
return st->text[index];
}
index -= st->text_len;
if (index < st->suffix) {
return (uint8_t)' ';
}
return (uint8_t)' ';
}
static void fill_frame(const struct libx52util_scroll *st, size_t tape_pos,
uint8_t display[SCROLL_WINDOW])
{
for (size_t i = 0; i < SCROLL_WINDOW; i++) {
size_t idx = tape_pos + i;
if (idx < st->tape_len) {
display[i] = tape_byte(st, idx);
} else {
display[i] = (uint8_t)' ';
}
}
}
static void advance_pos(struct libx52util_scroll *st)
{
if (st->static_mode) {
return;
}
if (st->single_pass_done) {
return;
}
if (!st->ltr) {
if (st->pos < st->max_pos) {
st->pos++;
} else if (!st->single_pass) {
st->pos = 0;
} else {
st->single_pass_done = true;
}
} else {
if (st->pos > 0) {
st->pos--;
} else if (!st->single_pass) {
st->pos = st->max_pos;
} else {
st->single_pass_done = true;
}
}
}
int libx52util_scroll_new(libx52util_scroll_state **state,
const uint8_t *utf8_string, libx52util_scroll_flags flags)
{
struct libx52util_scroll *st;
size_t out_len;
int conv_rc;
if (!state || !utf8_string) {
return -EINVAL;
}
st = calloc(1, sizeof(*st));
if (!st) {
return -ENOMEM;
}
out_len = SCROLL_OUT_BUF;
conv_rc = libx52util_convert_utf8_string(utf8_string, st->text, &out_len);
if (conv_rc == -EINVAL) {
free(st);
return -EINVAL;
}
st->text_len = out_len;
st->flags = (uint32_t)flags;
{
uint32_t eff = st->flags;
if (eff & (uint32_t)LIBX52UTIL_SCROLL_SINGLE_PASS) {
eff |= (uint32_t)LIBX52UTIL_SCROLL_OUT;
}
st->prefix = (eff & (uint32_t)LIBX52UTIL_SCROLL_IN) ? SCROLL_PAD : 0;
st->suffix = (eff & (uint32_t)LIBX52UTIL_SCROLL_OUT) ? SCROLL_PAD : 0;
}
st->tape_len = st->prefix + st->text_len + st->suffix;
st->ltr = (st->flags & (uint32_t)LIBX52UTIL_SCROLL_LTR) != 0;
st->single_pass = (st->flags & (uint32_t)LIBX52UTIL_SCROLL_SINGLE_PASS) != 0;
st->static_mode =
(st->text_len <= SCROLL_WINDOW) || (st->tape_len <= SCROLL_WINDOW);
if (st->tape_len > SCROLL_WINDOW) {
st->max_pos = st->tape_len - SCROLL_WINDOW;
} else {
st->max_pos = 0;
}
st->pos = st->ltr ? st->max_pos : 0;
st->has_last = false;
st->static_emitted = false;
st->single_pass_done = false;
*state = st;
if (conv_rc == -E2BIG) {
return -E2BIG;
}
return 0;
}
void libx52util_scroll_free(libx52util_scroll_state **state)
{
if (!state || !*state) {
return;
}
free(*state);
*state = NULL;
}
int libx52util_scroll_reset(libx52util_scroll_state *state)
{
if (!state) {
return -EINVAL;
}
state->pos = state->ltr ? state->max_pos : 0;
state->has_last = false;
state->static_emitted = false;
state->single_pass_done = false;
return 0;
}
int libx52util_scroll_next(libx52util_scroll_state *state, uint8_t display[SCROLL_WINDOW])
{
uint8_t candidate[SCROLL_WINDOW];
if (!state || !display) {
return -EINVAL;
}
if (state->static_mode && state->static_emitted) {
return -EAGAIN;
}
fill_frame(state, state->pos, candidate);
if (state->has_last &&
memcmp(candidate, state->last, SCROLL_WINDOW) == 0) {
advance_pos(state);
return -EAGAIN;
}
memcpy(display, candidate, SCROLL_WINDOW);
memcpy(state->last, candidate, SCROLL_WINDOW);
state->has_last = true;
if (state->static_mode) {
state->static_emitted = true;
}
advance_pos(state);
return 0;
}

View File

@ -0,0 +1,332 @@
/*
* libx52util MFD scroll tests (cmocka, TAP output)
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include <errno.h>
#include <setjmp.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <cmocka.h>
#include <libx52/libx52util.h>
#define MFD_LINE 16u
static void utf8_to_mfd(const char *utf8, uint8_t *out, size_t *out_len)
{
*out_len = 256;
(void)libx52util_convert_utf8_string((const uint8_t *)utf8, out, out_len);
}
static bool frame_all_spaces(const uint8_t *d)
{
for (size_t i = 0; i < MFD_LINE; i++) {
if (d[i] != (uint8_t)' ') {
return false;
}
}
return true;
}
static void test_scroll_new_null_state(void **state)
{
(void)state;
assert_int_equal(-EINVAL,
libx52util_scroll_new(NULL, (const uint8_t *)"x",
LIBX52UTIL_SCROLL_NONE));
}
static void test_scroll_new_null_utf8(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
assert_int_equal(-EINVAL,
libx52util_scroll_new(&st, NULL, LIBX52UTIL_SCROLL_NONE));
assert_null(st);
}
static void test_scroll_next_null_state(void **state)
{
(void)state;
uint8_t disp[MFD_LINE];
memset(disp, 0xAA, sizeof(disp));
assert_int_equal(-EINVAL, libx52util_scroll_next(NULL, disp));
}
static void test_scroll_next_null_display(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
assert_int_equal(0, libx52util_scroll_new(&st, (const uint8_t *)"z",
LIBX52UTIL_SCROLL_NONE));
assert_non_null(st);
assert_int_equal(-EINVAL, libx52util_scroll_next(st, NULL));
libx52util_scroll_free(&st);
}
static void test_scroll_reset_null(void **state)
{
(void)state;
assert_int_equal(-EINVAL, libx52util_scroll_reset(NULL));
}
static void test_short_string_first_then_eagain(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
uint8_t disp[MFD_LINE];
assert_int_equal(0, libx52util_scroll_new(&st, (const uint8_t *)"AB",
LIBX52UTIL_SCROLL_NONE));
assert_non_null(st);
assert_int_equal(0, libx52util_scroll_next(st, disp));
assert_int_equal(-EAGAIN, libx52util_scroll_next(st, disp));
libx52util_scroll_free(&st);
}
static void test_long_string_wrap(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
uint8_t first[MFD_LINE];
uint8_t disp[MFD_LINE];
uint8_t disp2[MFD_LINE];
assert_int_equal(
0, libx52util_scroll_new(&st, (const uint8_t *)"01234567890123456",
LIBX52UTIL_SCROLL_NONE));
assert_non_null(st);
assert_int_equal(0, libx52util_scroll_next(st, first));
assert_int_equal(0, libx52util_scroll_next(st, disp));
assert_int_equal(0, libx52util_scroll_next(st, disp2));
assert_memory_equal(first, disp2, MFD_LINE);
libx52util_scroll_free(&st);
}
static void test_identical_glyph_run(void **state)
{
(void)state;
char buf[32];
libx52util_scroll_state *st = NULL;
uint8_t disp[MFD_LINE];
uint8_t disp2[MFD_LINE];
int rc;
memset(buf, 'A', sizeof(buf));
buf[17] = '\0';
assert_int_equal(0, libx52util_scroll_new(&st, (const uint8_t *)buf,
LIBX52UTIL_SCROLL_NONE));
assert_non_null(st);
assert_int_equal(0, libx52util_scroll_next(st, disp));
memcpy(disp2, disp, sizeof(disp));
rc = libx52util_scroll_next(st, disp);
assert_int_equal(-EAGAIN, rc);
assert_memory_equal(disp, disp2, MFD_LINE);
libx52util_scroll_free(&st);
}
static void test_scroll_in_first_all_spaces(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
uint8_t disp[MFD_LINE];
assert_int_equal(
0,
libx52util_scroll_new(&st, (const uint8_t *)"01234567890123456789",
LIBX52UTIL_SCROLL_IN));
assert_non_null(st);
assert_int_equal(0, libx52util_scroll_next(st, disp));
assert_true(frame_all_spaces(disp));
libx52util_scroll_free(&st);
}
static void test_scroll_out_eventually_all_spaces(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
uint8_t disp[MFD_LINE];
bool saw_all_spaces = false;
int rc;
int i;
assert_int_equal(
0, libx52util_scroll_new(&st, (const uint8_t *)"01234567890123456",
LIBX52UTIL_SCROLL_OUT));
assert_non_null(st);
for (i = 0; i < 64 && !saw_all_spaces; i++) {
rc = libx52util_scroll_next(st, disp);
if (rc == 0 && frame_all_spaces(disp)) {
saw_all_spaces = true;
} else if (rc == -EAGAIN) {
continue;
} else if (rc != 0) {
break;
}
}
assert_true(saw_all_spaces);
libx52util_scroll_free(&st);
}
static void test_reset_rewinds(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
uint8_t first[MFD_LINE];
uint8_t disp[MFD_LINE];
assert_int_equal(
0, libx52util_scroll_new(&st, (const uint8_t *)"01234567890123456",
LIBX52UTIL_SCROLL_NONE));
assert_non_null(st);
assert_int_equal(0, libx52util_scroll_next(st, first));
(void)libx52util_scroll_next(st, disp);
(void)libx52util_scroll_next(st, disp);
assert_int_equal(0, libx52util_scroll_reset(st));
assert_int_equal(0, libx52util_scroll_next(st, disp));
assert_memory_equal(first, disp, MFD_LINE);
libx52util_scroll_free(&st);
}
static void test_single_pass_clamp_then_reset(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
uint8_t disp[MFD_LINE];
int rc;
int zeros = 0;
int steps = 0;
int i;
assert_int_equal(
0, libx52util_scroll_new(&st, (const uint8_t *)"01234567890123456",
LIBX52UTIL_SCROLL_SINGLE_PASS));
assert_non_null(st);
while (steps < 4096) {
rc = libx52util_scroll_next(st, disp);
steps++;
if (rc == 0) {
zeros++;
}
if (zeros > 2 && rc == -EAGAIN) {
int eagain_streak = 0;
for (i = 0; i < 8; i++) {
uint8_t tmp[MFD_LINE];
memcpy(tmp, disp, sizeof(tmp));
rc = libx52util_scroll_next(st, disp);
steps++;
if (rc == -EAGAIN && memcmp(disp, tmp, MFD_LINE) == 0) {
eagain_streak++;
}
}
if (eagain_streak >= 8) {
break;
}
}
}
assert_int_equal(0, libx52util_scroll_reset(st));
assert_true(zeros > 0);
assert_int_equal(0, libx52util_scroll_next(st, disp));
libx52util_scroll_free(&st);
}
static void test_e2big_truncated_prefix(void **state)
{
(void)state;
char big[512];
libx52util_scroll_state *st = NULL;
uint8_t disp[MFD_LINE];
uint8_t mfd[256];
size_t mfd_len;
int rc;
memset(big, 'B', sizeof(big) - 1);
big[sizeof(big) - 1] = '\0';
rc = libx52util_scroll_new(&st, (const uint8_t *)big, LIBX52UTIL_SCROLL_NONE);
assert_int_equal(-E2BIG, rc);
assert_non_null(st);
assert_int_equal(0, libx52util_scroll_next(st, disp));
mfd_len = sizeof(mfd);
utf8_to_mfd(big, mfd, &mfd_len);
assert_true((size_t)mfd_len >= MFD_LINE);
assert_memory_equal(mfd, disp, MFD_LINE);
libx52util_scroll_free(&st);
assert_null(st);
}
static void test_scroll_free_nulls_state(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
assert_int_equal(0, libx52util_scroll_new(&st, (const uint8_t *)"q",
LIBX52UTIL_SCROLL_NONE));
assert_non_null(st);
libx52util_scroll_free(&st);
assert_null(st);
}
static void test_ltr_first_frame_trailing(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
uint8_t disp[MFD_LINE];
uint8_t mfd[256];
size_t mfd_len;
assert_int_equal(
0, libx52util_scroll_new(&st, (const uint8_t *)"01234567890123456",
LIBX52UTIL_SCROLL_LTR));
assert_non_null(st);
mfd_len = sizeof(mfd);
utf8_to_mfd("01234567890123456", mfd, &mfd_len);
assert_true(mfd_len >= 17);
assert_int_equal(0, libx52util_scroll_next(st, disp));
assert_memory_equal(mfd + (mfd_len - MFD_LINE), disp, MFD_LINE);
libx52util_scroll_free(&st);
}
static void test_scroll_free_defensive_null(void **state)
{
(void)state;
libx52util_scroll_state *st = NULL;
libx52util_scroll_free(NULL);
libx52util_scroll_free(&st);
assert_null(st);
}
int main(void)
{
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_scroll_new_null_state),
cmocka_unit_test(test_scroll_new_null_utf8),
cmocka_unit_test(test_scroll_next_null_state),
cmocka_unit_test(test_scroll_next_null_display),
cmocka_unit_test(test_scroll_reset_null),
cmocka_unit_test(test_short_string_first_then_eagain),
cmocka_unit_test(test_long_string_wrap),
cmocka_unit_test(test_identical_glyph_run),
cmocka_unit_test(test_scroll_in_first_all_spaces),
cmocka_unit_test(test_scroll_out_eventually_all_spaces),
cmocka_unit_test(test_reset_rewinds),
cmocka_unit_test(test_single_pass_clamp_then_reset),
cmocka_unit_test(test_e2big_truncated_prefix),
cmocka_unit_test(test_scroll_free_nulls_state),
cmocka_unit_test(test_ltr_first_frame_trailing),
cmocka_unit_test(test_scroll_free_defensive_null),
};
cmocka_set_message_output(CM_OUTPUT_TAP);
return cmocka_run_group_tests(tests, NULL, NULL);
}