Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 208 additions & 13 deletions src/ui/inputwidget.c
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,15 @@ static void enableEditorKeysInMenus_(iBool enable) {
}

static void updateMetrics_InputWidget_(iInputWidget *);
static void contentsWereChanged_InputWidget_(iInputWidget *);

/*----------------------------------------------------------------------------------------------*/
#if !LAGRANGE_USE_SYSTEM_TEXT_INPUT

static void insertRange_InputWidget_(iInputWidget *, iRangecc);
static void pushUndo_InputWidget_(iInputWidget *);
static iBool deleteMarked_InputWidget_(iInputWidget *);

iDeclareType(InputLine)

struct Impl_InputLine {
Expand Down Expand Up @@ -273,6 +278,12 @@ struct Impl_InputWidget {
iRanges mark; /* TODO: would likely simplify things to use two Int2's for marking; no conversions needed */
iRanges initialMark;
iArray undoStack;
iString preedit; /* IME composition ("preedit") text, shown inline but not
yet part of the document (e.g., a Korean syllable being
assembled). Committed on Enter or when composition ends. */
int preeditCursor; /* byte offset of active segment within preedit */
int preeditLength; /* byte length of active segment within preedit */
int preeditAdvance; /* cached pixel width of preedit text */
uint32_t tapStartTime;
uint32_t lastTapTime;
iInt2 lastTapPos;
Expand All @@ -283,6 +294,50 @@ struct Impl_InputWidget {

iDefineObjectConstructionArgs(InputWidget, (size_t maxLen), maxLen)

#if !LAGRANGE_USE_SYSTEM_TEXT_INPUT
static void clearPreedit_InputWidget_(iInputWidget *d) {
clear_String(&d->preedit);
d->preeditCursor = 0;
d->preeditLength = 0;
d->preeditAdvance = 0;
}

/* Convert a character (codepoint) offset to a byte offset within a UTF-8 string. */
static int charToByteOffset_(const char *s, const char *end, int charOff) {
const char *p = s;
for (int i = 0; i < charOff && p < end; i++) {
iChar ch;
int n = decodeBytes_MultibyteChar(p, end, &ch);
p += iMax(n, 1);
}
return (int)(p - s);
}

/* Store preedit state from an SDL TEXTEDITING or TEXTEDITING_EXT event.
Converts the cursor/length from character offsets (as SDL provides) to
byte offsets for internal use. */
static void setPreedit_InputWidget_(iInputWidget *d, const char *text, int start, int length) {
setCStr_String(&d->preedit, text);
const iRangecc range = range_String(&d->preedit);
d->preeditCursor = charToByteOffset_(range.start, range.end, start);
d->preeditLength = charToByteOffset_(range.start + d->preeditCursor, range.end, length);
d->preeditAdvance = isEmpty_String(&d->preedit) ? 0
: measureRange_Text(d->font, range).advance.x;
}

/* Finalize any in-progress IME composition by inserting the preedit text. Called
when the widget loses focus or editing ends, so partially composed
text (e.g., an uncommitted Korean syllable) isn't silently discarded. */
static void commitPreedit_InputWidget_(iInputWidget *d) {
if (!isEmpty_String(&d->preedit)) {
pushUndo_InputWidget_(d);
insertRange_InputWidget_(d, range_String(&d->preedit));
contentsWereChanged_InputWidget_(d);
clearPreedit_InputWidget_(d);
}
}
#endif

static int extraPaddingHeight_InputWidget_(const iInputWidget *d) {
if ((isPortraitPhone_App() || deviceType_App() == tablet_AppDeviceType) &&
!cmp_String(id_Widget(&d->widget), "url")) {
Expand Down Expand Up @@ -796,8 +851,19 @@ static void updateTextInputRect_InputWidget_(const iInputWidget *d) {
}
#endif
#if !defined (iPlatformAppleMobile) && !defined (iPlatformAndroidMobile) && !defined (SDL_SEAL_CURSES)
const iRect bounds = bounds_Widget(constAs_Widget(d));
SDL_SetTextInputRect(&(SDL_Rect){ bounds.pos.x, bounds.pos.y, bounds.size.x, bounds.size.y });
/* Tell SDL where the text cursor is so the OS can position IME candidate
windows (e.g., the Korean Hanja selection panel) near the insertion point.
Coordinates must be in points, not pixels, hence the pixelRatio division. */
iBool inBounds;
iInt2 wc = cursorToWindowCoord_InputWidget_(d, d->cursor, &inBounds);
const int lh = lineHeight_Text(d->font);
if (!isEmpty_String(&d->preedit)) {
wc.x += d->preeditAdvance;
}
const float pr = get_Window()->pixelRatio;
SDL_SetTextInputRect(&(SDL_Rect){
(int)(wc.x / pr), (int)(wc.y / pr), 1, (int)(lh / pr)
});
#endif
}

Expand Down Expand Up @@ -861,6 +927,10 @@ void init_InputWidget(iInputWidget *d, size_t maxLen) {
d->lastTapTime = 0;
d->tapCount = 0;
d->cursorVis = 0;
init_String(&d->preedit);
d->preeditCursor = 0;
d->preeditLength = 0;
d->preeditAdvance = 0;
iZap(d->mark);
splitToLines_(&iStringLiteral(""), &d->lines);
#endif
Expand Down Expand Up @@ -906,6 +976,7 @@ void deinit_InputWidget(iInputWidget *d) {
delete_SystemTextInput(d->sysCtrl);
deinit_String(&d->text);
#else
deinit_String(&d->preedit);
startOrStopCursorTimer_InputWidget_(d, iFalse);
clearInputLines_(&d->lines);
deactivateInputMode_InputWidget_(d);
Expand Down Expand Up @@ -1298,8 +1369,6 @@ iLocalDef iBool isEditing_InputWidget_(const iInputWidget *d) {
return (flags_Widget(constAs_Widget(d)) & selected_WidgetFlag) != 0;
}

static void contentsWereChanged_InputWidget_(iInputWidget *);

#if LAGRANGE_USE_SYSTEM_TEXT_INPUT
void systemInputChanged_InputWidget_(iSystemTextInput *sysCtrl, void *widget) {
iInputWidget *d = widget;
Expand Down Expand Up @@ -1413,6 +1482,10 @@ void end_InputWidget(iInputWidget *d, iBool accept) {
if (!accept) {
/* Overwrite the edited lines. */
splitToLines_(&d->oldText, &d->lines);
clearPreedit_InputWidget_(d);
}
else {
commitPreedit_InputWidget_(d);
}
d->inFlags &= ~isMarking_InputWidgetFlag;
deactivateInputMode_InputWidget_(d);
Expand Down Expand Up @@ -1960,6 +2033,7 @@ static enum iEventResult processPointerEvents_InputWidget_(iInputWidget *d, cons
break;
case started_ClickResult: {
setFocus_Widget(w);
commitPreedit_InputWidget_(d);
const iInt2 oldCursor = d->cursor;
setCursor_InputWidget(d, coordCursor_InputWidget_(d, pos_Click(&d->click)));
if (keyMods_Sym(modState_Keys()) == KMOD_SHIFT) {
Expand Down Expand Up @@ -2424,6 +2498,9 @@ static iBool processEvent_InputWidget_(iInputWidget *d, const SDL_Event *ev) {
isCommand_UserEvent(ev, "window.focus.gained"))) {
/* Ignore events happening in other windows. */
if (arg_Command(command_UserEvent(ev)) == id_Window(window_Widget(w))) {
if (isCommand_UserEvent(ev, "window.focus.lost")) {
commitPreedit_InputWidget_(d); /* don't lose in-progress composition */
}
startOrStopCursorTimer_InputWidget_(d, isCommand_UserEvent(ev, "window.focus.gained"));
d->cursorVis = 1;
refresh_Widget(d);
Expand Down Expand Up @@ -2595,17 +2672,65 @@ static iBool processEvent_InputWidget_(iInputWidget *d, const SDL_Event *ev) {
return false_EventResult;
}
if (ev->type == SDL_TEXTINPUT && isFocused_Widget(w)) {
/* TEXTINPUT: a normal (e.g. non-CJK) keypress or the IME has finalized
text to commit. */
if ((modState_Keys() & (KMOD_CTRL | KMOD_ALT)) == KMOD_CTRL) {
/* Note: AltGr on Windows is reported as Ctrl+Alt. */
return iTrue;
}
if (isLinux_Platform() && keyMods_Sym(modState_Keys()) == KMOD_CTRL) {
return iTrue;
}
pushUndo_InputWidget_(d);
if (isEmpty_String(&d->preedit)) {
/* Not finishing an IME composition, so this needs its own undo entry.
(Compositions already pushed undo when they started.) */
pushUndo_InputWidget_(d);
}
clearPreedit_InputWidget_(d);
deleteMarked_InputWidget_(d);
insertRange_InputWidget_(d, range_CStr(ev->text.text));
contentsWereChanged_InputWidget_(d);
updateTextInputRect_InputWidget_(d);
return iTrue;
}
if ((ev->type == SDL_TEXTEDITING || ev->type == SDL_TEXTEDITING_EXT) &&
isFocused_Widget(w)) {
/* IME is composing text that hasn't been committed yet (e.g., assembling a
Korean syllable from individual jamo keystrokes). We store it separately
and draw it inline at the cursor position. TEXTEDITING_EXT is used for
composition strings that exceed the 32-byte TEXTEDITING buffer. */
const char *compText;
int compStart, compLen;
if (ev->type == SDL_TEXTEDITING_EXT) {
/* Ownership is transferred; freed below after the text is copied. */
compText = ev->editExt.text;
compStart = ev->editExt.start;
compLen = ev->editExt.length;
}
else {
compText = ev->edit.text;
compStart = ev->edit.start;
compLen = ev->edit.length;
}
if (compText[0] == '\0') {
/* Empty composition: IME cancelled or composition ended without commit. */
clearPreedit_InputWidget_(d);
}
else {
if (isEmpty_String(&d->preedit)) {
/* First event of a new composition. Delete any selected text. */
pushUndo_InputWidget_(d);
deleteMarked_InputWidget_(d);
}
setPreedit_InputWidget_(d, compText, compStart, compLen);
}
if (ev->type == SDL_TEXTEDITING_EXT) {
SDL_free((char *)compText);
}
showCursor_InputWidget_(d);
updateTextInputRect_InputWidget_(d);
d->inFlags |= needUpdateBuffer_InputWidgetFlag;
refresh_Widget(d);
return iTrue;
}
const iInt2 curMax = cursorMax_InputWidget_(d);
Expand All @@ -2624,6 +2749,16 @@ static iBool processEvent_InputWidget_(iInputWidget *d, const SDL_Event *ev) {
return iTrue;
}
if (ev->type == SDL_KEYDOWN && isFocused_Widget(w)) {
/* While an IME composition is active, SDL sends KEYDOWN before the IME
processes the key. Consume unmodified and Option keys so the widget
doesn't also act on them (e.g., Option+Enter for Hanja selection).
Cmd/Ctrl combos are let through as app shortcuts. */
if (!isEmpty_String(&d->preedit) &&
!(ev->key.keysym.mod & (KMOD_GUI | KMOD_CTRL)) &&
ev->key.keysym.sym != SDLK_ESCAPE &&
ev->key.keysym.sym != SDLK_TAB) {
return iTrue;
}
const int key = ev->key.keysym.sym;
const int mods = keyMods_Sym(ev->key.keysym.mod);
#if !LAGRANGE_USE_SYSTEM_TEXT_INPUT
Expand Down Expand Up @@ -3055,17 +3190,78 @@ static void draw_InputWidget_(const iInputWidget *d) {
wrapText.wrapFunc = NULL;
wrapText.context = NULL;
}
int visWrapsAbove = 0;
iInt2 cursorCoord = zero_I2();
const int preeditWidth = d->preeditAdvance;
if (isFocused && contains_Range(&visLines, d->cursor.y)) {
for (int i = d->cursor.y - 1; i >= visLines.start; i--) {
visWrapsAbove += numWrapLines_InputLine_(constAt_Array(&d->lines, i));
}
cursorCoord = relativeCursorCoord_InputWidget_(d);
}
/* Draw IME composition (preedit) text inline at the cursor, with an
underline to distinguish it from committed text. Text after the cursor
is redrawn shifted to the right so it doesn't overlap. */
if (isFocused && !isEmpty_String(&d->preedit) &&
contains_Range(&visLines, d->cursor.y)) {
const iInt2 compPos = add_I2(
addY_I2(topLeft_Rect(contentBounds),
visLineOffsetY + visWrapsAbove * lineHeight_Text(d->font)),
cursorCoord);
const iRangecc compText = range_String(&d->preedit);
const int lh = lineHeight_Text(d->font);
/* Clear from cursor to right edge, then redraw preedit + shifted tail. */
const int clearWidth = right_Rect(contentBounds) - compPos.x;
if (clearWidth > 0) {
fillRect_Paint(&p,
(iRect){ compPos, init_I2(clearWidth, lh) },
uiInputBackgroundFocused_ColorId);
}
drawRange_Text(d->font, compPos, uiInputTextFocused_ColorId, compText);
/* Underline the preedit text. The active segment (preeditCursor..+preeditLength)
gets a thicker underline to distinguish it from the rest. */
const int baseline = compPos.y + lh - 1;
if (d->preeditLength > 0 && d->preeditCursor + d->preeditLength <= size_String(&d->preedit)) {
const iRangecc before = { compText.start, compText.start + d->preeditCursor };
const iRangecc active = { compText.start + d->preeditCursor,
compText.start + d->preeditCursor + d->preeditLength };
const iRangecc after = { active.end, compText.end };
const int xBefore = measureRange_Text(d->font, before).advance.x;
const int xActive = measureRange_Text(d->font, active).advance.x;
if (!isEmpty_Range(&before)) {
drawHLine_Paint(&p, init_I2(compPos.x, baseline),
xBefore, uiInputCursor_ColorId);
}
drawHLine_Paint(&p, init_I2(compPos.x + xBefore, baseline),
xActive, uiInputCursor_ColorId);
drawHLine_Paint(&p, init_I2(compPos.x + xBefore, baseline - 1),
xActive, uiInputCursor_ColorId);
if (!isEmpty_Range(&after)) {
drawHLine_Paint(&p, init_I2(compPos.x + xBefore + xActive, baseline),
preeditWidth - xBefore - xActive, uiInputCursor_ColorId);
}
}
else {
drawHLine_Paint(&p, init_I2(compPos.x, baseline), preeditWidth,
uiInputCursor_ColorId);
}
/* Redraw the text after the cursor, shifted right by the preedit width. */
const char *afterCursor = charPos_InputWidget_(d, d->cursor);
const char *lineEnd = constEnd_String(lineString_InputWidget_(d, d->cursor.y));
if (afterCursor < lineEnd) {
const iRangecc tail = { afterCursor, lineEnd };
drawRange_Text(d->font,
init_I2(compPos.x + preeditWidth, compPos.y),
uiInputTextFocused_ColorId,
tail);
}
}
/* Draw the insertion point. */
if (isFocused && (d->cursorVis || !isBlinkingCursor_()) &&
contains_Range(&visLines, d->cursor.y) &&
(deviceType_App() == desktop_AppDeviceType || isEmpty_Range(&d->mark))) {
iInt2 curSize;
iRangecc cursorChar = iNullRange;
int visWrapsAbove = 0;
for (int i = d->cursor.y - 1; i >= visLines.start; i--) {
const iInputLine *line = constAt_Array(&d->lines, i);
visWrapsAbove += numWrapLines_InputLine_(line);
}
iRangecc cursorChar = iNullRange;
if (d->mode == overwrite_InputMode) {
/* Block cursor that overlaps a character. */
cursorChar.start = charPos_InputWidget_(d, d->cursor);
Expand All @@ -3089,10 +3285,9 @@ static void draw_InputWidget_(const iInputWidget *d) {
/* Bar cursor. */
curSize = init_I2(gap_UI / 2, lineHeight_Text(d->font));
}
const iInt2 advance = relativeCursorCoord_InputWidget_(d);
const iInt2 curPos = add_I2(addY_I2(topLeft_Rect(contentBounds), visLineOffsetY +
visWrapsAbove * lineHeight_Text(d->font)),
addX_I2(advance,
addX_I2(addX_I2(cursorCoord, preeditWidth),
(d->mode == insert_InputMode ? -curSize.x / 2 : 0)));
const iRect curRect = { curPos, curSize };
#if defined (SDL_SEAL_CURSES)
Expand Down
4 changes: 3 additions & 1 deletion src/ui/widget.c
Original file line number Diff line number Diff line change
Expand Up @@ -1213,7 +1213,9 @@ iBool containsExpanded_Widget(const iWidget *d, iInt2 windowCoord, int expand) {
}

iLocalDef iBool isKeyboardEvent_(const SDL_Event *ev) {
return (ev->type == SDL_KEYUP || ev->type == SDL_KEYDOWN || ev->type == SDL_TEXTINPUT);
return (ev->type == SDL_KEYUP || ev->type == SDL_KEYDOWN ||
ev->type == SDL_TEXTINPUT || ev->type == SDL_TEXTEDITING ||
ev->type == SDL_TEXTEDITING_EXT);
}

iLocalDef iBool isMouseEvent_(const SDL_Event *ev) {
Expand Down
8 changes: 7 additions & 1 deletion src/ui/window.c
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,10 @@ static uint32_t windowId_SDLEvent_(const SDL_Event *ev) {
return ev->key.windowID;
case SDL_TEXTINPUT:
return ev->text.windowID;
case SDL_TEXTEDITING:
return ev->edit.windowID;
case SDL_TEXTEDITING_EXT:
return ev->editExt.windowID;
case SDL_USEREVENT:
return ev->user.windowID;
default:
Expand Down Expand Up @@ -1587,7 +1591,9 @@ iBool dispatchEvent_Window(iWindow *d, const SDL_Event *ev) {
if (isCommand_SDLEvent(ev) && ev->user.data2 && ev->user.data2 != root) {
continue; /* Not meant for this root. */
}
if ((ev->type == SDL_KEYDOWN || ev->type == SDL_KEYUP || ev->type == SDL_TEXTINPUT)
if ((ev->type == SDL_KEYDOWN || ev->type == SDL_KEYUP ||
ev->type == SDL_TEXTINPUT || ev->type == SDL_TEXTEDITING ||
ev->type == SDL_TEXTEDITING_EXT)
&& d->keyRoot != root) {
if (!isEscapeKeypress_(ev)) {
/* Key events go only to the root with keyboard focus, with the exception
Expand Down