Reimplement the string input dialog with a microui textbox

The dialog edited and drew its field by hand because microui's textbox
cannot display IME composition text.

Teach microui about compositions instead, and split mu_textbox_raw()
into input and drawing halves so the MJ inline editor can share the
input path while keeping the game's own appearance.
This commit is contained in:
kichikuou
2026-08-08 13:03:31 +09:00
parent c3bd978f02
commit a9e23508b7
4 changed files with 188 additions and 176 deletions
+46 -124
View File
@@ -148,118 +148,31 @@ bool input_modal_number(INPUTNUM_PARAM *p) {
#include "utfsjis.h"
// The string-input dialog.
// The input field is not a microui textbox: to support IME (SDL_TEXTEDITING)
// composition we keep our own buffer (str_buf) and feed it from the handler,
// mirroring editor.c.
struct string_state {
modal base;
INPUTSTRING_PARAM *param;
char composing[SDL_TEXTEDITINGEVENT_TEXT_SIZE]; // IME preedit text
bool done;
bool accepted;
FontSpec font; // inline edit (input_modal_string_inline) only
bool focus_init; // focus the text field on the first frame
FontSpec font; // inline edit (input_modal_string_inline) only
};
// Heap buffer holding the string dialog's edited text. It must outlive the
// modal loop because the caller reads p->newstring after input_modal_string()
// returns; it is freed and reallocated on the next call.
// Heap buffer holding the edited text. It must outlive the modal loop because
// the caller reads p->newstring after input_modal_string() returns; it is freed
// and reallocated on the next call.
static char *str_buf;
static int str_bufsz;
// Append `add` to str_buf, truncating so the total stays within
// st->param->max characters (counted as UTF-8 code points).
static void str_append(struct string_state *st, const char *add) {
int room = st->param->max;
for (const char *p = str_buf; *p; p = advance_char(p, UTF8))
room--;
const char *end = add;
while (room > 0 && *end) {
end = advance_char(end, UTF8);
room--;
}
strncat(str_buf, add, end - add);
}
// Delete the last UTF-8 character of str_buf.
static void str_backspace(void) {
if (!*str_buf)
return;
char *p = str_buf + strlen(str_buf) - 1;
while (p > str_buf && UTF8_TRAIL_BYTE(*p))
p--;
*p = '\0';
}
static bool menu_string_handler(const SDL_Event *e, modal *modal) {
struct string_state *st = (struct string_state *)modal;
switch (e->type) {
case SDL_KEYDOWN:
// While an IME composition is active, let it consume the key.
if (!*st->composing) {
switch (e->key.keysym.sym) {
case SDLK_RETURN:
st->accepted = true;
st->done = true;
break;
case SDLK_ESCAPE:
st->base.cancelled = true;
break;
case SDLK_BACKSPACE:
str_backspace();
break;
}
}
return true; // never forward keys to microui (there is no textbox)
case SDL_TEXTINPUT:
str_append(st, e->text.text);
st->composing[0] = '\0';
return true;
case SDL_TEXTEDITING:
strncpy(st->composing, e->edit.text, sizeof(st->composing) - 1);
st->composing[sizeof(st->composing) - 1] = '\0';
return true;
}
return modal_default_handler(e, modal);
}
// Tell SDL where the text input is, so the IME candidate window is positioned
// near the field. `box` is in logical (view) coordinates; convert to window
// pixels for SDL_SetTextInputRect.
static void set_text_input_rect(mu_Rect box) {
SDL_Rect r = gfx_viewToWindowRect(
(SDL_Rect){box.x, box.y, box.w, box.h});
SDL_SetTextInputRect(&r);
static void str_buf_init(const char *initial, int max_chars) {
free(str_buf);
str_bufsz = max_chars * MAX_UTF8_BYTES_PAR_CHAR + 1;
str_buf = malloc(str_bufsz);
strncpy(str_buf, initial ? initial : "", str_bufsz - 1);
str_buf[str_bufsz - 1] = '\0';
}
#ifndef _WIN32 // input_modal_string is provided by win/dialog.c on Windows.
// Draw the IME-aware text field (committed text, preedit with underline, caret).
static void draw_string_field(mu_Context *ctx, mu_Rect box, const struct string_state *st) {
mu_draw_rect(ctx, box, ctx->style->colors[MU_COLOR_BASE]);
mu_draw_box(ctx, box, ctx->style->colors[MU_COLOR_BORDER]);
mu_Font font = ctx->style->font;
mu_Color col = ctx->style->colors[MU_COLOR_TEXT];
int th = ctx->text_height(font);
int x = box.x + ctx->style->padding;
int y = box.y + (box.h - th) / 2;
mu_push_clip_rect(ctx, box);
if (*str_buf) {
mu_draw_text(ctx, font, str_buf, -1, mu_vec2(x, y), col);
x += ctx->text_width(font, str_buf, -1);
}
if (*st->composing) {
mu_draw_text(ctx, font, st->composing, -1, mu_vec2(x, y), col);
int cw = ctx->text_width(font, st->composing, -1);
mu_draw_rect(ctx, mu_rect(x, y + th, cw, 1), col); // preedit underline
x += cw;
}
mu_draw_rect(ctx, mu_rect(x, y, 1, th), col); // caret
mu_pop_clip_rect(ctx);
set_text_input_rect(box);
}
static bool inputstring_build(mu_Context *ctx, modal *modal) {
struct string_state *st = (struct string_state *)modal;
const char *title = (st->param->title && *st->param->title)
@@ -278,7 +191,17 @@ static bool inputstring_build(mu_Context *ctx, modal *modal) {
snprintf(info, sizeof(info), _("Up to %d characters"), st->param->max);
mu_label(ctx, info);
draw_string_field(ctx, mu_layout_next(ctx), st);
mu_Id id = mu_get_id(ctx, "value", 5);
mu_Rect box = mu_layout_next(ctx);
if (st->focus_init) {
mu_set_kb_focus(ctx, id);
st->focus_init = false;
}
if (mu_textbox_raw(ctx, str_buf, str_bufsz, st->param->max, id, box, 0)
& MU_RES_SUBMIT) {
st->accepted = true;
st->done = true;
}
int content = w - ctx->style->padding * 2;
int half = (content - ctx->style->spacing) / 2;
@@ -303,16 +226,12 @@ static bool inputstring_build(mu_Context *ctx, modal *modal) {
bool input_modal_string(INPUTSTRING_PARAM *p) {
struct string_state st = {
.base = { .build = inputstring_build, .handler = menu_string_handler },
.base = { .build = inputstring_build, .handler = modal_default_handler },
.param = p,
.focus_init = true,
};
free(str_buf);
str_buf = malloc(p->max * MAX_UTF8_BYTES_PAR_CHAR + 1);
strcpy(str_buf, p->oldstring ? p->oldstring : "");
SDL_StartTextInput();
str_buf_init(p->oldstring, p->max);
modal_run(&st.base);
SDL_StopTextInput();
p->newstring = st.accepted ? str_buf : p->oldstring;
return true;
@@ -320,8 +239,8 @@ bool input_modal_string(INPUTSTRING_PARAM *p) {
#endif // !_WIN32
// The inline text input (MJ command). It reuses the IME buffer handling
// and the event handler of input_modal_string.
// The inline text input (MJ command). It shares the textbox input handling but
// draws itself with the game's font and palette.
// Look up an AGS color index in the active palette and return it as a mu_Color.
static mu_Color palette_color(int index) {
@@ -342,6 +261,17 @@ static bool inline_edit_build(mu_Context *ctx, modal *modal) {
// all drawing below is done manually with the game's colors.
if (mu_begin_window_ex(ctx, "editstr", rect,
MU_OPT_NOFRAME | MU_OPT_NOTITLE | MU_OPT_NORESIZE | MU_OPT_NOSCROLL)) {
mu_Id id = mu_get_id(ctx, "editstr", 7);
if (st->focus_init) {
mu_set_kb_focus(ctx, id);
st->focus_init = false;
}
if (mu_textbox_input(ctx, str_buf, str_bufsz, st->param->max, id, rect, 0)
& MU_RES_SUBMIT) {
st->accepted = true;
st->done = true;
}
mu_Color bg = palette_color(nact->msg.WinBackgroundColor);
mu_Color fg = palette_color(nact->msg.MsgFontColor);
mu_Font font = (mu_Font)&st->font;
@@ -354,14 +284,13 @@ static bool inline_edit_build(mu_Context *ctx, modal *modal) {
mu_draw_text(ctx, font, str_buf, -1, mu_vec2(x, y), fg);
x += ctx->text_width(font, str_buf, -1);
}
if (*st->composing) {
mu_draw_text(ctx, font, st->composing, -1, mu_vec2(x, y), fg);
int cw = ctx->text_width(font, st->composing, -1);
if (*ctx->preedit) {
mu_draw_text(ctx, font, ctx->preedit, -1, mu_vec2(x, y), fg);
int cw = ctx->text_width(font, ctx->preedit, -1);
mu_draw_rect(ctx, mu_rect(x, y + fh, cw, 2), fg); // preedit underline
x += cw;
}
mu_draw_rect(ctx, mu_rect(x, y, 2, fh), fg); // caret
set_text_input_rect(rect);
mu_end_window(ctx);
}
@@ -372,18 +301,14 @@ static bool inline_edit_build(mu_Context *ctx, modal *modal) {
bool input_modal_string_inline(INPUTSTRING_PARAM *p) {
struct string_state st = {
.base = { .build = inline_edit_build, .handler = menu_string_handler,
.base = { .build = inline_edit_build, .handler = modal_default_handler,
.no_dim = true },
.param = p,
.focus_init = true,
.font = { FONT_GOTHIC, FONT_WEIGHT_NORMAL, p->h },
};
free(str_buf);
str_buf = malloc(p->max * MAX_UTF8_BYTES_PAR_CHAR + 1);
strcpy(str_buf, p->oldstring ? p->oldstring : "");
SDL_StartTextInput();
str_buf_init(p->oldstring, p->max);
modal_run(&st.base);
SDL_StopTextInput();
p->newstring = st.accepted ? str_buf : NULL;
return true;
@@ -456,7 +381,7 @@ static bool inputnumber_build(mu_Context *ctx, modal *modal) {
mu_set_kb_focus(ctx, id);
st->focus_init = false;
}
int res = mu_textbox_raw(ctx, st->buf, sizeof(st->buf), id, box, 0);
int res = mu_textbox_raw(ctx, st->buf, sizeof(st->buf), 0, id, box, 0);
if (mu_button(ctx, "-"))
num_adjust(st, -1);
if (mu_button(ctx, "+"))
@@ -501,10 +426,7 @@ bool input_modal_number(INPUTNUM_PARAM *p) {
.focus_init = true,
};
snprintf(st.buf, sizeof(st.buf), "%d", p->def);
SDL_StartTextInput();
modal_run(&st.base);
SDL_StopTextInput();
if (!st.accepted)
return false;
+100 -50
View File
@@ -149,6 +149,7 @@ void mu_begin(mu_Context *ctx) {
ctx->nav_first = ctx->nav_last = 0;
ctx->nav_prev = ctx->nav_next = ctx->nav_cursor = 0;
ctx->kb_focus_seen = 0;
ctx->text_input = 0;
ctx->frame++;
}
@@ -190,6 +191,9 @@ void mu_end(mu_Context *ctx) {
}
}
/* drop a composition left over from a textbox that lost the focus */
if (!ctx->text_input) { ctx->preedit[0] = '\0'; }
/* 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 &&
@@ -427,6 +431,8 @@ void mu_input_scroll(mu_Context *ctx, int x, int y) {
void mu_input_keydown(mu_Context *ctx, int key) {
/* while an IME composition is active the keys belong to the IME */
if (*ctx->preedit) { return; }
ctx->key_pressed |= key;
ctx->key_down |= key;
}
@@ -438,10 +444,22 @@ void mu_input_keyup(mu_Context *ctx, int key) {
void mu_input_text(mu_Context *ctx, const char *text) {
/* SDL splits a long commit into several events, which all arrive within one
** frame and can overflow the buffer. Drop the excess instead of failing. */
int len = strlen(ctx->input_text);
int size = strlen(text) + 1;
expect(len + size <= (int) sizeof(ctx->input_text));
memcpy(ctx->input_text + len, text, size);
int n = mu_min((int) strlen(text), (int) sizeof(ctx->input_text) - 1 - len);
while (n > 0 && (text[n] & 0xc0) == 0x80) { n--; } /* keep utf-8 whole */
memcpy(ctx->input_text + len, text, n);
ctx->input_text[len + n] = '\0';
ctx->preedit[0] = '\0'; /* committing ends the composition */
}
void mu_input_preedit(mu_Context *ctx, const char *text) {
int n = mu_min((int) strlen(text), (int) sizeof(ctx->preedit) - 1);
while (n > 0 && (text[n] & 0xc0) == 0x80) { n--; } /* keep utf-8 whole */
memcpy(ctx->preedit, text, n);
ctx->preedit[n] = '\0';
}
@@ -829,59 +847,91 @@ int mu_checkbox(mu_Context *ctx, const char *label, int *state) {
}
int mu_textbox_raw(mu_Context *ctx, char *buf, int bufsz, mu_Id id, mu_Rect r,
int opt)
/* number of characters in a utf-8 string */
static int utf8_strlen(const char *s) {
int n = 0;
for (; *s; s++) { if ((*s & 0xc0) != 0x80) { n++; } }
return n;
}
int mu_textbox_input(mu_Context *ctx, char *buf, int bufsz, int max_chars,
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);
/* the keyboard focus decides which textbox is editable, so that Tab moves
** the caret out of it. A click reaches this through mu_update_control(). */
mu_update_control(ctx, id, r, opt);
if (ctx->kb_focus != id) { return 0; }
if (ctx->focus == id) {
/* handle text input */
int len = strlen(buf);
int n = mu_min(bufsz - len - 1, (int) strlen(ctx->input_text));
if (n > 0) {
memcpy(buf + len, ctx->input_text, n);
len += n;
buf[len] = '\0';
res |= MU_RES_CHANGE;
}
/* handle backspace */
if (ctx->key_pressed & MU_KEY_BACKSPACE && len > 0) {
/* skip utf-8 continuation bytes */
while ((buf[--len] & 0xc0) == 0x80 && len > 0);
buf[len] = '\0';
res |= MU_RES_CHANGE;
}
/* handle return */
if (ctx->key_pressed & MU_KEY_RETURN) {
mu_set_focus(ctx, 0);
res |= MU_RES_SUBMIT;
}
/* let the host turn the IME on and place its candidate window */
ctx->text_input = 1;
ctx->text_input_rect = r;
/* handle text input, honoring both the byte and the character limit */
int len = strlen(buf);
int room = bufsz - len - 1;
int chars = max_chars ? max_chars - utf8_strlen(buf) : bufsz;
int n = 0;
while (ctx->input_text[n] && chars > 0) {
int e = n + 1;
while ((ctx->input_text[e] & 0xc0) == 0x80) { e++; }
if (e > room) { break; }
n = e;
chars--;
}
if (n > 0) {
memcpy(buf + len, ctx->input_text, n);
len += n;
buf[len] = '\0';
res |= MU_RES_CHANGE;
}
/* handle backspace */
if (ctx->key_pressed & MU_KEY_BACKSPACE && len > 0) {
/* skip utf-8 continuation bytes */
while ((buf[--len] & 0xc0) == 0x80 && len > 0);
buf[len] = '\0';
res |= MU_RES_CHANGE;
}
/* handle return */
if (ctx->key_pressed & MU_KEY_RETURN) { res |= MU_RES_SUBMIT; }
/* draw */
return res;
}
void mu_draw_textbox(mu_Context *ctx, const char *buf, mu_Id id, mu_Rect r,
int opt)
{
mu_draw_control_frame(ctx, id, r, MU_COLOR_BASE, opt);
if (ctx->focus == id) {
mu_Color color = ctx->style->colors[MU_COLOR_TEXT];
mu_Font font = ctx->style->font;
int textw = ctx->text_width(font, buf, -1);
int texth = ctx->text_height(font);
int ofx = r.w - ctx->style->padding - textw - 1;
int textx = r.x + mu_min(ofx, ctx->style->padding);
int texty = r.y + (r.h - texth) / 2;
mu_push_clip_rect(ctx, r);
mu_draw_text(ctx, font, buf, -1, mu_vec2(textx, texty), color);
mu_draw_rect(ctx, mu_rect(textx + textw, texty, 1, texth), color);
mu_pop_clip_rect(ctx);
} else {
if (ctx->kb_focus != id) {
mu_draw_control_text(ctx, buf, r, MU_COLOR_TEXT, opt);
return;
}
mu_Color color = ctx->style->colors[MU_COLOR_TEXT];
mu_Font font = ctx->style->font;
int textw = ctx->text_width(font, buf, -1);
int prew = *ctx->preedit ? ctx->text_width(font, ctx->preedit, -1) : 0;
int texth = ctx->text_height(font);
int ofx = r.w - ctx->style->padding - textw - prew - 1;
int textx = r.x + mu_min(ofx, ctx->style->padding);
int texty = r.y + (r.h - texth) / 2;
mu_push_clip_rect(ctx, r);
mu_draw_text(ctx, font, buf, -1, mu_vec2(textx, texty), color);
if (prew) {
mu_draw_text(ctx, font, ctx->preedit, -1, mu_vec2(textx + textw, texty), color);
mu_draw_rect(ctx, mu_rect(textx + textw, texty + texth, prew, 1), color);
}
mu_draw_rect(ctx, mu_rect(textx + textw + prew, texty, 1, texth), color);
mu_pop_clip_rect(ctx);
}
int mu_textbox_raw(mu_Context *ctx, char *buf, int bufsz, int max_chars,
mu_Id id, mu_Rect r, int opt)
{
int res = mu_textbox_input(ctx, buf, bufsz, max_chars, id, r, opt);
mu_draw_textbox(ctx, buf, id, r, opt);
return res;
}
@@ -895,8 +945,8 @@ static int number_textbox(mu_Context *ctx, mu_Real *value, mu_Rect r, mu_Id id)
}
if (ctx->number_edit == id) {
int res = mu_textbox_raw(
ctx, ctx->number_edit_buf, sizeof(ctx->number_edit_buf), id, r, 0);
if (res & MU_RES_SUBMIT || ctx->focus != id) {
ctx, ctx->number_edit_buf, sizeof(ctx->number_edit_buf), 0, id, r, 0);
if (res & MU_RES_SUBMIT || ctx->kb_focus != id) {
*value = strtod(ctx->number_edit_buf, NULL);
ctx->number_edit = 0;
} else {
@@ -910,7 +960,7 @@ static int number_textbox(mu_Context *ctx, mu_Real *value, mu_Rect r, mu_Id id)
int mu_textbox_ex(mu_Context *ctx, char *buf, int bufsz, int opt) {
mu_Id id = mu_get_id(ctx, &buf, sizeof(buf));
mu_Rect r = mu_layout_next(ctx);
return mu_textbox_raw(ctx, buf, bufsz, id, r, opt);
return mu_textbox_raw(ctx, buf, bufsz, 0, id, r, opt);
}
+12 -1
View File
@@ -229,6 +229,11 @@ struct mu_Context {
int key_down;
int key_pressed;
char input_text[32];
char preedit[32]; /* IME composition text, not yet committed */
/* Set by mu_textbox_input() while a textbox has the keyboard focus, so the
** host can enable the IME and place its candidate window. */
int text_input;
mu_Rect text_input_rect;
};
@@ -263,6 +268,7 @@ void mu_input_scroll(mu_Context *ctx, int x, int y);
void mu_input_keydown(mu_Context *ctx, int key);
void mu_input_keyup(mu_Context *ctx, int key);
void mu_input_text(mu_Context *ctx, const char *text);
void mu_input_preedit(mu_Context *ctx, const char *text);
mu_Command* mu_push_command(mu_Context *ctx, int type, int size);
int mu_next_command(mu_Context *ctx, mu_Command **cmd);
@@ -298,7 +304,12 @@ void mu_text(mu_Context *ctx, const char *text);
void mu_label(mu_Context *ctx, const char *text);
int mu_button_ex(mu_Context *ctx, const char *label, int icon, int opt);
int mu_checkbox(mu_Context *ctx, const char *label, int *state);
int mu_textbox_raw(mu_Context *ctx, char *buf, int bufsz, mu_Id id, mu_Rect r, int opt);
/* Edit `buf` in place, without drawing. `max_chars` limits the length in UTF-8
** characters (0 for no limit beyond `bufsz`). Use this with mu_draw_textbox(),
** or with custom drawing when the default appearance does not fit. */
int mu_textbox_input(mu_Context *ctx, char *buf, int bufsz, int max_chars, mu_Id id, mu_Rect r, int opt);
void mu_draw_textbox(mu_Context *ctx, const char *buf, mu_Id id, mu_Rect r, int opt);
int mu_textbox_raw(mu_Context *ctx, char *buf, int bufsz, int max_chars, mu_Id id, mu_Rect r, int opt);
int mu_textbox_ex(mu_Context *ctx, char *buf, int bufsz, int opt);
int mu_slider_ex(mu_Context *ctx, mu_Real *value, mu_Real low, mu_Real high, mu_Real step, const char *fmt, int opt);
int mu_number_ex(mu_Context *ctx, mu_Real *value, mu_Real step, const char *fmt, int opt);
+30 -1
View File
@@ -120,6 +120,9 @@ bool modal_default_handler(const SDL_Event *e, modal *modal) {
case SDL_TEXTINPUT:
mu_input_text(ctx, e->text.text);
break;
case SDL_TEXTEDITING:
mu_input_preedit(ctx, e->edit.text);
break;
case SDL_MOUSEBUTTONDOWN:
case SDL_MOUSEBUTTONUP: {
int b = 0;
@@ -161,7 +164,9 @@ bool modal_default_handler(const SDL_Event *e, modal *modal) {
break;
case SDL_KEYDOWN:
case SDL_KEYUP: {
if (e->type == SDL_KEYDOWN && e->key.keysym.sym == SDLK_ESCAPE)
// Esc cancels an IME composition first, not the modal.
if (e->type == SDL_KEYDOWN && e->key.keysym.sym == SDLK_ESCAPE &&
!*ctx->preedit)
modal->cancelled = true;
int c = 0;
switch (e->key.keysym.sym) {
@@ -293,6 +298,27 @@ static void modal_render(void) {
SDL_SetRenderDrawBlendMode(gfx_renderer, SDL_BLENDMODE_NONE);
}
// Turn SDL's text input on while a textbox has the keyboard focus, and keep the
// IME candidate window near it. The rect is in logical (view) coordinates.
static void update_text_input(void) {
static SDL_Rect last_rect;
if (!ctx->text_input) {
if (SDL_IsTextInputActive())
SDL_StopTextInput();
return;
}
if (!SDL_IsTextInputActive()) {
SDL_StartTextInput();
last_rect = (SDL_Rect){0, 0, 0, 0};
}
mu_Rect r = ctx->text_input_rect;
SDL_Rect wr = gfx_viewToWindowRect((SDL_Rect){r.x, r.y, r.w, r.h});
if (memcmp(&wr, &last_rect, sizeof(wr))) {
last_rect = wr;
SDL_SetTextInputRect(&wr);
}
}
bool modal_handle_event(const SDL_Event *e) {
if (!current_modal)
return false;
@@ -341,6 +367,7 @@ void modal_run(modal *m) {
mu_begin(ctx);
open = m->build(ctx, m);
mu_end(ctx);
update_text_input();
// Present only when the overlay changed.
mu_Id hash = mu_get_id(ctx, ctx->command_list.items, ctx->command_list.idx);
@@ -355,6 +382,8 @@ void modal_run(modal *m) {
current_modal = NULL;
free(ctx);
ctx = NULL;
if (SDL_IsTextInputActive())
SDL_StopTextInput();
gfx_requestRedraw(); // repaint once more to clear the overlay
// The gesture that opened/dismissed the dialog must not leave the engine