From 52a2730ccbd014efc6705e140b4e3965fe2cfbb1 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Sat, 14 Mar 2026 18:03:31 -0500 Subject: [PATCH] Added masked editing and password entry. --- .gitignore | 1 + dvx/dvxWidget.h | 34 ++- dvx/widgets/widgetTextInput.c | 397 +++++++++++++++++++++++++++++++++- dvxdemo/demo.c | 8 +- 4 files changed, 427 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index bc2e0b7..b930465 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ lib/ *.~ .gitignore~ DVX_GUI_DESIGN.md +*.SWP diff --git a/dvx/dvxWidget.h b/dvx/dvxWidget.h index a52f1d0..80f591d 100644 --- a/dvx/dvxWidget.h +++ b/dvx/dvxWidget.h @@ -94,6 +94,16 @@ typedef enum { FrameFlatE // solid color line } FrameStyleE; +// ============================================================ +// Text input mode enum +// ============================================================ + +typedef enum { + InputNormalE, // default free-form text + InputPasswordE, // displays bullets, no copy + InputMaskedE // format mask (e.g. "(###) ###-####") +} InputModeE; + // ============================================================ // Widget structure // ============================================================ @@ -177,16 +187,18 @@ typedef struct WidgetT { } radio; struct { - char *buf; - int32_t bufSize; - int32_t len; - int32_t cursorPos; - int32_t scrollOff; - int32_t selStart; // selection anchor (-1 = none) - int32_t selEnd; // selection end (-1 = none) - char *undoBuf; - int32_t undoLen; - int32_t undoCursor; + char *buf; + int32_t bufSize; + int32_t len; + int32_t cursorPos; + int32_t scrollOff; + int32_t selStart; // selection anchor (-1 = none) + int32_t selEnd; // selection end (-1 = none) + char *undoBuf; + int32_t undoLen; + int32_t undoCursor; + InputModeE inputMode; + const char *mask; // format mask for InputMaskedE } textInput; struct { @@ -385,6 +397,8 @@ WidgetT *wgtLabel(WidgetT *parent, const char *text); WidgetT *wgtButton(WidgetT *parent, const char *text); WidgetT *wgtCheckbox(WidgetT *parent, const char *text); WidgetT *wgtTextInput(WidgetT *parent, int32_t maxLen); +WidgetT *wgtPasswordInput(WidgetT *parent, int32_t maxLen); +WidgetT *wgtMaskedInput(WidgetT *parent, const char *mask); // ============================================================ // Radio buttons diff --git a/dvx/widgets/widgetTextInput.c b/dvx/widgets/widgetTextInput.c index bdce545..eba89e8 100644 --- a/dvx/widgets/widgetTextInput.c +++ b/dvx/widgets/widgetTextInput.c @@ -17,6 +17,12 @@ // Prototypes // ============================================================ +static bool maskCharValid(char slot, char ch); +static int32_t maskFirstSlot(const char *mask); +static bool maskIsSlot(char ch); +static int32_t maskNextSlot(const char *mask, int32_t pos); +static int32_t maskPrevSlot(const char *mask, int32_t pos); +static void maskedInputOnKey(WidgetT *w, int32_t key, int32_t mod); static int32_t textAreaCountLines(const char *buf, int32_t len); static int32_t textAreaCursorToOff(const char *buf, int32_t len, int32_t row, int32_t col); static void textAreaEnsureVisible(WidgetT *w, int32_t visRows, int32_t visCols); @@ -297,6 +303,375 @@ WidgetT *wgtTextInput(WidgetT *parent, int32_t maxLen) { } +WidgetT *wgtPasswordInput(WidgetT *parent, int32_t maxLen) { + WidgetT *w = wgtTextInput(parent, maxLen); + + if (w) { + w->as.textInput.inputMode = InputPasswordE; + } + + return w; +} + + +WidgetT *wgtMaskedInput(WidgetT *parent, const char *mask) { + if (!mask) { + return NULL; + } + + int32_t maskLen = (int32_t)strlen(mask); + WidgetT *w = wgtTextInput(parent, maskLen); + + if (w) { + w->as.textInput.inputMode = InputMaskedE; + w->as.textInput.mask = mask; + + // Pre-fill buffer: literals copied as-is, slots filled with '_' + for (int32_t i = 0; i < maskLen; i++) { + if (maskIsSlot(mask[i])) { + w->as.textInput.buf[i] = '_'; + } else { + w->as.textInput.buf[i] = mask[i]; + } + } + + w->as.textInput.buf[maskLen] = '\0'; + w->as.textInput.len = maskLen; + w->as.textInput.cursorPos = maskFirstSlot(mask); + } + + return w; +} + + +// ============================================================ +// Mask helpers +// ============================================================ + +static bool maskIsSlot(char ch) { + return ch == '#' || ch == 'A' || ch == '*'; +} + + +static bool maskCharValid(char slot, char ch) { + switch (slot) { + case '#': + return ch >= '0' && ch <= '9'; + case 'A': + return isalpha((unsigned char)ch); + case '*': + return ch >= 32 && ch < 127; + default: + return false; + } +} + + +static int32_t maskFirstSlot(const char *mask) { + for (int32_t i = 0; mask[i]; i++) { + if (maskIsSlot(mask[i])) { + return i; + } + } + + return 0; +} + + +static int32_t maskNextSlot(const char *mask, int32_t pos) { + for (int32_t i = pos + 1; mask[i]; i++) { + if (maskIsSlot(mask[i])) { + return i; + } + } + + return (int32_t)strlen(mask); +} + + +static int32_t maskPrevSlot(const char *mask, int32_t pos) { + for (int32_t i = pos - 1; i >= 0; i--) { + if (maskIsSlot(mask[i])) { + return i; + } + } + + return pos; +} + + +static void maskedInputOnKey(WidgetT *w, int32_t key, int32_t mod) { + char *buf = w->as.textInput.buf; + const char *mask = w->as.textInput.mask; + int32_t *pCur = &w->as.textInput.cursorPos; + int32_t maskLen = w->as.textInput.len; + bool shift = (mod & KEY_MOD_SHIFT) != 0; + + (void)shift; + + // Ctrl+A — select all + if (key == 1) { + w->as.textInput.selStart = 0; + w->as.textInput.selEnd = maskLen; + *pCur = maskLen; + goto done; + } + + // Ctrl+C — copy formatted text + if (key == 3) { + int32_t selLo = -1; + int32_t selHi = -1; + + if (w->as.textInput.selStart >= 0 && w->as.textInput.selEnd >= 0 && w->as.textInput.selStart != w->as.textInput.selEnd) { + selLo = w->as.textInput.selStart < w->as.textInput.selEnd ? w->as.textInput.selStart : w->as.textInput.selEnd; + selHi = w->as.textInput.selStart < w->as.textInput.selEnd ? w->as.textInput.selEnd : w->as.textInput.selStart; + clipboardCopy(buf + selLo, selHi - selLo); + } + + return; + } + + // Ctrl+V — paste valid chars into slots + if (key == 22) { + int32_t clipLen; + const char *clip = clipboardGet(&clipLen); + + if (clipLen > 0) { + if (w->as.textInput.undoBuf) { + textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize); + } + + int32_t slotPos = *pCur; + bool changed = false; + + for (int32_t i = 0; i < clipLen && slotPos < maskLen; i++) { + // Skip to next slot if not on one + while (slotPos < maskLen && !maskIsSlot(mask[slotPos])) { + slotPos++; + } + + if (slotPos >= maskLen) { + break; + } + + // Skip non-matching clipboard chars + if (maskCharValid(mask[slotPos], clip[i])) { + buf[slotPos] = clip[i]; + slotPos = maskNextSlot(mask, slotPos); + changed = true; + } + } + + if (changed) { + *pCur = slotPos; + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + + if (w->onChange) { + w->onChange(w); + } + } + } + + goto done; + } + + // Ctrl+X — copy and clear selected slots + if (key == 24) { + int32_t selLo = -1; + int32_t selHi = -1; + + if (w->as.textInput.selStart >= 0 && w->as.textInput.selEnd >= 0 && w->as.textInput.selStart != w->as.textInput.selEnd) { + selLo = w->as.textInput.selStart < w->as.textInput.selEnd ? w->as.textInput.selStart : w->as.textInput.selEnd; + selHi = w->as.textInput.selStart < w->as.textInput.selEnd ? w->as.textInput.selEnd : w->as.textInput.selStart; + clipboardCopy(buf + selLo, selHi - selLo); + + if (w->as.textInput.undoBuf) { + textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize); + } + + for (int32_t i = selLo; i < selHi; i++) { + if (maskIsSlot(mask[i])) { + buf[i] = '_'; + } + } + + *pCur = selLo; + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + + if (w->onChange) { + w->onChange(w); + } + } + + goto done; + } + + // Ctrl+Z — undo + if (key == 26 && w->as.textInput.undoBuf) { + char tmpBuf[CLIPBOARD_MAX]; + int32_t tmpCursor = *pCur; + memcpy(tmpBuf, buf, maskLen + 1); + + memcpy(buf, w->as.textInput.undoBuf, maskLen + 1); + *pCur = w->as.textInput.undoCursor < maskLen ? w->as.textInput.undoCursor : maskLen; + + memcpy(w->as.textInput.undoBuf, tmpBuf, maskLen + 1); + w->as.textInput.undoCursor = tmpCursor; + + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + + if (w->onChange) { + w->onChange(w); + } + + goto done; + } + + if (key >= 32 && key < 127) { + // Printable character — place at current slot if valid + if (*pCur < maskLen && maskIsSlot(mask[*pCur]) && maskCharValid(mask[*pCur], (char)key)) { + if (w->as.textInput.undoBuf) { + textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize); + } + + buf[*pCur] = (char)key; + *pCur = maskNextSlot(mask, *pCur); + + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + + if (w->onChange) { + w->onChange(w); + } + } + } else if (key == 8) { + // Backspace — clear previous slot + int32_t prev = maskPrevSlot(mask, *pCur); + + if (prev != *pCur) { + if (w->as.textInput.undoBuf) { + textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize); + } + + buf[prev] = '_'; + *pCur = prev; + + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + + if (w->onChange) { + w->onChange(w); + } + } + } else if (key == (0x53 | 0x100)) { + // Delete — clear current slot + if (*pCur < maskLen && maskIsSlot(mask[*pCur])) { + if (w->as.textInput.undoBuf) { + textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize); + } + + buf[*pCur] = '_'; + + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + + if (w->onChange) { + w->onChange(w); + } + } + } else if (key == (0x4B | 0x100)) { + // Left arrow — move to previous slot + int32_t prev = maskPrevSlot(mask, *pCur); + + if (shift) { + if (w->as.textInput.selStart < 0) { + w->as.textInput.selStart = *pCur; + w->as.textInput.selEnd = *pCur; + } + + *pCur = prev; + w->as.textInput.selEnd = *pCur; + } else { + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + *pCur = prev; + } + } else if (key == (0x4D | 0x100)) { + // Right arrow — move to next slot + int32_t next = maskNextSlot(mask, *pCur); + + if (shift) { + if (w->as.textInput.selStart < 0) { + w->as.textInput.selStart = *pCur; + w->as.textInput.selEnd = *pCur; + } + + *pCur = next; + w->as.textInput.selEnd = *pCur; + } else { + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + *pCur = next; + } + } else if (key == (0x47 | 0x100)) { + // Home — first slot + if (shift) { + if (w->as.textInput.selStart < 0) { + w->as.textInput.selStart = *pCur; + w->as.textInput.selEnd = *pCur; + } + + *pCur = maskFirstSlot(mask); + w->as.textInput.selEnd = *pCur; + } else { + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + *pCur = maskFirstSlot(mask); + } + } else if (key == (0x4F | 0x100)) { + // End — past last slot + int32_t last = maskLen; + + if (shift) { + if (w->as.textInput.selStart < 0) { + w->as.textInput.selStart = *pCur; + w->as.textInput.selEnd = *pCur; + } + + *pCur = last; + w->as.textInput.selEnd = *pCur; + } else { + w->as.textInput.selStart = -1; + w->as.textInput.selEnd = -1; + *pCur = last; + } + } else { + return; + } + +done: + // Adjust scroll + { + AppContextT *ctx = (AppContextT *)w->window->widgetRoot->userData; + const BitmapFontT *font = &ctx->font; + int32_t visibleChars = (w->w - TEXT_INPUT_PAD * 2) / font->charWidth; + + if (*pCur < w->as.textInput.scrollOff) { + w->as.textInput.scrollOff = *pCur; + } + + if (*pCur >= w->as.textInput.scrollOff + visibleChars) { + w->as.textInput.scrollOff = *pCur - visibleChars + 1; + } + } + + wgtInvalidate(w); +} + + // ============================================================ // TextArea line helpers // ============================================================ @@ -1550,6 +1925,18 @@ void widgetTextInputOnKey(WidgetT *w, int32_t key, int32_t mod) { clearOtherSelections(w); + if (w->as.textInput.inputMode == InputMaskedE) { + maskedInputOnKey(w, key, mod); + return; + } + + // Password mode: block copy (Ctrl+C) and cut (Ctrl+X) + if (w->as.textInput.inputMode == InputPasswordE) { + if (key == 3 || key == 24) { + return; + } + } + widgetTextEditOnKey(w, key, mod, w->as.textInput.buf, w->as.textInput.bufSize, &w->as.textInput.len, &w->as.textInput.cursorPos, &w->as.textInput.scrollOff, @@ -1648,6 +2035,8 @@ void widgetTextInputPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bi selHi = w->as.textInput.selStart < w->as.textInput.selEnd ? w->as.textInput.selEnd : w->as.textInput.selStart; } + bool isPassword = (w->as.textInput.inputMode == InputPasswordE); + for (int32_t i = 0; i < len; i++) { int32_t charIdx = off + i; uint32_t cfgc = fg; @@ -1658,8 +2047,14 @@ void widgetTextInputPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bi cbgc = colors->menuHighlightBg; } + char displayCh = w->as.textInput.buf[charIdx]; + + if (isPassword) { + displayCh = '\xF9'; // CP437 bullet + } + drawChar(d, ops, font, textX + i * font->charWidth, textY, - w->as.textInput.buf[charIdx], cfgc, cbgc, true); + displayCh, cfgc, cbgc, true); } // Draw cursor diff --git a/dvxdemo/demo.c b/dvxdemo/demo.c index 5a966cf..25b7b5e 100644 --- a/dvxdemo/demo.c +++ b/dvxdemo/demo.c @@ -700,8 +700,12 @@ static void setupWidgetDemo(AppContextT *ctx) { wgtTextInput(row1, 64); WidgetT *row2 = wgtHBox(frame); - wgtLabel(row2, "A&ddress:"); - wgtTextInput(row2, 64); + wgtLabel(row2, "&Password:"); + wgtPasswordInput(row2, 32); + + WidgetT *row3 = wgtHBox(frame); + wgtLabel(row3, "P&hone:"); + wgtMaskedInput(row3, "(###) ###-####"); wgtHSeparator(root);