Skip to content

Commit 130d9f9

Browse files
committed
ui(menu): add paged ncurses navigation and chooser scrolling
1 parent 4919832 commit 130d9f9

9 files changed

Lines changed: 1163 additions & 79 deletions

docs/ui-terminal.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ processed).
2020

2121
Common controls:
2222

23-
- Navigate: arrow keys (`Up`/`Down`)
24-
- Select: `Enter`
25-
- Back/close: `Esc`, `q`
26-
- Scroll long lists: `PageUp`/`PageDown`, `Home`/`End` (where supported by that prompt)
23+
- Move selection: arrow keys (`Up`/`Down`), `PageUp`/`PageDown`, `Home`/`End`
24+
- Select / open submenu: `Enter`, `Right`
25+
- Back / close: `Esc`, `q`, `Left`
26+
- Item help: `h`
2727

2828
## Hotkeys (Main Screen)
2929

src/ui/terminal/menu_core.c

Lines changed: 174 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,85 @@ static UiMenuFrame g_stack[8];
4343
static int g_depth = 0;
4444
static UiCtx g_ctx_overlay = {0};
4545

46+
static int
47+
ui_frame_items_rows(const UiMenuFrame* f) {
48+
int rows = 1;
49+
if (f && f->h > 7) {
50+
rows = f->h - 7;
51+
}
52+
if (rows < 1) {
53+
rows = 1;
54+
}
55+
return rows;
56+
}
57+
58+
static int
59+
ui_first_enabled_idx(const NcMenuItem* items, size_t n) {
60+
if (!items || n == 0) {
61+
return 0;
62+
}
63+
for (size_t i = 0; i < n; i++) {
64+
if (ui_is_enabled(&items[i], &g_ctx_overlay)) {
65+
return (int)i;
66+
}
67+
}
68+
return 0;
69+
}
70+
71+
static int
72+
ui_last_enabled_idx(const NcMenuItem* items, size_t n) {
73+
if (!items || n == 0) {
74+
return 0;
75+
}
76+
for (size_t i = n; i > 0; i--) {
77+
if (ui_is_enabled(&items[i - 1], &g_ctx_overlay)) {
78+
return (int)(i - 1);
79+
}
80+
}
81+
return 0;
82+
}
83+
84+
static int
85+
ui_step_enabled(const NcMenuItem* items, size_t n, int from, int dir, int steps) {
86+
if (!items || n == 0) {
87+
return 0;
88+
}
89+
if (steps < 1) {
90+
steps = 1;
91+
}
92+
const int delta = (dir < 0) ? -1 : 1;
93+
int idx = from;
94+
for (int i = 0; i < steps; i++) {
95+
int next = idx;
96+
while (1) {
97+
next += delta;
98+
if (next < 0 || next >= (int)n) {
99+
next = idx;
100+
break;
101+
}
102+
if (ui_is_enabled(&items[next], &g_ctx_overlay)) {
103+
break;
104+
}
105+
}
106+
if (next == idx) {
107+
break;
108+
}
109+
idx = next;
110+
}
111+
return idx;
112+
}
113+
114+
static void
115+
ui_frame_keep_highlight_visible(UiMenuFrame* f) {
116+
if (!f || !f->items || f->n == 0) {
117+
return;
118+
}
119+
int page_rows = ui_frame_items_rows(f);
120+
int vis_total = ui_visible_count_and_maxlab(f->items, f->n, &g_ctx_overlay, NULL);
121+
int hi_pos = ui_visible_index_for_item(f->items, f->n, &g_ctx_overlay, f->hi);
122+
f->top = ui_scroll_follow_selection(vis_total, page_rows, f->top, hi_pos);
123+
}
124+
46125
static void
47126
ui_overlay_breadcrumb(char* buf, size_t n) {
48127
if (!buf || n == 0) {
@@ -80,6 +159,65 @@ ui_overlay_close_all(void) {
80159
g_overlay_open = 0;
81160
}
82161

162+
static void
163+
ui_overlay_pop_one(void) {
164+
if (g_depth <= 0) {
165+
return;
166+
}
167+
UiMenuFrame* cur = &g_stack[g_depth - 1];
168+
if (cur->win) {
169+
delwin(cur->win);
170+
cur->win = NULL;
171+
}
172+
g_depth--;
173+
if (g_depth <= 0) {
174+
g_depth = 0;
175+
g_overlay_open = 0;
176+
}
177+
}
178+
179+
static int
180+
ui_menu_activate_current(void) {
181+
if (!g_overlay_open || g_depth <= 0) {
182+
return 0;
183+
}
184+
UiMenuFrame* f = &g_stack[g_depth - 1];
185+
const NcMenuItem* it = &f->items[f->hi];
186+
if (!ui_is_enabled(it, &g_ctx_overlay)) {
187+
return 1;
188+
}
189+
if (it->submenu && it->submenu_len > 0) {
190+
if (g_depth < (int)(sizeof g_stack / sizeof g_stack[0])) {
191+
UiMenuFrame* nf = &g_stack[g_depth++];
192+
memset(nf, 0, sizeof(*nf));
193+
nf->items = it->submenu;
194+
nf->n = it->submenu_len;
195+
nf->hi = ui_first_enabled_idx(nf->items, nf->n);
196+
nf->top = 0;
197+
nf->title = it->label ? it->label : it->id;
198+
ui_overlay_layout(nf, &g_ctx_overlay);
199+
}
200+
}
201+
if (it->on_select) {
202+
it->on_select(&g_ctx_overlay);
203+
if (exitflag) {
204+
ui_overlay_close_all();
205+
return 1;
206+
}
207+
UiMenuFrame* cf = &g_stack[g_depth - 1];
208+
if (!ui_is_enabled(&cf->items[cf->hi], &g_ctx_overlay)) {
209+
cf->hi = ui_next_enabled(cf->items, cf->n, &g_ctx_overlay, cf->hi, +1);
210+
}
211+
ui_overlay_layout(cf, &g_ctx_overlay);
212+
ui_frame_keep_highlight_visible(cf);
213+
ui_overlay_recreate_if_needed(cf);
214+
}
215+
if (!it->on_select && (!it->submenu || it->submenu_len == 0) && it->help && *it->help) {
216+
ui_help_open(it->help);
217+
}
218+
return 1;
219+
}
220+
83221
void
84222
ui_menu_open_async(dsd_opts* opts, dsd_state* state) {
85223
// Initialize overlay context and push root menu
@@ -105,7 +243,8 @@ ui_menu_open_async(dsd_opts* opts, dsd_state* state) {
105243
memset(g_stack, 0, sizeof(g_stack));
106244
g_stack[0].items = items;
107245
g_stack[0].n = n;
108-
g_stack[0].hi = 0;
246+
g_stack[0].hi = ui_first_enabled_idx(items, n);
247+
g_stack[0].top = 0;
109248
g_stack[0].title = "Main Menu";
110249
ui_overlay_layout(&g_stack[0], &g_ctx_overlay);
111250
}
@@ -151,17 +290,45 @@ ui_menu_handle_key(int ch, dsd_opts* opts, dsd_state* state) {
151290
f->win = NULL;
152291
}
153292
ui_overlay_layout(f, &g_ctx_overlay);
293+
ui_frame_keep_highlight_visible(f);
154294
return 1;
155295
}
156296
if (ch == ERR) {
157297
return 0;
158298
}
159299
if (ch == KEY_UP) {
160300
f->hi = ui_next_enabled(f->items, f->n, &g_ctx_overlay, f->hi, -1);
301+
ui_frame_keep_highlight_visible(f);
161302
return 1;
162303
}
163304
if (ch == KEY_DOWN) {
164305
f->hi = ui_next_enabled(f->items, f->n, &g_ctx_overlay, f->hi, +1);
306+
ui_frame_keep_highlight_visible(f);
307+
return 1;
308+
}
309+
if (ch == KEY_HOME) {
310+
f->hi = ui_first_enabled_idx(f->items, f->n);
311+
f->top = 0;
312+
return 1;
313+
}
314+
if (ch == KEY_END) {
315+
f->hi = ui_last_enabled_idx(f->items, f->n);
316+
f->top = ui_scroll_last_page_top(ui_visible_count_and_maxlab(f->items, f->n, &g_ctx_overlay, NULL),
317+
ui_frame_items_rows(f));
318+
return 1;
319+
}
320+
if (ch == KEY_PPAGE) {
321+
int page = ui_scroll_page_step_from_rows(ui_frame_items_rows(f));
322+
f->hi = ui_step_enabled(f->items, f->n, f->hi, -1, page);
323+
f->top -= page;
324+
ui_frame_keep_highlight_visible(f);
325+
return 1;
326+
}
327+
if (ch == KEY_NPAGE) {
328+
int page = ui_scroll_page_step_from_rows(ui_frame_items_rows(f));
329+
f->hi = ui_step_enabled(f->items, f->n, f->hi, +1, page);
330+
f->top += page;
331+
ui_frame_keep_highlight_visible(f);
165332
return 1;
166333
}
167334
if (ch == 'h' || ch == 'H') {
@@ -171,59 +338,16 @@ ui_menu_handle_key(int ch, dsd_opts* opts, dsd_state* state) {
171338
}
172339
return 1;
173340
}
174-
if (ch == DSD_KEY_ESC || ch == 'q' || ch == 'Q') {
175-
// Pop submenu or close root
341+
if (ch == KEY_LEFT || ch == DSD_KEY_ESC || ch == 'q' || ch == 'Q') {
176342
if (g_depth > 1) {
177-
UiMenuFrame* cur = &g_stack[g_depth - 1];
178-
if (cur->win) {
179-
delwin(cur->win);
180-
cur->win = NULL;
181-
}
182-
g_depth--;
343+
ui_overlay_pop_one();
183344
} else {
184345
ui_overlay_close_all();
185346
}
186347
return 1;
187348
}
188-
if (ch == 10 || ch == KEY_ENTER || ch == '\r') {
189-
const NcMenuItem* it = &f->items[f->hi];
190-
if (!ui_is_enabled(it, &g_ctx_overlay)) {
191-
return 1;
192-
}
193-
if (it->submenu && it->submenu_len > 0) {
194-
if (g_depth < (int)(sizeof g_stack / sizeof g_stack[0])) {
195-
UiMenuFrame* nf = &g_stack[g_depth++];
196-
memset(nf, 0, sizeof(*nf));
197-
nf->items = it->submenu;
198-
nf->n = it->submenu_len;
199-
nf->hi = 0;
200-
nf->title = it->label ? it->label : it->id;
201-
if (!ui_is_enabled(&nf->items[nf->hi], &g_ctx_overlay)) {
202-
nf->hi = ui_next_enabled(nf->items, nf->n, &g_ctx_overlay, nf->hi, +1);
203-
}
204-
ui_overlay_layout(nf, &g_ctx_overlay);
205-
}
206-
}
207-
if (it->on_select) {
208-
it->on_select(&g_ctx_overlay);
209-
if (exitflag) {
210-
// Let caller exit soon
211-
ui_overlay_close_all();
212-
return 1;
213-
}
214-
// After a toggle or action, visible items may have changed.
215-
// Ensure the highlight points at a visible item and recompute size.
216-
UiMenuFrame* cf = &g_stack[g_depth - 1];
217-
if (!ui_is_enabled(&cf->items[cf->hi], &g_ctx_overlay)) {
218-
cf->hi = ui_next_enabled(cf->items, cf->n, &g_ctx_overlay, cf->hi, +1);
219-
}
220-
ui_overlay_layout(cf, &g_ctx_overlay);
221-
ui_overlay_recreate_if_needed(cf);
222-
}
223-
if (!it->on_select && (!it->submenu || it->submenu_len == 0) && it->help && *it->help) {
224-
ui_help_open(it->help);
225-
}
226-
return 1;
349+
if (ch == KEY_RIGHT || ch == 10 || ch == KEY_ENTER || ch == '\r') {
350+
return ui_menu_activate_current();
227351
}
228352
return 0;
229353
}
@@ -256,12 +380,13 @@ ui_menu_tick(dsd_opts* opts, dsd_state* state) {
256380
}
257381
// Ensure window exists with up-to-date geometry
258382
ui_overlay_layout(f, &g_ctx_overlay);
383+
ui_frame_keep_highlight_visible(f);
259384
ui_overlay_recreate_if_needed(f);
260385
ui_overlay_ensure_window(f);
261386
if (!f->win) {
262387
return;
263388
}
264389
char breadcrumb[256];
265390
ui_overlay_breadcrumb(breadcrumb, sizeof breadcrumb);
266-
ui_draw_menu(f->win, f->items, f->n, f->hi, breadcrumb, &g_ctx_overlay);
391+
ui_draw_menu(f->win, f->items, f->n, f->hi, &f->top, breadcrumb, &g_ctx_overlay);
267392
}

src/ui/terminal/menu_internal.h

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@
1111
*/
1212
#pragma once
1313

14+
#include <stddef.h>
15+
16+
#include <dsd-neo/core/opts_fwd.h>
17+
#include <dsd-neo/core/state_fwd.h>
1418
#include <dsd-neo/platform/curses_compat.h>
15-
#include <dsd-neo/ui/menu_core.h>
19+
20+
typedef struct NcMenuItem NcMenuItem;
1621

1722
// Shared UI context passed to callbacks (full definition; forward-declared in menu_defs.h)
1823
typedef struct UiCtx {
@@ -25,6 +30,7 @@ typedef struct {
2530
const NcMenuItem* items;
2631
size_t n;
2732
int hi;
33+
int top;
2834
const char* title;
2935
WINDOW* win;
3036
int w, h;
@@ -103,13 +109,61 @@ typedef struct {
103109
int n;
104110
} PulseSelCtx;
105111

112+
static inline int
113+
ui_scroll_page_step_from_rows(int page_rows) {
114+
if (page_rows <= 1) {
115+
return 1;
116+
}
117+
return page_rows - 1;
118+
}
119+
120+
static inline int
121+
ui_scroll_clamp_top(int total, int page_rows, int top) {
122+
if (total <= 0 || page_rows <= 0 || total <= page_rows) {
123+
return 0;
124+
}
125+
if (top < 0) {
126+
top = 0;
127+
}
128+
if (top > total - page_rows) {
129+
top = total - page_rows;
130+
}
131+
return top;
132+
}
133+
134+
static inline int
135+
ui_scroll_last_page_top(int total, int page_rows) {
136+
return ui_scroll_clamp_top(total, page_rows, total - page_rows);
137+
}
138+
139+
static inline int
140+
ui_scroll_follow_selection(int total, int page_rows, int top, int sel_pos) {
141+
if (total <= 0 || page_rows <= 0) {
142+
return 0;
143+
}
144+
if (sel_pos < 0) {
145+
sel_pos = 0;
146+
}
147+
if (sel_pos >= total) {
148+
sel_pos = total - 1;
149+
}
150+
top = ui_scroll_clamp_top(total, page_rows, top);
151+
if (sel_pos < top) {
152+
top = sel_pos;
153+
} else if (sel_pos >= top + page_rows) {
154+
top = sel_pos - page_rows + 1;
155+
}
156+
return ui_scroll_clamp_top(total, page_rows, top);
157+
}
158+
106159
// ---- Visibility helpers (from menu_render.c) ----
107160
int ui_is_enabled(const NcMenuItem* it, void* ctx);
108161
int ui_submenu_has_visible(const NcMenuItem* items, size_t n, void* ctx);
109162
int ui_next_enabled(const NcMenuItem* items, size_t n, void* ctx, int from, int dir);
163+
int ui_visible_index_for_item(const NcMenuItem* items, size_t n, void* ctx, int idx);
110164

111165
// ---- Render helpers (from menu_render.c) ----
112-
void ui_draw_menu(WINDOW* win, const NcMenuItem* items, size_t n, int hi, const char* title, void* ctx);
166+
void ui_draw_menu(WINDOW* win, const NcMenuItem* items, size_t n, int hi, int* top_io, const char* title, void* ctx);
113167
void ui_overlay_layout(UiMenuFrame* f, void* ctx);
114168
void ui_overlay_ensure_window(UiMenuFrame* f);
115169
void ui_overlay_recreate_if_needed(UiMenuFrame* f);

0 commit comments

Comments
 (0)