From 7ab721aeacdb238a1970dd2671726d3ce12fc0ea Mon Sep 17 00:00:00 2001 From: TharunBabu-05 Date: Sat, 3 Oct 2026 23:16:04 +0530 Subject: [PATCH 1/2] Test the widgets and the Editor's undo and redo test_widgets: text box editing, scrolling, own buffer and paste; list keys, wheel, clicks and shrinking; the scrollbar; checkbox, button and progress bar; focus order; message and input boxes; the window and widget slots kept for a message box. test_editor: undo by word, runs of Backspace and Delete, typing over a selection, cut / copy / paste and a terminal paste as single steps, the '*' following the save point, a new edit ending redo, the history limits, and closing with unsaved changes. The file is in memory and the text is read back from the screen. --- CMakeLists.txt | 7 +- tests/test_editor.c | 400 +++++++++++++++++++++++++++++++ tests/test_widgets.c | 551 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 tests/test_editor.c create mode 100644 tests/test_widgets.c diff --git a/CMakeLists.txt b/CMakeLists.txt index e7cc8c5..e84d893 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -239,7 +239,7 @@ if(TD_BUILD_TESTS) set_target_properties(test_console_access PROPERTIES C_STANDARD 11 C_STANDARD_REQUIRED ON) target_compile_options(test_console_access PRIVATE ${TD_WARNINGS}) add_test(NAME test_console_access COMMAND test_console_access) - foreach(t test_utf8 test_input test_render test_vterm test_wm) + foreach(t test_utf8 test_input test_render test_vterm test_wm test_widgets) add_executable(${t} tests/${t}.c) target_link_libraries(${t} PRIVATE tinydesk_core) set_target_properties(${t} PROPERTIES C_STANDARD 11) @@ -251,4 +251,9 @@ if(TD_BUILD_TESTS) set_target_properties(test_proto PROPERTIES C_STANDARD 11) target_compile_options(test_proto PRIVATE ${TD_WARNINGS}) add_test(NAME test_proto COMMAND test_proto) + add_executable(test_editor tests/test_editor.c) + target_link_libraries(test_editor PRIVATE tinydesk_apps) + set_target_properties(test_editor PROPERTIES C_STANDARD 11) + target_compile_options(test_editor PRIVATE ${TD_WARNINGS}) + add_test(NAME test_editor COMMAND test_editor) endif() diff --git a/tests/test_editor.c b/tests/test_editor.c new file mode 100644 index 0000000..0083238 --- /dev/null +++ b/tests/test_editor.c @@ -0,0 +1,400 @@ +/* + * test_editor.c - the Editor's undo and redo, cut / copy / paste, and + * closing with unsaved changes. Keys and pastes are synthetic events; the + * file lives in memory, and the text is read back from the screen. + */ +#include "td_test.h" +#include "td_apps.h" + +#define PATH "/fs/a.txt" + +static uint32_t s_now = 10000; +static int fake_read(void *ctx) { (void)ctx; return -1; } +static int fake_write(void *ctx, const uint8_t *b, int n) { (void)ctx; (void)b; return n; } +static uint32_t fake_millis(void *ctx) { (void)ctx; return s_now; } +static void fake_sleep(void *ctx, uint32_t ms) { (void)ctx; s_now += ms; } +static const td_hal_t s_hal = { fake_read, fake_write, fake_millis, fake_sleep, NULL }; + +/* ------------------------------------------------- one file in memory */ + +static char s_file[20000]; +static int s_file_len; +static int s_writes; + +static int fs_read(const char *path, char *buf, int cap) +{ + (void)path; + int n = s_file_len < cap ? s_file_len : cap; + memcpy(buf, s_file, (size_t)n); + return n; +} + +static int fs_write(const char *path, const char *data, int len) +{ + (void)path; + if (len > (int)sizeof(s_file)) return -1; + memcpy(s_file, data, (size_t)len); + s_file_len = len; + s_writes++; + return 0; +} + +static int fs_exists(const char *path) { (void)path; return 1; } + +static const td_fs_ops_t s_fs = { .root = "/fs", .read = fs_read, .write = fs_write, .exists = fs_exists }; +static const td_sysinfo_t s_info = { .fs = &s_fs }; + +static bool file_is(const char *expect) +{ + if (s_file_len == (int)strlen(expect) && memcmp(s_file, expect, (size_t)s_file_len) == 0) return true; + printf(" file: \"%.*s\", expected \"%s\"\n", s_file_len, s_file, expect); + return false; +} + +/* ------------------------------------------------------------- input */ + +static void key(uint32_t k, uint8_t mods) +{ + td_event_t ev = { 0 }; + ev.type = TD_EV_KEY; + ev.key = k; + ev.mods = mods; + ev.time_ms = s_now; + td_wm_dispatch(&ev); +} + +static void ctrl(char c) { key((uint32_t)c, TD_MOD_CTRL); } + +static void type(const char *s) +{ + while (*s) key((uint8_t)*s++, 0); +} + +/* A bracketed paste, as a terminal sends it, through the input parser. */ +static void paste(const char *text) +{ + static td_input_t in; + td_input_init(&in); + const char *parts[3] = { "\x1b[200~", text, "\x1b[201~" }; + for (int i = 0; i < 3; i++) + for (const char *p = parts[i]; *p; p++) td_input_feed(&in, (uint8_t)*p, s_now); + td_event_t ev; + while (td_event_pop(&ev)) { + td_wm_dispatch(&ev); + if (ev.type == TD_EV_PASTE) td_input_paste_done(&in); + } +} + +/* ------------------------------------------------------------ screen */ + +static td_window_t *s_ed; +static td_buffer_t s_back; + +/* Open PATH holding `text` in a fresh Editor window. */ +static void open_file(const char *text) +{ + td_wm_close_all(); + s_file_len = (int)strlen(text); + memcpy(s_file, text, (size_t)s_file_len); + s_writes = 0; + td_editor_open(PATH); + s_ed = td_win_focused(); + CHECK(s_ed != NULL); +} + +/* Row `row` of the Editor's client area as drawn, without trailing blanks. */ +static const char *row_text(int row) +{ + static char out[128]; + td_wm_compose(&s_back); + td_rect_t c = td_win_client(s_ed); + int n = 0; + for (int x = 0; x < c.w && n < (int)sizeof(out) - 1; x++) { + uint32_t ch = td_buffer_cell(&s_back, c.x + x, c.y + row)->ch; + out[n++] = ch < 0x80 ? (char)ch : '?'; + } + while (n > 0 && out[n - 1] == ' ') n--; + out[n] = '\0'; + return out; +} + +static bool shows(int row, const char *expect) +{ + const char *got = row_text(row); + if (strcmp(got, expect) == 0) return true; + printf(" row %d: \"%s\", expected \"%s\"\n", row, got, expect); + return false; +} + +/* The status bar is the last row. */ +static bool status_contains(const char *text) { return strstr(row_text(td_win_client(s_ed).h - 1), text) != NULL; } + +static bool status_has(const char *text) +{ + if (status_contains(text)) return true; + printf(" status: \"%s\", expected \"%s\" in it\n", row_text(td_win_client(s_ed).h - 1), text); + return false; +} + +/* Unsaved changes show as a '*' before the title. */ +static bool changed(void) { return s_ed->title[0] == '*'; } + +/* -------------------------------------------------------------- tests */ + +static void test_undo_by_word(void) +{ + open_file(""); + type("hello world"); + CHECK(shows(0, "hello world")); + CHECK(changed()); + + /* Typing is undone a word at a time. */ + ctrl('z'); + CHECK(shows(0, "hello")); + CHECK(status_has("Col 7")); /* the space is still there */ + ctrl('z'); + CHECK(shows(0, "")); + CHECK(!changed()); /* back to the file as it was */ + ctrl('z'); + CHECK(status_has("Nothing to undo")); + + /* Redo with Ctrl+Y or Ctrl+Shift+Z. */ + ctrl('y'); + CHECK(shows(0, "hello")); + key('z', TD_MOD_CTRL | TD_MOD_SHIFT); + CHECK(shows(0, "hello world")); + ctrl('y'); + CHECK(status_has("Nothing to redo")); + + ctrl('s'); + CHECK(file_is("hello world")); + CHECK(!changed()); +} + +static void test_changed_follows_the_save_point(void) +{ + open_file("abc"); + CHECK(!changed()); + key(TD_KEY_END, 0); + type("!"); + CHECK(changed()); + ctrl('s'); + CHECK(file_is("abc!")); + CHECK(!changed()); + + /* Undoing past the save marks the file changed; redoing back to it + * clears the mark. */ + ctrl('z'); + CHECK(shows(0, "abc")); + CHECK(changed()); + ctrl('y'); + CHECK(!changed()); + + /* Typing after a save is a step of its own. */ + type("?"); + ctrl('z'); + CHECK(shows(0, "abc!")); + CHECK(!changed()); +} + +static void test_new_edit_ends_redo(void) +{ + open_file(""); + type("one two"); + ctrl('z'); + type("three"); + CHECK(shows(0, "one three")); + ctrl('y'); + CHECK(status_has("Nothing to redo")); + ctrl('z'); + CHECK(shows(0, "one")); + ctrl('z'); + CHECK(shows(0, "")); +} + +static void test_delete_runs(void) +{ + open_file("abcdef"); + key(TD_KEY_END, 0); + + /* A run of Backspace is one step, and so is a run of Delete. */ + for (int i = 0; i < 3; i++) key(TD_KEY_BACKSPACE, 0); + CHECK(shows(0, "abc")); + key(TD_KEY_HOME, 0); + for (int i = 0; i < 2; i++) key(TD_KEY_DELETE, 0); + CHECK(shows(0, "c")); + ctrl('z'); + CHECK(shows(0, "abc")); + CHECK(status_has("Col 1")); /* where the Deletes started */ + ctrl('z'); + CHECK(shows(0, "abcdef")); + CHECK(status_has("Col 7")); +} + +static void test_typing_over_a_selection(void) +{ + /* Replacing the selection and typing on is one step. */ + open_file("old text"); + ctrl('a'); + type("new"); + CHECK(shows(0, "new")); + ctrl('z'); + CHECK(shows(0, "old text")); + ctrl('y'); + CHECK(shows(0, "new")); + + /* Shift+arrows select too. */ + open_file("abcdef"); + for (int i = 0; i < 3; i++) key(TD_KEY_RIGHT, TD_MOD_SHIFT); + type("X"); + CHECK(shows(0, "Xdef")); + ctrl('z'); + CHECK(shows(0, "abcdef")); +} + +static void test_cut_copy_paste(void) +{ + open_file("abc"); + ctrl('a'); + ctrl('x'); + CHECK(shows(0, "")); + ctrl('v'); + ctrl('v'); + CHECK(shows(0, "abcabc")); + + /* Each cut and each paste is a step of its own. */ + ctrl('z'); + CHECK(shows(0, "abc")); + ctrl('z'); + CHECK(shows(0, "")); + ctrl('z'); + CHECK(shows(0, "abc")); + CHECK(!changed()); + + /* Copying changes nothing and is not a step. */ + ctrl('a'); + ctrl('c'); + CHECK(status_has("Copied 3 characters")); + key(TD_KEY_END, 0); + ctrl('v'); + CHECK(shows(0, "abcabc")); + ctrl('z'); + CHECK(shows(0, "abc")); + CHECK(!changed()); +} + +static void test_paste_from_the_terminal(void) +{ + /* CR and CRLF become LF, and the whole paste is one step. */ + open_file(""); + paste("one\r\ntwo\rthree"); + CHECK(shows(0, "one")); + CHECK(shows(1, "two")); + CHECK(shows(2, "three")); + CHECK(status_has("Pasted 13 characters")); + ctrl('z'); + CHECK(shows(0, "")); + CHECK(shows(1, "")); + + /* It also went to the clipboard. */ + ctrl('v'); + ctrl('s'); + CHECK(file_is("one\ntwo\nthree")); +} + +static void test_history_limits(void) +{ + /* The oldest steps are dropped when the history is full; what is left + * still undoes and redoes to the right text. Words differ, so text + * restored from the wrong place in the history would show. */ + enum { WORDS = 200 }; + static char full[WORDS * 2 + 1]; + open_file(""); + for (int i = 0; i < WORDS; i++) { + char word[3] = { (char)('a' + i % 26), ' ', '\0' }; + type(word); + memcpy(full + i * 2, word, 2); + } + int undone = 0; + while (undone <= WORDS) { + ctrl('z'); + if (status_contains("Nothing to undo")) break; + undone++; + } + int kept = WORDS - undone; + CHECK(kept > 0 && kept < WORDS); + ctrl('s'); + char expect[WORDS * 2 + 1]; + snprintf(expect, sizeof(expect), "%.*s", kept * 2, full); + CHECK(file_is(expect)); + for (int i = 0; i < undone; i++) ctrl('y'); + ctrl('s'); + CHECK(file_is(full)); + + /* A paste larger than the whole history (TD_EDITOR_UNDO, 6 KB by + * default) is kept in the text but cannot be undone; the file stays + * marked as changed. */ + static char big[10001]; + memset(big, 'x', sizeof(big) - 1); + open_file(""); + td_clipboard_set(big, (int)sizeof(big) - 1); + ctrl('v'); + ctrl('z'); + CHECK(status_has("Nothing to undo")); + CHECK(changed()); + ctrl('s'); + CHECK_EQ(s_file_len, (int)sizeof(big) - 1); +} + +static void test_close_asks_about_changes(void) +{ + /* Without changes Esc closes at once. */ + open_file("x"); + key(TD_KEY_ESC, 0); + CHECK(!td_win_is_open(s_ed)); + + /* With changes it asks first; Esc on the question keeps the Editor. */ + open_file("x"); + key(TD_KEY_END, 0); + type("y"); + key(TD_KEY_ESC, 0); + CHECK(td_win_is_open(s_ed)); + td_window_t *question = td_win_focused(); + CHECK(question != s_ed && (question->flags & TD_WIN_MODAL)); + key(TD_KEY_ESC, 0); + CHECK(td_win_focused() == s_ed); + CHECK(changed()); + + /* Discard closes without saving. */ + key(TD_KEY_ESC, 0); + key(TD_KEY_TAB, 0); /* Save -> Discard */ + key(TD_KEY_ENTER, 0); + CHECK(!td_win_is_open(s_ed)); + CHECK_EQ(s_writes, 0); + CHECK(file_is("x")); + + /* Save writes the file, then closes. */ + open_file("x"); + key(TD_KEY_END, 0); + type("z"); + key(TD_KEY_ESC, 0); + key(TD_KEY_ENTER, 0); /* Save */ + CHECK(!td_win_is_open(s_ed)); + CHECK(file_is("xz")); +} + +int main(void) +{ + td_set_sysinfo(&s_info); + td_init(&s_hal); /* no size answer: 80x25 */ + test_undo_by_word(); + test_changed_follows_the_save_point(); + test_new_edit_ends_redo(); + test_delete_runs(); + test_typing_over_a_selection(); + test_cut_copy_paste(); + test_paste_from_the_terminal(); + test_history_limits(); + test_close_asks_about_changes(); + return TD_TEST_RESULT(); +} diff --git a/tests/test_widgets.c b/tests/test_widgets.c new file mode 100644 index 0000000..693bf62 --- /dev/null +++ b/tests/test_widgets.c @@ -0,0 +1,551 @@ +/* + * test_widgets.c - text boxes, lists, scrollbars, checkboxes, buttons, + * focus, the message box and the input box, driven by synthetic events. + */ +#include "td_test.h" +#include "tinydesk/td.h" + +static uint32_t s_now = 10000; +static int fake_read(void *ctx) { (void)ctx; return -1; } +static int fake_write(void *ctx, const uint8_t *b, int n) { (void)ctx; (void)b; return n; } +static uint32_t fake_millis(void *ctx) { (void)ctx; return s_now; } +static void fake_sleep(void *ctx, uint32_t ms) { (void)ctx; s_now += ms; } +static const td_hal_t s_hal = { fake_read, fake_write, fake_millis, fake_sleep, NULL }; + +static void key(uint32_t k, uint8_t mods) +{ + td_event_t ev = { 0 }; + ev.type = TD_EV_KEY; + ev.key = k; + ev.mods = mods; + ev.time_ms = s_now; + td_wm_dispatch(&ev); +} + +static void type(const char *s) +{ + while (*s) key((uint8_t)*s++, 0); +} + +static void mouse(int action, int button, int x, int y) +{ + td_event_t ev = { 0 }; + ev.type = TD_EV_MOUSE; + ev.action = (uint8_t)action; + ev.button = (uint8_t)button; + ev.x = (int16_t)x; + ev.y = (int16_t)y; + ev.time_ms = s_now; + s_now += 500; /* never a double click */ + td_wm_dispatch(&ev); +} + +static void click(int x, int y) +{ + mouse(TD_MOUSE_PRESS, TD_BUTTON_LEFT, x, y); + mouse(TD_MOUSE_RELEASE, TD_BUTTON_LEFT, x, y); +} + +static void double_click(int x, int y) +{ + td_event_t ev = { 0 }; + ev.type = TD_EV_MOUSE; + ev.button = TD_BUTTON_LEFT; + ev.x = (int16_t)x; + ev.y = (int16_t)y; + for (int i = 0; i < 2; i++) { + ev.time_ms = s_now; + ev.action = TD_MOUSE_PRESS; + td_wm_dispatch(&ev); + ev.action = TD_MOUSE_RELEASE; + td_wm_dispatch(&ev); + s_now += 100; /* well inside the double-click time */ + } + s_now += 1000; +} + +/* A bracketed paste, as a terminal sends it, through the input parser. */ +static void paste(const char *text) +{ + static td_input_t in; + td_input_init(&in); + const char *parts[3] = { "\x1b[200~", text, "\x1b[201~" }; + for (int i = 0; i < 3; i++) + for (const char *p = parts[i]; *p; p++) td_input_feed(&in, (uint8_t)*p, s_now); + td_event_t ev; + while (td_event_pop(&ev)) { + td_wm_dispatch(&ev); + if (ev.type == TD_EV_PASTE) td_input_paste_done(&in); + } +} + +static bool text_is(const td_widget_t *w, const char *expect) +{ + const char *got = td_widget_text(w); + if (strcmp(got, expect) == 0) return true; + printf(" text: \"%s\", expected \"%s\"\n", got, expect); + return false; +} + +static td_buffer_t s_back; +static const td_cell_t *cell(int x, int y) { return td_buffer_cell(&s_back, x, y); } + +/* A fresh desktop with one 40x12 window; its client area starts at (1, 1). */ +static td_window_t *new_window(void) +{ + td_wm_init(80, 25); + td_window_desc_t d = { .title = "W", .rect = td_rect(0, 0, 40, 12), .flags = TD_WIN_DEFAULT }; + return td_win_create(&d); +} + +static td_widget_t *find_button(td_window_t *w, const char *caption) +{ + for (td_widget_t *wd = w->widgets; wd; wd = wd->next) + if (wd->type == TD_WT_BUTTON && strcmp(td_widget_text(wd), caption) == 0) return wd; + return NULL; +} + +static int s_activated; +static void on_activate(td_widget_t *w, void *user) { (void)w; (void)user; s_activated++; } + +/* ------------------------------------------------------------ text box */ + +static void test_textbox(void) +{ + td_window_t *w = new_window(); + td_widget_t *tb = td_textbox(w, 0, 0, 10, 20, on_activate, NULL); + CHECK(w->focus == tb); + + type("hello"); + CHECK(text_is(tb, "hello")); + CHECK_EQ(tb->value, 5); /* the cursor follows */ + + /* Insert in the middle, delete at both ends. */ + key(TD_KEY_LEFT, 0); + key(TD_KEY_LEFT, 0); + type("XY"); + CHECK(text_is(tb, "helXYlo")); + key(TD_KEY_HOME, 0); + key(TD_KEY_DELETE, 0); + key(TD_KEY_END, 0); + key(TD_KEY_BACKSPACE, 0); + CHECK(text_is(tb, "elXYl")); + + /* Nothing to delete before the start or after the end. */ + key(TD_KEY_HOME, 0); + key(TD_KEY_BACKSPACE, 0); + CHECK_EQ(tb->value, 0); + key(TD_KEY_END, 0); + key(TD_KEY_DELETE, 0); + CHECK(text_is(tb, "elXYl")); + + /* Ctrl and Alt combinations are not typed. */ + key('a', TD_MOD_CTRL); + key('b', TD_MOD_ALT); + CHECK(text_is(tb, "elXYl")); + + /* The length limit holds, and the view scrolls to keep the cursor in + * the box. */ + type("0123456789abcdefghij"); + CHECK_EQ((int)strlen(td_widget_text(tb)), 20); + CHECK_EQ(tb->scroll, 20 - 10 + 1); + key(TD_KEY_HOME, 0); + CHECK_EQ(tb->scroll, 0); + + s_activated = 0; + key(TD_KEY_ENTER, 0); + CHECK_EQ(s_activated, 1); + + /* Setting the text puts the cursor at its end. */ + td_widget_set_text(tb, "abc"); + CHECK(text_is(tb, "abc")); + CHECK_EQ(tb->value, 3); + CHECK_EQ(tb->scroll, 0); + + /* A click places the cursor; past the end it goes to the end. */ + td_rect_t r = td_widget_rect(tb); + click(r.x + 1, r.y); + CHECK_EQ(tb->value, 1); + click(r.x + 8, r.y); + CHECK_EQ(tb->value, 3); +} + +static void test_textbox_own_buffer(void) +{ + td_window_t *w = new_window(); + td_widget_t *tb = td_textbox(w, 0, 0, 30, 10, NULL, NULL); + static char url[100] = "https://"; + td_textbox_set_buffer(tb, url, (int)sizeof(url)); + CHECK(td_widget_text(tb) == url); + CHECK_EQ(tb->value, 8); + + /* Longer than TD_TEXT_MAX, up to the buffer's size. */ + for (int i = 0; i < 120; i++) type("x"); + CHECK_EQ((int)strlen(url), (int)sizeof(url) - 1); +} + +static void test_textbox_paste(void) +{ + td_window_t *w = new_window(); + td_widget_t *tb = td_textbox(w, 0, 0, 10, 8, NULL, NULL); + type("<>"); + key(TD_KEY_LEFT, 0); + + /* Only the first line; a tab becomes a space, control characters go. */ + paste("ab\tc\x01" "d\r\nsecond line"); + CHECK(text_is(tb, "")); + + /* Up to the length limit. */ + paste("123456"); + CHECK(text_is(tb, "")); +} + +/* ---------------------------------------------------------------- list */ + +static char s_item[16]; +static const char *list_item(td_widget_t *w, int index, int *fg, void *user) +{ + (void)w; + (void)fg; + (void)user; + snprintf(s_item, sizeof(s_item), "item %d", index); + return s_item; +} + +static void test_list(void) +{ + td_window_t *w = new_window(); + td_widget_t *l = td_list(w, td_rect(0, 0, 20, 5), list_item, on_activate, NULL); + + /* An empty list has no selection, and Enter does nothing. */ + CHECK_EQ(td_list_selected(l), -1); + s_activated = 0; + key(TD_KEY_ENTER, 0); + CHECK_EQ(s_activated, 0); + + td_list_set_count(l, 50); + CHECK_EQ(td_list_selected(l), 0); + + /* The keys move the selection, and the view follows it. */ + for (int i = 0; i < 3; i++) key(TD_KEY_DOWN, 0); + CHECK_EQ(td_list_selected(l), 3); + CHECK_EQ(l->scroll, 0); + key(TD_KEY_PGDN, 0); + CHECK_EQ(td_list_selected(l), 8); + CHECK_EQ(l->scroll, 4); + key(TD_KEY_END, 0); + CHECK_EQ(td_list_selected(l), 49); + CHECK_EQ(l->scroll, 45); + key(TD_KEY_PGUP, 0); + CHECK_EQ(td_list_selected(l), 44); + CHECK_EQ(l->scroll, 44); + key(TD_KEY_HOME, 0); + CHECK_EQ(td_list_selected(l), 0); + CHECK_EQ(l->scroll, 0); + key(TD_KEY_UP, 0); + CHECK_EQ(td_list_selected(l), 0); + td_list_select(l, 100); + CHECK_EQ(td_list_selected(l), 49); + + /* Shrinking the list keeps the selection and the view inside it. */ + td_list_set_count(l, 10); + CHECK_EQ(td_list_selected(l), 9); + CHECK_EQ(l->scroll, 5); + + /* The wheel scrolls without moving the selection. */ + td_list_select(l, 0); + td_rect_t r = td_widget_rect(l); + mouse(TD_MOUSE_PRESS, TD_BUTTON_WHEEL_DOWN, r.x + 1, r.y + 1); + CHECK_EQ(l->scroll, 3); + mouse(TD_MOUSE_PRESS, TD_BUTTON_WHEEL_DOWN, r.x + 1, r.y + 1); + CHECK_EQ(l->scroll, 5); + mouse(TD_MOUSE_PRESS, TD_BUTTON_WHEEL_UP, r.x + 1, r.y + 1); + CHECK_EQ(l->scroll, 2); + CHECK_EQ(td_list_selected(l), 0); + + /* A click selects the row under it; a double click opens it. */ + s_activated = 0; + click(r.x + 1, r.y + 2); + CHECK_EQ(td_list_selected(l), 4); + CHECK_EQ(s_activated, 0); + double_click(r.x + 1, r.y + 3); + CHECK_EQ(td_list_selected(l), 5); + CHECK_EQ(s_activated, 1); + + /* The selected row is drawn highlighted. */ + td_wm_compose(&s_back); + CHECK_EQ(cell(r.x, r.y + 3)->bg, td_theme()->select_bg); + CHECK_EQ(cell(r.x + 1, r.y)->ch, 'i'); +} + +static void test_scrollbar(void) +{ + td_window_t *w = new_window(); + td_widget_t *l = td_list(w, td_rect(0, 0, 20, 5), list_item, NULL, NULL); + td_widget_t *sb = td_scrollbar(w, 20, 0, 5, l); + td_list_set_count(l, 50); + td_rect_t r = td_widget_rect(sb); + + click(r.x, r.y + 4); /* down arrow */ + CHECK_EQ(l->scroll, 1); + click(r.x, r.y); /* up arrow */ + CHECK_EQ(l->scroll, 0); + click(r.x, r.y + 3); /* bottom of the track */ + CHECK_EQ(l->scroll, 45); + click(r.x, r.y + 1); /* top of the track */ + CHECK_EQ(l->scroll, 0); + + /* Dragging along the track scrolls with the mouse. */ + mouse(TD_MOUSE_PRESS, TD_BUTTON_LEFT, r.x, r.y + 1); + mouse(TD_MOUSE_DRAG, TD_BUTTON_LEFT, r.x, r.y + 3); + CHECK_EQ(l->scroll, 45); + mouse(TD_MOUSE_RELEASE, TD_BUTTON_LEFT, r.x, r.y + 3); + + /* The scrollbar never moves the selection. */ + CHECK_EQ(td_list_selected(l), 0); +} + +/* ------------------------------------------ checkbox, button, progress */ + +static int s_toggles; +static void on_toggle(td_widget_t *w, void *user) { (void)w; (void)user; s_toggles++; } + +static void test_checkbox_button_progress(void) +{ + td_window_t *w = new_window(); + td_widget_t *cb = td_checkbox(w, 0, 0, "Option", false, on_toggle, NULL); + td_widget_t *btn = td_button(w, 0, 2, "Go", on_activate, NULL); + + /* Space and a click toggle a checkbox; td_checkbox_set does not report. */ + s_toggles = 0; + key(' ', 0); + CHECK(td_checkbox_get(cb)); + td_rect_t r = td_widget_rect(cb); + click(r.x + 5, r.y); + CHECK(!td_checkbox_get(cb)); + CHECK_EQ(s_toggles, 2); + td_checkbox_set(cb, true); + CHECK(td_checkbox_get(cb)); + CHECK_EQ(s_toggles, 2); + + /* A button fires on release, and only if the mouse is still on it. */ + s_activated = 0; + r = td_widget_rect(btn); + mouse(TD_MOUSE_PRESS, TD_BUTTON_LEFT, r.x + 1, r.y); + CHECK(btn->pressed); + CHECK_EQ(s_activated, 0); + mouse(TD_MOUSE_DRAG, TD_BUTTON_LEFT, r.x + 20, r.y + 5); + CHECK(!btn->pressed); + mouse(TD_MOUSE_RELEASE, TD_BUTTON_LEFT, r.x + 20, r.y + 5); + CHECK_EQ(s_activated, 0); + click(r.x + 1, r.y); + CHECK_EQ(s_activated, 1); + key(' ', 0); /* Space works like Enter */ + CHECK_EQ(s_activated, 2); + + /* A new caption resizes the button: "[ caption ]". */ + td_widget_set_text(btn, "Longer"); + CHECK_EQ(btn->rect.w, 6 + 4); + + /* Progress stays within 0..100 %. */ + td_widget_t *p = td_progress(w, 0, 4, 10); + td_progress_set(p, 150); + CHECK_EQ(p->value, 100); + td_progress_set(p, -5); + CHECK_EQ(p->value, 0); + td_progress_set(p, 50); + td_wm_compose(&s_back); + r = td_widget_rect(p); + CHECK_EQ(cell(r.x + 4, r.y)->ch, 0x2588); /* five of ten cells filled */ + CHECK_EQ(cell(r.x + 5, r.y)->ch, 0x2591); +} + +/* --------------------------------------------------------------- focus */ + +static void test_focus(void) +{ + td_window_t *w = new_window(); + td_widget_t *label = td_label(w, 0, 0, 10, "Label"); + td_widget_t *a = td_button(w, 0, 1, "A", NULL, NULL); + td_widget_t *b = td_checkbox(w, 0, 2, "B", false, NULL, NULL); + td_widget_t *c = td_textbox(w, 0, 3, 10, 10, NULL, NULL); + + /* Labels never take the focus; Tab follows creation order and wraps. */ + CHECK(w->focus == a); + td_widget_focus(label); + CHECK(w->focus == a); + key(TD_KEY_TAB, 0); + CHECK(w->focus == b); + key(TD_KEY_TAB, 0); + CHECK(w->focus == c); + key(TD_KEY_TAB, 0); + CHECK(w->focus == a); + key(TD_KEY_TAB, TD_MOD_SHIFT); + CHECK(w->focus == c); + + /* Hidden widgets are skipped; hiding the focused one moves the focus on. */ + td_widget_set_visible(b, false); + key(TD_KEY_TAB, 0); + CHECK(w->focus == a); + key(TD_KEY_TAB, 0); + CHECK(w->focus == c); + td_widget_set_visible(c, false); + CHECK(w->focus == a); +} + +/* ---------------------------------------------------------- dialogs */ + +static int s_answer; +static void on_answer(int button, void *user) { (void)user; s_answer = button; } + +static td_window_t *s_next; +static void on_answer_open_next(int button, void *user) +{ + (void)user; + if (button == 0) s_next = td_msgbox("Next", "Second question", "OK", NULL, NULL); +} + +static td_window_t *ask(void) { return td_msgbox("Q", "Line one\nLine two", "Yes|No|Cancel", on_answer, NULL); } + +static void test_msgbox(void) +{ + td_window_t *w = new_window(); + + /* Enter chooses the focused button, the first one. */ + s_answer = -2; + td_window_t *m = ask(); + CHECK(m && (m->flags & TD_WIN_MODAL)); + CHECK(td_win_focused() == m); + key(TD_KEY_ENTER, 0); + CHECK_EQ(s_answer, 0); + CHECK(!td_win_is_open(m)); + CHECK(td_win_focused() == w); + + /* Tab moves to the next button. */ + ask(); + key(TD_KEY_TAB, 0); + key(TD_KEY_ENTER, 0); + CHECK_EQ(s_answer, 1); + + /* A click on a button. */ + m = ask(); + td_rect_t r = td_widget_rect(find_button(m, "Cancel")); + click(r.x + 1, r.y); + CHECK_EQ(s_answer, 2); + CHECK(!td_win_is_open(m)); + + /* Esc and the [x] button answer -1. */ + s_answer = -2; + m = ask(); + key(TD_KEY_ESC, 0); + CHECK_EQ(s_answer, -1); + s_answer = -2; + m = ask(); + click(m->rect.x + m->rect.w - 3, m->rect.y); + CHECK_EQ(s_answer, -1); + CHECK(!td_win_is_open(m)); + + /* While it is open, the window behind cannot take the focus. */ + m = ask(); + click(w->rect.x + 5, w->rect.y + 5); + CHECK(td_win_focused() == m); + key(TD_KEY_ESC, 0); + + /* The box is closed before its callback runs, so the callback can open + * the next one. */ + s_next = NULL; + td_msgbox("Q", "First question", "OK", on_answer_open_next, NULL); + key(TD_KEY_ENTER, 0); + CHECK(td_win_is_open(s_next)); + CHECK(td_win_focused() == s_next); +} + +static char s_entered[64]; +static int s_entries; +static void on_text(const char *text, void *user) +{ + (void)user; + snprintf(s_entered, sizeof(s_entered), "%s", text); + s_entries++; +} + +static void test_inputbox(void) +{ + new_window(); + s_entries = 0; + + /* It starts with the given text; Enter hands over the text and closes. */ + td_window_t *ib = td_inputbox("Name", "Your name:", "Bo", on_text, NULL); + CHECK(ib && td_win_focused() == ib); + type("b"); + key(TD_KEY_ENTER, 0); + CHECK(strcmp(s_entered, "Bob") == 0); + CHECK_EQ(s_entries, 1); + CHECK(!td_win_is_open(ib)); + + /* OK does the same. */ + ib = td_inputbox("Name", "Your name:", "Al", on_text, NULL); + td_rect_t r = td_widget_rect(find_button(ib, "OK")); + click(r.x + 1, r.y); + CHECK(strcmp(s_entered, "Al") == 0); + CHECK_EQ(s_entries, 2); + + /* Cancel and Esc hand over nothing. */ + ib = td_inputbox("Name", "Your name:", "", on_text, NULL); + r = td_widget_rect(find_button(ib, "Cancel")); + click(r.x + 1, r.y); + CHECK(!td_win_is_open(ib)); + ib = td_inputbox("Name", "Your name:", "", on_text, NULL); + key(TD_KEY_ESC, 0); + CHECK(!td_win_is_open(ib)); + CHECK_EQ(s_entries, 2); + + /* A password box shows stars, never the text. */ + ib = td_passwordbox("Login", "Password:", on_text, NULL); + type("pw"); + td_wm_compose(&s_back); + r = td_widget_rect(ib->focus); + CHECK_EQ(cell(r.x, r.y)->ch, '*'); + CHECK_EQ(cell(r.x + 1, r.y)->ch, '*'); + key(TD_KEY_ENTER, 0); + CHECK(strcmp(s_entered, "pw") == 0); +} + +/* ------------------------------------------------------------- pools */ + +static void test_pools_keep_room_for_a_message_box(void) +{ + /* The last window slot is kept for a message box. */ + td_wm_init(80, 25); + td_window_desc_t d = { .title = "W", .rect = td_rect(0, 0, 20, 5), .flags = TD_WIN_DEFAULT }; + int opened = 0; + while (opened <= TD_MAX_WINDOWS && td_win_create(&d)) opened++; + CHECK_EQ(opened, TD_MAX_WINDOWS - 1); + CHECK(td_msgbox("Full", "Too many windows", "OK", NULL, NULL) != NULL); + + /* A window that wants more widgets than the shared pool can spare is + * marked incomplete, and a message box still gets its widgets. */ + td_window_t *w = new_window(); + int made = 0; + while (made <= TD_MAX_WIDGETS && td_label(w, 0, 0, 5, "x")) made++; + CHECK(w->incomplete); + CHECK(made > 0 && made < TD_MAX_WIDGETS); + td_window_t *m = td_msgbox("Full", "No widgets left", "OK", NULL, NULL); + CHECK(m != NULL && find_button(m, "OK") != NULL); +} + +int main(void) +{ + td_init(&s_hal); /* no size answer: 80x25 */ + test_textbox(); + test_textbox_own_buffer(); + test_textbox_paste(); + test_list(); + test_scrollbar(); + test_checkbox_button_progress(); + test_focus(); + test_msgbox(); + test_inputbox(); + test_pools_keep_room_for_a_message_box(); + return TD_TEST_RESULT(); +} From 0412d17a0b03af2eafdada60792b14d89fb03d49 Mon Sep 17 00:00:00 2001 From: TharunBabu-05 Date: Sun, 4 Oct 2026 09:32:51 +0530 Subject: [PATCH 2/2] Editor: keep the file name when saving save() passes E.path to save_to(), which copied it onto itself with snprintf. That is undefined: glibc empties the string, so on Linux the title turned "untitled" after the first Ctrl+S and the next one asked for a name (Save as). Found by the new Editor test on the Linux CI runner; test_save_twice now checks it directly. --- apps/editor.c | 4 +++- tests/test_editor.c | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/editor.c b/apps/editor.c index a2722c8..d137cf6 100644 --- a/apps/editor.c +++ b/apps/editor.c @@ -596,7 +596,9 @@ static bool save_to(const char *path) set_status("Save failed"); return false; } - snprintf(E.path, sizeof(E.path), "%s", path); + /* save() passes E.path itself; copying a string onto itself is + * undefined (glibc empties it, and the next save asks for a name). */ + if (path != E.path) snprintf(E.path, sizeof(E.path), "%s", path); if (E.undo) { E.undo->saved = E.undo->top; E.undo->merge = false; /* typing after a save is a new step */ diff --git a/tests/test_editor.c b/tests/test_editor.c index 0083238..166e7f8 100644 --- a/tests/test_editor.c +++ b/tests/test_editor.c @@ -197,6 +197,21 @@ static void test_changed_follows_the_save_point(void) CHECK(!changed()); } +static void test_save_twice(void) +{ + /* The file keeps its name after a save: the next one writes it again + * instead of asking for a name. */ + open_file("1"); + key(TD_KEY_END, 0); + type("2"); + ctrl('s'); + CHECK(strncmp(s_ed->title, "a.txt", 5) == 0); + type("3"); + ctrl('s'); + CHECK(td_win_focused() == s_ed); + CHECK(file_is("123")); +} + static void test_new_edit_ends_redo(void) { open_file(""); @@ -389,6 +404,7 @@ int main(void) td_init(&s_hal); /* no size answer: 80x25 */ test_undo_by_word(); test_changed_follows_the_save_point(); + test_save_twice(); test_new_edit_ends_redo(); test_delete_runs(); test_typing_over_a_selection();