microui: Add keyboard navigation

ctx->focus is a transient "pressed with the mouse" state, so it cannot
serve as a keyboard focus. Add a persistent ctx->kb_focus alongside it.
Controls enter the focus cycle from mu_update_control(), which every
widget already calls, so this needs no per-widget code.

Tab and Down move the focus forward, Shift+Tab and Up backward, Enter or
Space activates the focused control, and Left/Right adjust a focused
slider. The focused control is outlined with the new MU_COLOR_FOCUSRING;
window chrome is excluded via MU_OPT_NOKBNAV.
This commit is contained in:
kichikuou
2026-08-08 10:17:02 +09:00
parent 50a6b17c5e
commit c3bd978f02
5 changed files with 114 additions and 16 deletions
+3
View File
@@ -1,5 +1,8 @@
# Changelog
## Unreleased
- The popup menu and dialogs can now be operated with the keyboard.
## 2.19.1 - 2026-08-02
- Fixed an issue that prevented moving upward during battles in Rance 4.
+1 -1
View File
@@ -453,7 +453,7 @@ static bool inputnumber_build(mu_Context *ctx, modal *modal) {
mu_Id id = mu_get_id(ctx, "value", 5);
mu_Rect box = mu_layout_next(ctx);
if (st->focus_init) {
mu_set_focus(ctx, id);
mu_set_kb_focus(ctx, id);
st->focus_init = false;
}
int res = mu_textbox_raw(ctx, st->buf, sizeof(st->buf), id, box, 0);
+76 -8
View File
@@ -68,7 +68,8 @@ static mu_Style default_style = {
{ 35, 35, 35, 255 }, /* MU_COLOR_BASEHOVER */
{ 40, 40, 40, 255 }, /* MU_COLOR_BASEFOCUS */
{ 43, 43, 43, 255 }, /* MU_COLOR_SCROLLBASE */
{ 30, 30, 30, 255 } /* MU_COLOR_SCROLLTHUMB */
{ 30, 30, 30, 255 }, /* MU_COLOR_SCROLLTHUMB */
{ 120, 170, 255, 255 } /* MU_COLOR_FOCUSRING */
}
};
@@ -144,6 +145,10 @@ void mu_begin(mu_Context *ctx) {
ctx->next_hover_root = NULL;
ctx->mouse_delta.x = ctx->mouse_pos.x - ctx->last_mouse_pos.x;
ctx->mouse_delta.y = ctx->mouse_pos.y - ctx->last_mouse_pos.y;
/* restart the keyboard-navigation scan */
ctx->nav_first = ctx->nav_last = 0;
ctx->nav_prev = ctx->nav_next = ctx->nav_cursor = 0;
ctx->kb_focus_seen = 0;
ctx->frame++;
}
@@ -171,6 +176,20 @@ void mu_end(mu_Context *ctx) {
if (!ctx->updated_focus) { ctx->focus = 0; }
ctx->updated_focus = 0;
/* move the keyboard focus. The new focus takes effect on the next frame,
** which is fine for a UI that is rebuilt every frame. */
if (!ctx->kb_focus_seen) { ctx->kb_focus = 0; }
if (ctx->key_pressed & (MU_KEY_TAB | MU_KEY_UP | MU_KEY_DOWN)) {
int back = (ctx->key_pressed & MU_KEY_UP) ||
((ctx->key_pressed & MU_KEY_TAB) && (ctx->key_down & MU_KEY_SHIFT));
if (!ctx->kb_focus) {
ctx->kb_focus = back ? ctx->nav_last : ctx->nav_first;
} else {
ctx->kb_focus = back ? (ctx->nav_prev ? ctx->nav_prev : ctx->nav_last)
: (ctx->nav_next ? ctx->nav_next : ctx->nav_first);
}
}
/* bring hover root to front if mouse was pressed */
if (ctx->mouse_pressed && ctx->next_hover_root &&
ctx->next_hover_root->zindex < ctx->last_zindex &&
@@ -216,6 +235,12 @@ void mu_set_focus(mu_Context *ctx, mu_Id id) {
}
void mu_set_kb_focus(mu_Context *ctx, mu_Id id) {
ctx->kb_focus = id;
ctx->kb_focus_seen = 1;
}
/* 32bit fnv-1a hash */
#define HASH_INITIAL 2166136261
@@ -652,6 +677,17 @@ void mu_draw_control_frame(mu_Context *ctx, mu_Id id, mu_Rect rect,
if (opt & MU_OPT_NOFRAME) { return; }
colorid += (ctx->focus == id) ? 2 : (ctx->hover == id) ? 1 : 0;
ctx->draw_frame(ctx, rect, colorid);
if (ctx->kb_focus == id) {
mu_draw_box(ctx, expand_rect(rect, 1), ctx->style->colors[MU_COLOR_FOCUSRING]);
}
}
static int control_activated(mu_Context *ctx, mu_Id id) {
if (ctx->mouse_pressed == MU_MOUSE_LEFT && ctx->focus == id) { return 1; }
if (ctx->kb_focus == id &&
ctx->key_pressed & (MU_KEY_RETURN | MU_KEY_SPACE)) { return 1; }
return 0;
}
@@ -689,6 +725,22 @@ void mu_update_control(mu_Context *ctx, mu_Id id, mu_Rect rect, int opt) {
if (opt & MU_OPT_NOINTERACT) { return; }
if (mouseover && !ctx->mouse_down) { ctx->hover = id; }
/* record this control in the keyboard focus cycle. Controls are visited in
** submission order, so tracking the previous one is enough to know both
** neighbours of kb_focus. */
if (~opt & MU_OPT_NOKBNAV && mu_check_clip(ctx, rect) != MU_CLIP_ALL) {
if (!ctx->nav_first) { ctx->nav_first = id; }
ctx->nav_last = id;
if (id == ctx->kb_focus) {
ctx->kb_focus_seen = 1;
ctx->nav_prev = ctx->nav_cursor;
} else if (ctx->kb_focus && !ctx->nav_next &&
ctx->nav_cursor == ctx->kb_focus) {
ctx->nav_next = id;
}
ctx->nav_cursor = id;
}
if (ctx->focus == id) {
if (ctx->mouse_pressed && !mouseover) { mu_set_focus(ctx, 0); }
if (!ctx->mouse_down && ~opt & MU_OPT_HOLDFOCUS) { mu_set_focus(ctx, 0); }
@@ -697,6 +749,8 @@ void mu_update_control(mu_Context *ctx, mu_Id id, mu_Rect rect, int opt) {
if (ctx->hover == id) {
if (ctx->mouse_pressed) {
mu_set_focus(ctx, id);
ctx->kb_focus = id; /* clicking moves the keyboard focus too */
ctx->kb_focus_seen = 1;
} else if (!mouseover) {
ctx->hover = 0;
}
@@ -742,7 +796,7 @@ int mu_button_ex(mu_Context *ctx, const char *label, int icon, int opt) {
mu_Rect r = mu_layout_next(ctx);
mu_update_control(ctx, id, r, opt);
/* handle click */
if (ctx->mouse_pressed == MU_MOUSE_LEFT && ctx->focus == id) {
if (control_activated(ctx, id)) {
res |= MU_RES_SUBMIT;
}
/* draw */
@@ -760,7 +814,7 @@ int mu_checkbox(mu_Context *ctx, const char *label, int *state) {
mu_Rect box = mu_rect(r.x, r.y, r.h, r.h);
mu_update_control(ctx, id, r, 0);
/* handle click */
if (ctx->mouse_pressed == MU_MOUSE_LEFT && ctx->focus == id) {
if (control_activated(ctx, id)) {
res |= MU_RES_CHANGE;
*state = !*state;
}
@@ -779,6 +833,11 @@ int mu_textbox_raw(mu_Context *ctx, char *buf, int bufsz, mu_Id id, mu_Rect r,
int opt)
{
int res = 0;
/* the keyboard focus decides which textbox is editable, so that Tab moves the
** caret out of a MU_OPT_HOLDFOCUS textbox. A click reaches this through
** mu_update_control(), which also sets kb_focus. */
if (ctx->kb_focus == id) { mu_set_focus(ctx, id); }
else if (ctx->focus == id) { mu_set_focus(ctx, 0); }
mu_update_control(ctx, id, r, opt | MU_OPT_HOLDFOCUS);
if (ctx->focus == id) {
@@ -878,6 +937,12 @@ int mu_slider_ex(mu_Context *ctx, mu_Real *value, mu_Real low, mu_Real high,
v = low + (ctx->mouse_pos.x - base.x) * (high - low) / base.w;
if (step) { v = ((long long)((v + step / 2) / step)) * step; }
}
/* left/right are not navigation keys, so they need no arbitration with the
** focus movement in mu_end() */
if (ctx->kb_focus == id && ctx->key_pressed & (MU_KEY_LEFT | MU_KEY_RIGHT)) {
mu_Real d = step ? step : (high - low) / 100;
v += (ctx->key_pressed & MU_KEY_RIGHT) ? d : -d;
}
/* clamp and store value, update res */
*value = v = mu_clamp(v, low, high);
if (last != v) { res |= MU_RES_CHANGE; }
@@ -916,6 +981,9 @@ int mu_number_ex(mu_Context *ctx, mu_Real *value, mu_Real step,
if (ctx->focus == id && ctx->mouse_down == MU_MOUSE_LEFT) {
*value += ctx->mouse_delta.x * step;
}
if (ctx->kb_focus == id && ctx->key_pressed & (MU_KEY_LEFT | MU_KEY_RIGHT)) {
*value += (ctx->key_pressed & MU_KEY_RIGHT) ? step : -step;
}
/* set flag if value changed */
if (*value != last) { res |= MU_RES_CHANGE; }
@@ -943,7 +1011,7 @@ static int header(mu_Context *ctx, const char *label, int istreenode, int opt) {
mu_update_control(ctx, id, r, 0);
/* handle click */
active ^= (ctx->mouse_pressed == MU_MOUSE_LEFT && ctx->focus == id);
active ^= control_activated(ctx, id);
/* update pool ref */
if (idx >= 0) {
@@ -1006,7 +1074,7 @@ void mu_end_treenode(mu_Context *ctx) {
base.w = ctx->style->scrollbar_size; \
\
/* handle input */ \
mu_update_control(ctx, id, base, 0); \
mu_update_control(ctx, id, base, MU_OPT_NOKBNAV); \
if (ctx->focus == id && ctx->mouse_down == MU_MOUSE_LEFT) { \
cnt->scroll.y += ctx->mouse_delta.y * cs.y / base.h; \
} \
@@ -1111,7 +1179,7 @@ int mu_begin_window_ex(mu_Context *ctx, const char *title, mu_Rect rect, int opt
/* do title text */
if (~opt & MU_OPT_NOTITLE) {
mu_Id id = mu_get_id(ctx, "!title", 6);
mu_update_control(ctx, id, tr, opt);
mu_update_control(ctx, id, tr, opt | MU_OPT_NOKBNAV);
mu_draw_control_text(ctx, title, tr, MU_COLOR_TITLETEXT, opt);
if (id == ctx->focus && ctx->mouse_down == MU_MOUSE_LEFT) {
cnt->rect.x += ctx->mouse_delta.x;
@@ -1127,7 +1195,7 @@ int mu_begin_window_ex(mu_Context *ctx, const char *title, mu_Rect rect, int opt
mu_Rect r = mu_rect(tr.x + tr.w - tr.h, tr.y, tr.h, tr.h);
tr.w -= r.w;
mu_draw_icon(ctx, MU_ICON_CLOSE, r, ctx->style->colors[MU_COLOR_TITLETEXT]);
mu_update_control(ctx, id, r, opt);
mu_update_control(ctx, id, r, opt | MU_OPT_NOKBNAV);
if (ctx->mouse_pressed == MU_MOUSE_LEFT && id == ctx->focus) {
cnt->open = 0;
}
@@ -1141,7 +1209,7 @@ int mu_begin_window_ex(mu_Context *ctx, const char *title, mu_Rect rect, int opt
int sz = ctx->style->title_height;
mu_Id id = mu_get_id(ctx, "!resize", 7);
mu_Rect r = mu_rect(rect.x + rect.w - sz, rect.y + rect.h - sz, sz, sz);
mu_update_control(ctx, id, r, opt);
mu_update_control(ctx, id, r, opt | MU_OPT_NOKBNAV);
if (id == ctx->focus && ctx->mouse_down == MU_MOUSE_LEFT) {
cnt->rect.w = mu_max(96, cnt->rect.w + ctx->mouse_delta.x);
cnt->rect.h = mu_max(64, cnt->rect.h + ctx->mouse_delta.y);
+22 -2
View File
@@ -58,6 +58,7 @@ enum {
MU_COLOR_BASEFOCUS,
MU_COLOR_SCROLLBASE,
MU_COLOR_SCROLLTHUMB,
MU_COLOR_FOCUSRING,
MU_COLOR_MAX
};
@@ -88,7 +89,8 @@ enum {
MU_OPT_AUTOSIZE = (1 << 9),
MU_OPT_POPUP = (1 << 10),
MU_OPT_CLOSED = (1 << 11),
MU_OPT_EXPANDED = (1 << 12)
MU_OPT_EXPANDED = (1 << 12),
MU_OPT_NOKBNAV = (1 << 13) /* exclude from the keyboard focus cycle */
};
enum {
@@ -102,7 +104,13 @@ enum {
MU_KEY_CTRL = (1 << 1),
MU_KEY_ALT = (1 << 2),
MU_KEY_BACKSPACE = (1 << 3),
MU_KEY_RETURN = (1 << 4)
MU_KEY_RETURN = (1 << 4),
MU_KEY_TAB = (1 << 5),
MU_KEY_UP = (1 << 6),
MU_KEY_DOWN = (1 << 7),
MU_KEY_LEFT = (1 << 8),
MU_KEY_RIGHT = (1 << 9),
MU_KEY_SPACE = (1 << 10)
};
@@ -179,6 +187,17 @@ struct mu_Context {
mu_Style *style;
mu_Id hover;
mu_Id focus;
/* Unlike `focus`, which only lives for as long as a mouse button is held,
** `kb_focus` persists across frames and is moved by Tab / arrow keys. The
** nav_* fields are rebuilt every frame as mu_update_control() walks the
** controls in submission order. */
mu_Id kb_focus;
mu_Id nav_first; /* first navigable control of this frame */
mu_Id nav_last; /* last navigable control of this frame */
mu_Id nav_prev; /* control registered just before kb_focus */
mu_Id nav_next; /* control registered just after kb_focus */
mu_Id nav_cursor; /* scan state: last registered control */
int kb_focus_seen; /* kb_focus was registered this frame */
mu_Id last_id;
mu_Rect last_rect;
int last_zindex;
@@ -221,6 +240,7 @@ void mu_init(mu_Context *ctx);
void mu_begin(mu_Context *ctx);
void mu_end(mu_Context *ctx);
void mu_set_focus(mu_Context *ctx, mu_Id id);
void mu_set_kb_focus(mu_Context *ctx, mu_Id id);
mu_Id mu_get_id(mu_Context *ctx, const void *data, int size);
void mu_push_id(mu_Context *ctx, const void *data, int size);
void mu_pop_id(mu_Context *ctx);
+12 -5
View File
@@ -89,6 +89,7 @@ static void set_light_style(mu_Style *style) {
[MU_COLOR_BASEFOCUS] = { 235, 235, 242, 255 },
[MU_COLOR_SCROLLBASE] = { 200, 200, 200, 255 },
[MU_COLOR_SCROLLTHUMB] = { 170, 170, 170, 255 },
[MU_COLOR_FOCUSRING] = { 40, 90, 200, 255 },
};
memcpy(style->colors, light, sizeof(light));
}
@@ -164,11 +165,17 @@ bool modal_default_handler(const SDL_Event *e, modal *modal) {
modal->cancelled = true;
int c = 0;
switch (e->key.keysym.sym) {
case SDLK_LSHIFT: case SDLK_RSHIFT: c = MU_KEY_SHIFT; break;
case SDLK_LCTRL: case SDLK_RCTRL: c = MU_KEY_CTRL; break;
case SDLK_LALT: case SDLK_RALT: c = MU_KEY_ALT; break;
case SDLK_RETURN: c = MU_KEY_RETURN; break;
case SDLK_BACKSPACE: c = MU_KEY_BACKSPACE; break;
case SDLK_LSHIFT: case SDLK_RSHIFT: c = MU_KEY_SHIFT; break;
case SDLK_LCTRL: case SDLK_RCTRL: c = MU_KEY_CTRL; break;
case SDLK_LALT: case SDLK_RALT: c = MU_KEY_ALT; break;
case SDLK_RETURN: case SDLK_KP_ENTER: c = MU_KEY_RETURN; break;
case SDLK_BACKSPACE: c = MU_KEY_BACKSPACE; break;
case SDLK_TAB: c = MU_KEY_TAB; break;
case SDLK_UP: c = MU_KEY_UP; break;
case SDLK_DOWN: c = MU_KEY_DOWN; break;
case SDLK_LEFT: c = MU_KEY_LEFT; break;
case SDLK_RIGHT: c = MU_KEY_RIGHT; break;
case SDLK_SPACE: c = MU_KEY_SPACE; break;
}
if (!c)
break;