Skip to content

Commit 8080670

Browse files
committed
ui(menu): add submenu indicators, cursor editing, and chooser key consistency
1 parent 5668e6a commit 8080670

4 files changed

Lines changed: 261 additions & 19 deletions

File tree

src/ui/terminal/menu_prompts.c

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ typedef struct {
3333
char* buf;
3434
size_t cap;
3535
size_t len;
36+
size_t cursor; // cursor position within buf [0..len]
3637
void (*on_done_str)(void* user, const char* text); // NULL text indicates cancel
3738
void* user;
3839
} UiPrompt;
@@ -247,6 +248,7 @@ ui_prompt_open_string_async(const char* title, const char* prefill, size_t cap,
247248
strncpy(g_prompt.buf, prefill, cap - 1);
248249
g_prompt.buf[cap - 1] = '\0';
249250
g_prompt.len = strlen(g_prompt.buf);
251+
g_prompt.cursor = g_prompt.len;
250252
}
251253
}
252254

@@ -379,9 +381,39 @@ ui_prompt_handle_key(int ch) {
379381
}
380382
return 1;
381383
}
384+
if (ch == KEY_LEFT) {
385+
if (g_prompt.cursor > 0) {
386+
g_prompt.cursor--;
387+
}
388+
return 1;
389+
}
390+
if (ch == KEY_RIGHT) {
391+
if (g_prompt.cursor < g_prompt.len) {
392+
g_prompt.cursor++;
393+
}
394+
return 1;
395+
}
396+
if (ch == KEY_HOME) {
397+
g_prompt.cursor = 0;
398+
return 1;
399+
}
400+
if (ch == KEY_END) {
401+
g_prompt.cursor = g_prompt.len;
402+
return 1;
403+
}
382404
if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
383-
if (g_prompt.len > 0) {
384-
g_prompt.buf[--g_prompt.len] = '\0';
405+
if (g_prompt.cursor > 0) {
406+
memmove(g_prompt.buf + g_prompt.cursor - 1, g_prompt.buf + g_prompt.cursor,
407+
g_prompt.len - g_prompt.cursor + 1);
408+
g_prompt.cursor--;
409+
g_prompt.len--;
410+
}
411+
return 1;
412+
}
413+
if (ch == KEY_DC) {
414+
if (g_prompt.cursor < g_prompt.len) {
415+
memmove(g_prompt.buf + g_prompt.cursor, g_prompt.buf + g_prompt.cursor + 1, g_prompt.len - g_prompt.cursor);
416+
g_prompt.len--;
385417
}
386418
return 1;
387419
}
@@ -407,8 +439,10 @@ ui_prompt_handle_key(int ch) {
407439
}
408440
if (isprint(ch)) {
409441
if (g_prompt.len + 1 < g_prompt.cap) {
410-
g_prompt.buf[g_prompt.len++] = (char)ch;
411-
g_prompt.buf[g_prompt.len] = '\0';
442+
memmove(g_prompt.buf + g_prompt.cursor + 1, g_prompt.buf + g_prompt.cursor,
443+
g_prompt.len - g_prompt.cursor + 1);
444+
g_prompt.buf[g_prompt.cursor++] = (char)ch;
445+
g_prompt.len++;
412446
}
413447
return 1;
414448
}
@@ -520,25 +554,41 @@ ui_prompt_render(void) {
520554
field_width = 1;
521555
}
522556
size_t text_len = strlen(text);
557+
size_t cpos = g_prompt.cursor;
558+
if (cpos > text_len) {
559+
cpos = text_len;
560+
}
523561
size_t start = 0;
524562
int show_left_ellipsis = 0;
525-
int visible_chars = field_width;
526-
if ((int)text_len > field_width && field_width >= 4) {
527-
show_left_ellipsis = 1;
528-
visible_chars = field_width - 3;
529-
}
530-
if ((int)text_len > visible_chars) {
531-
start = text_len - (size_t)visible_chars;
563+
if ((int)text_len > field_width) {
564+
// Scroll viewport to keep cursor visible
565+
int usable = field_width;
566+
if (cpos > (size_t)usable) {
567+
start = cpos - (size_t)(usable - 1);
568+
}
569+
if (start > 0 && field_width >= 4) {
570+
show_left_ellipsis = 1;
571+
usable = field_width - 3;
572+
if (cpos > start + (size_t)usable) {
573+
start = cpos - (size_t)(usable - 1);
574+
}
575+
if (cpos < start) {
576+
start = cpos;
577+
}
578+
if (start == 0) {
579+
show_left_ellipsis = 0;
580+
}
581+
}
532582
}
533583
mvwaddnstr(win, input_y, 2, "> ", (w > 5) ? 2 : 1);
534584
if (show_left_ellipsis) {
535585
mvwaddnstr(win, input_y, field_col, "...", 3);
536-
mvwaddnstr(win, input_y, field_col + 3, text + start, visible_chars);
586+
mvwaddnstr(win, input_y, field_col + 3, text + start, field_width - 3);
537587
} else {
538588
mvwaddnstr(win, input_y, field_col, text + start, field_width);
539589
}
540590
int cursor_prefix = show_left_ellipsis ? 3 : 0;
541-
int cursor_x = field_col + cursor_prefix + (int)(text_len - start);
591+
int cursor_x = field_col + cursor_prefix + (int)(cpos - start);
542592
if (cursor_x > field_right) {
543593
cursor_x = field_right;
544594
}
@@ -641,12 +691,10 @@ ui_help_handle_key(int ch) {
641691
return 1;
642692
}
643693
if (ch == DSD_KEY_ESC || ch == 'q' || ch == 'Q' || ch == 'h' || ch == 'H' || ch == 10 || ch == KEY_ENTER
644-
|| ch == '\r') {
694+
|| ch == '\r' || ch == KEY_LEFT) {
645695
ui_help_close();
646696
return 1;
647697
}
648-
// Keep previous ergonomics: any non-navigation key closes help.
649-
ui_help_close();
650698
}
651699
return 1;
652700
}
@@ -870,11 +918,11 @@ ui_chooser_handle_key(int ch) {
870918
ui_chooser_keep_selection_visible();
871919
return 1;
872920
}
873-
if (ch == 'q' || ch == 'Q' || ch == DSD_KEY_ESC) {
921+
if (ch == 'q' || ch == 'Q' || ch == DSD_KEY_ESC || ch == KEY_LEFT) {
874922
ui_chooser_finish(-1);
875923
return 1;
876924
}
877-
if (ch == 10 || ch == KEY_ENTER || ch == '\r') {
925+
if (ch == 10 || ch == KEY_ENTER || ch == '\r' || ch == KEY_RIGHT) {
878926
int sel = g_chooser.sel;
879927
ui_chooser_finish(sel);
880928
return 1;
@@ -899,7 +947,7 @@ ui_chooser_render(void) {
899947
max_item = L;
900948
}
901949
}
902-
const char* footer = "Arrows/PgUp/PgDn/Home/End Enter Esc/q";
950+
const char* footer = "Arrows/PgUp/PgDn Right/Enter: select Esc/q/Left";
903951
int w = 4 + (int)strlen(title);
904952
int need = 4 + max_item;
905953
if (need > w) {

src/ui/terminal/menu_render.c

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,18 @@ ui_draw_menu(WINDOW* menu_win, const NcMenuItem* items, size_t n, int hi, int* t
145145
wattron(menu_win, A_REVERSE);
146146
}
147147
const char* lab = items[i].label ? items[i].label : items[i].id;
148+
char labfmt[140];
148149
if (items[i].label_fn) {
149150
char dyn[128];
150151
const char* got = items[i].label_fn(ctx, dyn, sizeof dyn);
151152
if (got && *got) {
152153
lab = got;
153154
}
154155
}
156+
if (items[i].submenu && items[i].submenu_len > 0) {
157+
snprintf(labfmt, sizeof labfmt, "%s >", lab);
158+
lab = labfmt;
159+
}
155160
mvwaddnstr(menu_win, y, x, lab, (mw > 4) ? (mw - 4) : 1);
156161
wattroff(menu_win, A_REVERSE);
157162
vis_pos++;
@@ -223,6 +228,9 @@ ui_visible_count_and_maxlab(const NcMenuItem* items, size_t n, void* ctx, int* o
223228
}
224229
}
225230
int L = (int)strlen(lab);
231+
if (items[i].submenu && items[i].submenu_len > 0) {
232+
L += 2; // " >" suffix
233+
}
226234
if (L > maxlab) {
227235
maxlab = L;
228236
}

tests/CMakeLists.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,16 @@ target_include_directories(dsd-neo_test_ui_chooser_navigation PRIVATE
356356
target_link_libraries(dsd-neo_test_ui_chooser_navigation PRIVATE dsd-neo_test_support ${CURSES_LIBRARIES})
357357
add_test(NAME UI_CHOOSER_NAVIGATION COMMAND dsd-neo_test_ui_chooser_navigation)
358358

359+
add_executable(dsd-neo_test_ui_prompt_cursor
360+
ui/test_ui_prompt_cursor.c
361+
${PROJECT_SOURCE_DIR}/src/ui/terminal/menu_prompts.c)
362+
target_include_directories(dsd-neo_test_ui_prompt_cursor PRIVATE
363+
${PROJECT_SOURCE_DIR}/include
364+
${PROJECT_SOURCE_DIR}/src/ui/terminal
365+
${CURSES_INCLUDE_DIRS})
366+
target_link_libraries(dsd-neo_test_ui_prompt_cursor PRIVATE dsd-neo_test_support ${CURSES_LIBRARIES})
367+
add_test(NAME UI_PROMPT_CURSOR COMMAND dsd-neo_test_ui_prompt_cursor)
368+
359369
add_executable(dsd-neo_test_ui_history_state
360370
ui/test_ui_history_state.c
361371
${PROJECT_SOURCE_DIR}/src/ui/terminal/ui_history.c)

tests/ui/test_ui_prompt_cursor.c

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
/*
3+
* Copyright (C) 2026 by arancormonk <180709949+arancormonk@users.noreply.github.com>
4+
*/
5+
6+
/*
7+
* Regression coverage for prompt cursor movement:
8+
* - LEFT/RIGHT should move cursor within text
9+
* - Home/End should jump to start/end
10+
* - Backspace should delete before cursor
11+
* - Delete should delete at cursor
12+
* - Insert should occur at cursor position
13+
*/
14+
15+
#include <assert.h>
16+
#include <curses.h>
17+
#include <locale.h>
18+
#include <stdio.h>
19+
#include <string.h>
20+
21+
#include "menu_prompts.h"
22+
#include "test_support.h"
23+
24+
static SCREEN* g_screen = NULL;
25+
static FILE* g_in = NULL;
26+
static FILE* g_out = NULL;
27+
static int g_done_called = 0;
28+
static char g_done_text[256];
29+
30+
static void
31+
capture_done(void* user, const char* text) {
32+
(void)user;
33+
g_done_called = 1;
34+
if (text) {
35+
strncpy(g_done_text, text, sizeof g_done_text - 1);
36+
g_done_text[sizeof g_done_text - 1] = '\0';
37+
} else {
38+
g_done_text[0] = '\0';
39+
}
40+
}
41+
42+
WINDOW*
43+
ui_make_window(int h, int w, int y, int x) {
44+
return newwin(h, w, y, x);
45+
}
46+
47+
void
48+
ui_statusf(const char* fmt, ...) {
49+
(void)fmt;
50+
}
51+
52+
static void
53+
init_screen(void) {
54+
setlocale(LC_ALL, "");
55+
assert(dsd_test_setenv("TERM", "xterm-256color", 0) == 0);
56+
g_in = tmpfile();
57+
g_out = tmpfile();
58+
assert(g_in != NULL);
59+
assert(g_out != NULL);
60+
g_screen = newterm(NULL, g_out, g_in);
61+
assert(g_screen != NULL);
62+
set_term(g_screen);
63+
noecho();
64+
cbreak();
65+
keypad(stdscr, TRUE);
66+
resizeterm(24, 80);
67+
clear();
68+
refresh();
69+
}
70+
71+
static void
72+
shutdown_screen(void) {
73+
if (g_screen) {
74+
endwin();
75+
delscreen(g_screen);
76+
g_screen = NULL;
77+
}
78+
if (g_in) {
79+
fclose(g_in);
80+
g_in = NULL;
81+
}
82+
if (g_out) {
83+
fclose(g_out);
84+
g_out = NULL;
85+
}
86+
}
87+
88+
int
89+
main(void) {
90+
init_screen();
91+
92+
// Test: insert at cursor after moving LEFT
93+
g_done_called = 0;
94+
ui_prompt_open_string_async("Test", "abc", 64, capture_done, NULL);
95+
assert(ui_prompt_active() == 1);
96+
97+
// Move cursor left twice: cursor at position 1 (between 'a' and 'b')
98+
assert(ui_prompt_handle_key(KEY_LEFT) == 1);
99+
assert(ui_prompt_handle_key(KEY_LEFT) == 1);
100+
101+
// Insert 'X' at cursor position 1
102+
assert(ui_prompt_handle_key('X') == 1);
103+
104+
// Submit and verify
105+
assert(ui_prompt_handle_key('\r') == 1);
106+
assert(g_done_called == 1);
107+
assert(strcmp(g_done_text, "aXbc") == 0);
108+
109+
// Test: Home moves to start, type inserts at front
110+
g_done_called = 0;
111+
ui_prompt_open_string_async("Test", "def", 64, capture_done, NULL);
112+
assert(ui_prompt_handle_key(KEY_HOME) == 1);
113+
assert(ui_prompt_handle_key('Z') == 1);
114+
assert(ui_prompt_handle_key('\r') == 1);
115+
assert(g_done_called == 1);
116+
assert(strcmp(g_done_text, "Zdef") == 0);
117+
118+
// Test: End moves to end after Home
119+
g_done_called = 0;
120+
ui_prompt_open_string_async("Test", "gh", 64, capture_done, NULL);
121+
assert(ui_prompt_handle_key(KEY_HOME) == 1);
122+
assert(ui_prompt_handle_key(KEY_END) == 1);
123+
assert(ui_prompt_handle_key('i') == 1);
124+
assert(ui_prompt_handle_key('\r') == 1);
125+
assert(g_done_called == 1);
126+
assert(strcmp(g_done_text, "ghi") == 0);
127+
128+
// Test: Backspace deletes before cursor in middle
129+
g_done_called = 0;
130+
ui_prompt_open_string_async("Test", "abcd", 64, capture_done, NULL);
131+
assert(ui_prompt_handle_key(KEY_LEFT) == 1); // cursor at 3
132+
assert(ui_prompt_handle_key(KEY_BACKSPACE) == 1); // delete 'c'
133+
assert(ui_prompt_handle_key('\r') == 1);
134+
assert(g_done_called == 1);
135+
assert(strcmp(g_done_text, "abd") == 0);
136+
137+
// Test: Delete key removes character at cursor
138+
g_done_called = 0;
139+
ui_prompt_open_string_async("Test", "wxyz", 64, capture_done, NULL);
140+
assert(ui_prompt_handle_key(KEY_HOME) == 1); // cursor at 0
141+
assert(ui_prompt_handle_key(KEY_DC) == 1); // delete 'w'
142+
assert(ui_prompt_handle_key('\r') == 1);
143+
assert(g_done_called == 1);
144+
assert(strcmp(g_done_text, "xyz") == 0);
145+
146+
// Test: Delete at end of string does nothing
147+
g_done_called = 0;
148+
ui_prompt_open_string_async("Test", "ab", 64, capture_done, NULL);
149+
assert(ui_prompt_handle_key(KEY_DC) == 1); // cursor at end, no-op
150+
assert(ui_prompt_handle_key('\r') == 1);
151+
assert(g_done_called == 1);
152+
assert(strcmp(g_done_text, "ab") == 0);
153+
154+
// Test: LEFT at position 0 stays at 0
155+
g_done_called = 0;
156+
ui_prompt_open_string_async("Test", "a", 64, capture_done, NULL);
157+
assert(ui_prompt_handle_key(KEY_HOME) == 1);
158+
assert(ui_prompt_handle_key(KEY_LEFT) == 1); // should stay at 0
159+
assert(ui_prompt_handle_key('B') == 1);
160+
assert(ui_prompt_handle_key('\r') == 1);
161+
assert(g_done_called == 1);
162+
assert(strcmp(g_done_text, "Ba") == 0);
163+
164+
// Test: RIGHT at end stays at end
165+
g_done_called = 0;
166+
ui_prompt_open_string_async("Test", "c", 64, capture_done, NULL);
167+
assert(ui_prompt_handle_key(KEY_RIGHT) == 1); // at end, no-op
168+
assert(ui_prompt_handle_key('D') == 1);
169+
assert(ui_prompt_handle_key('\r') == 1);
170+
assert(g_done_called == 1);
171+
assert(strcmp(g_done_text, "cD") == 0);
172+
173+
shutdown_screen();
174+
printf("UI_PROMPT_CURSOR: OK\n");
175+
return 0;
176+
}

0 commit comments

Comments
 (0)