From 130a1f67de2a5b5ed7e760d6d06bb16df0d05cc9 Mon Sep 17 00:00:00 2001 From: nirenjan Date: Sat, 25 Apr 2026 12:54:41 -0700 Subject: [PATCH] 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. --- include/libx52/libx52util.h | 113 ++++++++++++ libx52util/meson.build | 13 ++ libx52util/scroll.c | 210 +++++++++++++++++++++++ libx52util/scroll_test.c | 332 ++++++++++++++++++++++++++++++++++++ 4 files changed, 668 insertions(+) create mode 100644 libx52util/scroll.c create mode 100644 libx52util/scroll_test.c diff --git a/include/libx52/libx52util.h b/include/libx52/libx52util.h index 3fb0656..a159f10 100644 --- a/include/libx52/libx52util.h +++ b/include/libx52/libx52util.h @@ -60,6 +60,119 @@ extern "C" { LIBX52UTIL_API int libx52util_convert_utf8_string(const uint8_t *input, 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 diff --git a/libx52util/meson.build b/libx52util/meson.build index d69cb8b..b2719a7 100644 --- a/libx52util/meson.build +++ b/libx52util/meson.build @@ -12,6 +12,7 @@ util_char_map = custom_target('util-char-map', install_dir: [false, get_option('datadir') / 'x52d']) lib_libx52util = library('x52util', util_char_map, 'char_map_lookup.c', + 'scroll.c', install: true, version: libx52util_version, c_args: sym_hidden_cargs, @@ -36,6 +37,18 @@ test('libx52util-bmp-test', libx52util_bmp_test, protocol: 'tap', 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, protocol: 'tap', args: [util_char_map[1]]) diff --git a/libx52util/scroll.c b/libx52util/scroll.c new file mode 100644 index 0000000..a45ba68 --- /dev/null +++ b/libx52util/scroll.c @@ -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 +#include +#include +#include +#include +#include + +#include + +#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; +} diff --git a/libx52util/scroll_test.c b/libx52util/scroll_test.c new file mode 100644 index 0000000..bd7d8a8 --- /dev/null +++ b/libx52util/scroll_test.c @@ -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 +#include +#include +#include +#include +#include +#include + +#include +#include + +#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); +}