From ede7db738c3d4f80d8641e0f2795e9c7315a03d1 Mon Sep 17 00:00:00 2001 From: OmniaX-Dev Date: Thu, 7 May 2026 17:50:12 +0200 Subject: [PATCH] Finished core TextEdit widget --- .zed/tasks.json | 29 +++ gdb-last.log | 38 +++ other/TODO.txt | 13 +- other/build.nr | 2 +- src/ogfx/gui/Events.hpp | 61 ++++- src/ogfx/gui/Window.cpp | 15 +- src/ogfx/gui/widgets/Text.cpp | 407 +++++++++++++++++++++++++++++++-- src/ogfx/gui/widgets/Text.hpp | 57 ++++- src/ostd/string/String.hpp | 1 + src/ostd/string/TextBuffer.cpp | 8 + src/ostd/string/TextBuffer.hpp | 1 + src/ostd/string/utf8.cpp | 12 + 12 files changed, 609 insertions(+), 35 deletions(-) create mode 100644 .zed/tasks.json create mode 100644 gdb-last.log diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 0000000..998a4f8 --- /dev/null +++ b/.zed/tasks.json @@ -0,0 +1,29 @@ +[ + { + "label": "build", + "command": "./build", + "args": ["debug"], + "cwd": "$ZED_WORKTREE_ROOT", + "use_new_terminal": false, + "allow_concurrent_runs": false, + "reveal": "always", + }, + { + "label": "build run", + "command": "./build", + "args": ["run"], + "cwd": "$ZED_WORKTREE_ROOT", + "use_new_terminal": false, + "allow_concurrent_runs": false, + "reveal": "always", + }, + { + "label": "build dbg", + "command": "./build", + "args": ["dbg"], + "cwd": "$ZED_WORKTREE_ROOT", + "use_new_terminal": false, + "allow_concurrent_runs": false, + "reveal": "always", + }, +] diff --git a/gdb-last.log b/gdb-last.log new file mode 100644 index 0000000..f46d3f5 --- /dev/null +++ b/gdb-last.log @@ -0,0 +1,38 @@ + +This GDB supports auto-downloading debuginfo from the following URLs: + +Enable debuginfod for this session? (y or [n]) [answered N; input not from terminal] +Debuginfod has been disabled. +To make this setting permanent, add 'set debuginfod enabled off' to .gdbinit. +[Thread debugging using libthread_db enabled] +Using host libthread_db library "/usr/lib/libthread_db.so.1". +[New Thread 0x7ffff41726c0 (LWP 82519)] +[New Thread 0x7fffe1dff6c0 (LWP 82522)] +[New Thread 0x7fffe11ff6c0 (LWP 82523)] +[New Thread 0x7fffe09fe6c0 (LWP 82524)] +[New Thread 0x7fffd3fff6c0 (LWP 82525)] +[New Thread 0x7fffd33fe6c0 (LWP 82526)] +[New Thread 0x7fffd2bfd6c0 (LWP 82527)] +[New Thread 0x7fffd23fc6c0 (LWP 82528)] +[New Thread 0x7fffd1bfb6c0 (LWP 82529)] +[New Thread 0x7fffd13fa6c0 (LWP 82530)] +[New Thread 0x7fffd0bf96c0 (LWP 82531)] +[New Thread 0x7fff822bf6c0 (LWP 82538)] +[Thread 0x7fffd0bf96c0 (LWP 82531) exited] +[Thread 0x7fffd13fa6c0 (LWP 82530) exited] +[Thread 0x7fffd1bfb6c0 (LWP 82529) exited] +[Thread 0x7fffd33fe6c0 (LWP 82526) exited] +[Thread 0x7fffd2bfd6c0 (LWP 82527) exited] +[Thread 0x7fffd23fc6c0 (LWP 82528) exited] +[Thread 0x7fffe09fe6c0 (LWP 82524) exited] +[Thread 0x7fffd3fff6c0 (LWP 82525) exited] +[Thread 0x7fffe11ff6c0 (LWP 82523) exited] +[Thread 0x7fffe1dff6c0 (LWP 82522) exited] +[Thread 0x7ffff41726c0 (LWP 82519) exited] +[Thread 0x7ffff7b5bf80 (LWP 82512) exited] +[Thread 0x7fff822bf6c0 (LWP 82538) exited] +[New process 82512] +[Inferior 1 (process 82512) exited normally] +No stack. +No frame selected. +No frame selected. diff --git a/other/TODO.txt b/other/TODO.txt index a57caa1..8ad30c9 100644 --- a/other/TODO.txt +++ b/other/TODO.txt @@ -32,6 +32,8 @@ ***Add utf-8 codepoint walking functions to String ***Change the stored text in KeyEventData from a single char to the full utf-8 string ***Check if Text Entered events are enabled via SDL +***Add modifiers to Event.keyboard path +***Implement max length in TextEdit Implement cursors in Stylesheet FIX: Window getting exponentially bigger when Desktop scale changes FIX: Refreshing scroll when content extent changes @@ -58,7 +60,6 @@ Add setSelectedMenuItem() function to ComboBox FIX: ContextMenu rendering partly outside the screen when showing it close to the right Window border Add ToolBar::addButton overload that takes a ogfx::Icon wrapper Redesign theme overrides to be more robust -Add modifiers to Event.keyboard path Implement a font-fallback chain in FontGlyphAtlas Implement appendText in TextBuffer and TextEdit Implement per-glyph caching in BasicRenderer2D @@ -66,6 +67,11 @@ Added double-click event to widgets Implement shift-selection in TextEdit (mouse/keyboard) Implement ctrl-selection/navigation in TextEdit Implement home/end and pg up/pg down in TextEdit +Implement Undo stack in TextBuffer +Resolve lifetime issue for character filter in TextEdit +Implement notification system +Fix cursor not advancing when selection is present and left arrow is pressed in TextEdit +Check widgets/layouts interactions @@ -101,8 +107,9 @@ Implement following widgets: ***Fill ***ComboBox ***TreeView - Text Input + ***Text Input + ***Numeric Field + ***Password Field Text Area - Numeric Field Color Picker Calendar diff --git a/other/build.nr b/other/build.nr index fc9b6b9..a9c7a68 100644 --- a/other/build.nr +++ b/other/build.nr @@ -1 +1 @@ -2077 +2078 diff --git a/src/ogfx/gui/Events.hpp b/src/ogfx/gui/Events.hpp index 3f818bb..ae8fe0c 100644 --- a/src/ogfx/gui/Events.hpp +++ b/src/ogfx/gui/Events.hpp @@ -22,6 +22,7 @@ #include #include +#include namespace ogfx { @@ -68,18 +69,76 @@ namespace ogfx }; class KeyEventData : public ostd::BaseObject { + public: struct KeyModifiers + { + bool lshift { false }; + bool rshift { false }; + bool ctrl { false }; + bool alt { false }; + bool meta { false }; // Win/Cmd/Super + + inline bool any(void) const { return rshift || lshift || ctrl || alt || meta; } + inline bool anyShift(void) const { return lshift || rshift; } + inline bool shiftOnly(void) const { return (lshift || rshift) && !ctrl && !alt && !meta; } + inline bool ctrlOnly(void) const { return ctrl && !lshift && !rshift && !alt && !meta; } + inline bool metaOnly(void) const { return meta && !lshift && !rshift && !alt && !ctrl; } + inline bool altOnly(void) const { return alt && !lshift && !rshift && !meta && !ctrl; } + inline bool primaryOnly(void) const + { + #ifdef MAC_OS + return metaOnly(); + #else + return ctrlOnly(); + #endif + } + inline bool primaryShiftOnly(void) const + { + #ifdef MAC_OS + return meta && anyShift() && !ctrl && !alt; + #else + return ctrl && anyShift() && !meta && !alt; + #endif + } + inline bool primaryAltOnly(void) const + { + #ifdef MAC_OS + return meta && alt && !ctrl && !anyShift(); + #else + return ctrl && alt && !ctrl && !anyShift(); + #endif + } + inline bool shiftAltOnly(void) const + { + return alt && anyShift() && !ctrl && !meta; + } + inline bool primary(void) const + { + #ifdef MAC_OS + return meta; + #else + return ctrl; + #endif + } + }; public: enum class eKeyEvent { Pressed = 0, Released, Text }; public: - inline KeyEventData(WindowCore& parent, i32 key, const String& _text, eKeyEvent evt) : parentWindow(parent), keyCode(key), text(_text), eventType(evt) + inline KeyEventData(WindowCore& parent, i32 key, const String& _text, eKeyEvent evt, const KeyModifiers& mod) : parentWindow(parent), keyCode(key), text(_text), eventType(evt), modifiers(mod) { setTypeName("ogfx::KeyEventData"); validate(); } + inline bool isCopy(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::C; } + inline bool isCut(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::X; } + inline bool isPaste(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::V; } + inline bool isSelectAll(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::A; } + inline bool isUndo(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::Z; } + inline bool isRedo(void) const { return eventType == eKeyEvent::Pressed && ((modifiers.primaryShiftOnly() && keyCode == KeyCode::Z) || (modifiers.primaryOnly() && keyCode == KeyCode::Y)); } public: i32 keyCode; String text; eKeyEvent eventType; + KeyModifiers modifiers; WindowCore& parentWindow; }; class DropEventData : public ostd::BaseObject diff --git a/src/ogfx/gui/Window.cpp b/src/ogfx/gui/Window.cpp index 5d37f95..1a26513 100644 --- a/src/ogfx/gui/Window.cpp +++ b/src/ogfx/gui/Window.cpp @@ -525,6 +525,15 @@ namespace ogfx void WindowCore::__handle_event(SDL_Event& event) { + auto l_keymod_status = [](SDL_Keymod m) -> KeyEventData::KeyModifiers { + return { + .lshift = (m & SDL_KMOD_LSHIFT) != 0, + .rshift = (m & SDL_KMOD_RSHIFT) != 0, + .ctrl = (m & SDL_KMOD_CTRL) != 0, + .alt = (m & SDL_KMOD_ALT) != 0, + .meta = (m & SDL_KMOD_GUI) != 0, + }; + }; if (event.type == REDRAW_EVENT) { //Doesn't need to do anything, the event exists just to make SDL_WaitEventTimeout() return early @@ -619,17 +628,17 @@ namespace ogfx } else if (event.type == SDL_EVENT_TEXT_INPUT) { - KeyEventData ked(*this, 0, event.text.text, KeyEventData::eKeyEvent::Text); + KeyEventData ked(*this, 0, event.text.text, KeyEventData::eKeyEvent::Text, l_keymod_status(event.key.mod)); ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::TextEntered, ostd::Signal::Priority::RealTime, ked); } else if (event.type == SDL_EVENT_KEY_DOWN) { - KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Pressed); + KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Pressed, l_keymod_status(event.key.mod)); ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::KeyPressed, ostd::Signal::Priority::RealTime, ked); } else if (event.type == SDL_EVENT_KEY_UP) { - KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Released); + KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Released, l_keymod_status(event.key.mod)); ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::KeyReleased, ostd::Signal::Priority::RealTime, ked); } __on_event(event); diff --git a/src/ogfx/gui/widgets/Text.cpp b/src/ogfx/gui/widgets/Text.cpp index c12b1ad..cadf90a 100644 --- a/src/ogfx/gui/widgets/Text.cpp +++ b/src/ogfx/gui/widgets/Text.cpp @@ -26,6 +26,224 @@ namespace ogfx { namespace gui { + String TextEdit::CharacterFilter::predictResult(const String& utf8, const ostd::TextBuffer& buffer) + { + const String& current = buffer.text(); + const u32 selStart = buffer.selectionStart(); + const u32 selEnd = buffer.selectionEnd(); + String head = current.new_substr(0, cast(selStart)); + String tail = current.new_substr(selEnd, cast(current.len())); + return head + utf8 + tail; + } + + + + + + bool TextEdit::IntegerFilter::isValidChar(const String& utf8, const ostd::TextBuffer& buffer) + { + // Reject anything that isn't a single ASCII byte. Multi-codepoint + // input (IME, emoji, accented chars) is never valid in an integer. + if (utf8.len() != 1) return false; + const char c = utf8[0]; + const bool isDigit = (c >= '0' && c <= '9'); + const bool isSign = (c == '-'); + if (!isDigit && !isSign) return false; + + // Predict the post-insert string and validate it as a prefix. + const String result = predictResult(utf8, buffer); + return isValidIntegerPrefix(result); + } + + bool TextEdit::IntegerFilter::isValidIntegerPrefix(const String& s) const + { + if (s.empty()) return true; + const ostd::cpp_string& bytes = s.cpp_str(); + + u32 i = 0; + // Optional leading minus. + if (bytes[0] == '-') { + if (!m_allowNegative) return false; + i = 1; + if (bytes.size() == 1) return true; // Just "-" by itself: valid prefix. + } + + // Everything from i onward must be digits. Count them while we're at it. + u32 digitCount = 0; + while (i < bytes.size()) { + if (bytes[i] < '0' || bytes[i] > '9') return false; + digitCount++; + i++; + } + + if (m_maxDigits > 0 && digitCount > cast(m_maxDigits)) + return false; + + return true; + } + + + + + + bool TextEdit::DecimalFilter::isValidChar(const String& utf8, const ostd::TextBuffer& buffer) + { + if (utf8.len() != 1) return false; + const char c = utf8[0]; + const bool isDigit = (c >= '0' && c <= '9'); + const bool isSign = (c == '-'); + const bool isSep = (c == m_separator); + if (!isDigit && !isSign && !isSep) return false; + + const String result = predictResult(utf8, buffer); + return isValidDecimalPrefix(result); + } + + bool TextEdit::DecimalFilter::isValidDecimalPrefix(const String& s) const + { + if (s.empty()) return true; + const ostd::cpp_string& bytes = s.cpp_str(); + + u32 i = 0; + if (bytes[0] == '-') { + if (!m_allowNegative) return false; + i = 1; + if (bytes.size() == 1) return true; // Just "-": valid prefix. + } + + u32 intDigits = 0; + u32 fracDigits = 0; + bool seenSep = false; + + while (i < bytes.size()) { + const char c = bytes[i]; + if (c == m_separator) { + if (seenSep) return false; // Two separators: invalid. + if (intDigits == 0) return false; // ".5" without a leading digit: + // we choose to require it for now. + seenSep = true; + } + else if (c >= '0' && c <= '9') { + if (seenSep) fracDigits++; + else intDigits++; + } + else { + return false; + } + i++; + } + + if (m_maxIntDigits > 0 && intDigits > cast(m_maxIntDigits)) return false; + if (m_maxFracDigits > 0 && fracDigits > cast(m_maxFracDigits)) return false; + + return true; + } + + + + + + bool TextEdit::DateFilter::isValidChar(const String& utf8, const ostd::TextBuffer& buffer) + { + if (utf8.len() != 1) return false; + const String result = predictResult(utf8, buffer); + return isValidDatePrefix(result); + } + + void TextEdit::DateFilter::buildPositionMap(void) + { + m_slots.clear(); + // Local helper to push a run of digit slots. + auto pushDigits = [&](eSlot s, u32 n) { + for (u32 i = 0; i < n; i++) m_slots.push_back(s); + }; + auto pushSep = [&]() { m_slots.push_back(eSlot::Sep); }; + + switch (m_format) { + case eFormat::DDMMYY: + pushDigits(eSlot::Day, 2); pushSep(); + pushDigits(eSlot::Month, 2); pushSep(); + pushDigits(eSlot::Year, 2); break; + case eFormat::DDMMYYYY: + pushDigits(eSlot::Day, 2); pushSep(); + pushDigits(eSlot::Month, 2); pushSep(); + pushDigits(eSlot::Year, 4); break; + case eFormat::MMDDYY: + pushDigits(eSlot::Month, 2); pushSep(); + pushDigits(eSlot::Day, 2); pushSep(); + pushDigits(eSlot::Year, 2); break; + case eFormat::MMDDYYYY: + pushDigits(eSlot::Month, 2); pushSep(); + pushDigits(eSlot::Day, 2); pushSep(); + pushDigits(eSlot::Year, 4); break; + case eFormat::YYYYMMDD: + pushDigits(eSlot::Year, 4); pushSep(); + pushDigits(eSlot::Month, 2); pushSep(); + pushDigits(eSlot::Day, 2); break; + case eFormat::YYMMDD: + pushDigits(eSlot::Year, 2); pushSep(); + pushDigits(eSlot::Month, 2); pushSep(); + pushDigits(eSlot::Day, 2); break; + } + } + + bool TextEdit::DateFilter::isValidDatePrefix(const String& s) const + { + const ostd::cpp_string& bytes = s.cpp_str(); + if (bytes.size() > m_slots.size()) return false; + + // Track the running values so we can validate ranges as soon as + // a field is fully entered. + u32 dayVal = 0, dayChars = 0; + u32 monthVal = 0, monthChars = 0; + u32 yearVal = 0, yearChars = 0; + + for (u32 i = 0; i < bytes.size(); i++) { + const char c = bytes[i]; + const eSlot expected = m_slots[i]; + + if (expected == eSlot::Sep) { + if (c != m_separator) return false; + // Separator means a field just ended — validate it. + // (Only the field that *just ended* needs validation; earlier + // fields were already validated when their separators were typed.) + } + else { + if (c < '0' || c > '9') return false; + const u32 d = cast(c - '0'); + if (expected == eSlot::Day) { dayVal = dayVal * 10 + d; dayChars++; } + if (expected == eSlot::Month) { monthVal = monthVal * 10 + d; monthChars++; } + if (expected == eSlot::Year) { yearVal = yearVal * 10 + d; yearChars++; } + } + + // Range validation as soon as a field is fully populated. + if (expected == eSlot::Day && dayChars == 2) { + if (dayVal < 1 || dayVal > 31) return false; + } + if (expected == eSlot::Month && monthChars == 2) { + if (monthVal < 1 || monthVal > 12) return false; + } + // No range check for year — historically valid years span millennia. + + // Also reject impossible *partial* values: a 2-digit day field + // can't start with '4'-'9' if the second digit would always make + // it exceed 31. Day field: first digit must be 0-3. + // Month field: first digit must be 0-1. + if (expected == eSlot::Day && dayChars == 1) { + if (dayVal > 3) return false; + } + if (expected == eSlot::Month && monthChars == 1) { + if (monthVal > 1) return false; + } + } + + return true; + } + + + + + TextEdit& TextEdit::create(void) { setPadding({ 0, 0, 0, 0 }); @@ -118,7 +336,12 @@ namespace ogfx // code path (which handles kerning, atlas, etc.). // ------------------------------------------------------------- if (!m_buffer.empty()) - gfx.drawString(m_buffer.text(), { textX, textY }, getTextColor(), getFontSize()); + { + if (std::isprint(m_charMask)) + gfx.drawString(String::duplicateChar(m_charMask, m_buffer.text().len()), { textX, textY }, getTextColor(), getFontSize()); + else + gfx.drawString(m_buffer.text(), { textX, textY }, getTextColor(), getFontSize()); + } // ------------------------------------------------------------- // 8. Draw the cursor. Only when focused and the blink is in the @@ -182,16 +405,15 @@ namespace ogfx void TextEdit::onTextEntered(const Event& event) { auto& data = *event.keyboard; - if (!m_charFilter || m_charFilter->isValidChar(data.text)) + const String toInsert = clamp_input_to_max_length(data.text); + if (toInsert.empty()) return; + if (!m_charFilter || m_charFilter->isValidChar(data.text, m_buffer)) { - if (m_lastChar == data.text && m_keyRepeatTimer.isCounting()) + if (m_lastChar == toInsert && m_keyRepeatTimer.isCounting()) return; - m_buffer.insertText(data.text); - m_lastChar = data.text; + m_buffer.insertText(toInsert); + m_lastChar = toInsert; m_keyRepeatTimer.start(); - if (m_cursorBlink) - m_cursorBlinkTimer.start(); - m_cursorState = true; } } @@ -200,37 +422,114 @@ namespace ogfx auto& data = *event.keyboard; if (m_lastKeyCode == data.keyCode && m_keyRepeatTimer.isCounting()) return; - if (data.keyCode == KeyCode::Backspace) + + const bool extend = data.modifiers.anyShift(); + const bool word = data.modifiers.primary(); + + // ----- Editing operations (chords first, since they're more specific) ----- + + if (data.isCopy()) { - m_keyRepeatTimer.start(); - m_lastKeyCode = data.keyCode; - m_buffer.backspace(); + const String sel = m_buffer.selectedText(); + if (!sel.empty()) + SDL_SetClipboardText(sel); } + else if (data.isCut()) + { + const String sel = m_buffer.selectedText(); + if (!sel.empty()) + { + SDL_SetClipboardText(sel); + m_buffer.deleteSelection(); + } + } + else if (data.isPaste()) + { + const String clipboardText = SDL_GetClipboardText(); + if (!clipboardText.empty()) + { + const String toInsert = clamp_input_to_max_length(clipboardText); + if (!toInsert.empty()) + m_buffer.insertText(toInsert); + } + } + else if (data.isSelectAll()) + { + m_buffer.selectAll(); + } + else if (data.isUndo()) + { + std::cout << "Undo is not implemented yet!\n"; + } + else if (data.isRedo()) + { + std::cout << "Redo is not implemented yet!\n"; + } + + // ----- Cursor movement ----- + else if (data.keyCode == KeyCode::Left) { - m_keyRepeatTimer.start(); - m_lastKeyCode = data.keyCode; - m_buffer.moveLeft(); + if (word) m_buffer.moveWordLeft(extend); + else m_buffer.moveLeft(extend); } else if (data.keyCode == KeyCode::Right) { - m_keyRepeatTimer.start(); + if (word) m_buffer.moveWordRight(extend); + else m_buffer.moveRight(extend); + } + else if (data.keyCode == KeyCode::Home || data.keyCode == KeyCode::Pageup) + { + // PageUp on single-line == Home. When you go multiline, split these. + if (word) m_buffer.moveDocumentStart(extend); + else m_buffer.moveLineStart(extend); + } + else if (data.keyCode == KeyCode::End || data.keyCode == KeyCode::Pagedown) + { + if (word) m_buffer.moveDocumentEnd(extend); + else m_buffer.moveLineEnd(extend); + } - m_lastKeyCode = data.keyCode; - m_buffer.moveRight(); + // ----- Deletion ----- + + else if (data.keyCode == KeyCode::Backspace) + { + if (word) m_buffer.backspaceWord(); + else m_buffer.backspace(); + } + else if (data.keyCode == KeyCode::Delete) + { + if (word) m_buffer.deleteWord(); + else m_buffer.deleteForward(); + } + + // ----- Other ----- + + else if (data.keyCode == KeyCode::Escape) + { + // Clear selection if there is one; otherwise no-op (the widget could + // give up focus here, but that's an opinionated choice — leave it + // up to the application via callback_onEscape if you want). + if (m_buffer.hasSelection()) + m_buffer.clearSelection(); } else if (data.keyCode == KeyCode::Return) { - m_keyRepeatTimer.start(); - m_lastKeyCode = data.keyCode; if (callback_onActionPerformed) callback_onActionPerformed(event); } - } - - void TextEdit::onKeyReleased(const Event& event) - { + else + { + // Unrecognized key — don't engage the repeat throttle, and don't + // record m_lastKeyCode. Returning here keeps the throttle accurate + // (if the user presses an unhandled key followed by a real one, + // the real one fires immediately rather than being suppressed). + return; + } + // Single bookkeeping point for every accepted branch. + m_keyRepeatTimer.start(); + m_lastKeyCode = data.keyCode; } void TextEdit::onMousePressed(const Event& event) @@ -294,6 +593,34 @@ namespace ogfx m_cursorState = true; } + void TextEdit::setText(const String& text) + { + if (m_maxLength <= 0) { + m_buffer.setText(text); + return; + } + + const u32 inputCps = String::utf8::count_codepoints(text.cpp_str()); + if (inputCps <= cast(m_maxLength)) { + m_buffer.setText(text); + return; + } + + m_buffer.setText(String::utf8::truncate(text, cast(m_maxLength))); + } + + void TextEdit::setMaxLength(i32 codepoints) + { + m_maxLength = codepoints; + if (m_maxLength <= 0) return; + + const u32 currentCps = m_buffer.codepointCount(); + if (currentCps <= cast(m_maxLength)) return; + + const String truncated = String::utf8::truncate(m_buffer.text(), cast(m_maxLength)); + m_buffer.setText(truncated); + } + void TextEdit::rebuild_layout(BasicRenderer2D& gfx) { m_layout.byteOffsets.clear(); @@ -441,5 +768,37 @@ namespace ogfx const f32 distHi = xs[hi] - doc_x; return cast((distLo <= distHi) ? bs[lo] : bs[hi]); } + + String TextEdit::clamp_input_to_max_length(const String& utf8) const + { + if (m_maxLength <= 0) return utf8; + + const u32 currentCps = m_buffer.codepointCount(); + + u32 selCps = 0; + if (m_buffer.hasSelection()) { + const u32 a = m_buffer.selectionStart(); + const u32 b = m_buffer.selectionEnd(); + const String selected = m_buffer.text().new_substr(a, cast(b)); + selCps = String::utf8::count_codepoints(selected.cpp_str()); + } + + const u32 insertedCps = String::utf8::count_codepoints(utf8.cpp_str()); + const u32 resultCps = currentCps - selCps + insertedCps; + const u32 capCps = cast(m_maxLength); + + if (resultCps <= capCps) return utf8; + + // Overflow. Truncate ONLY in the appending case: cursor at end, no selection. + const bool atEnd = (m_buffer.cursorByteOffset() == m_buffer.byteSize()); + if (!atEnd || m_buffer.hasSelection()) { + // Mid-buffer insert or replace-selection: reject the whole input. + return String(""); + } + + // Appending case: truncate to fit. + const u32 availableCps = capCps - currentCps; + return String::utf8::truncate(utf8, availableCps); + } } } diff --git a/src/ogfx/gui/widgets/Text.hpp b/src/ogfx/gui/widgets/Text.hpp index dc39c08..9c0e26a 100644 --- a/src/ogfx/gui/widgets/Text.hpp +++ b/src/ogfx/gui/widgets/Text.hpp @@ -33,7 +33,53 @@ namespace ogfx public: class CharacterFilter { public: - inline virtual bool isValidChar(const String& utf8) { return true; } + virtual ~CharacterFilter(void) = default; + inline virtual bool isValidChar(const String& utf8, const ostd::TextBuffer& buffer) = 0; + protected: + // Helper: build the string that would result from inserting `utf8` + // at the current cursor (replacing any selection). Use this in + // subclasses to ask "is the resulting string a valid prefix?" + static String predictResult(const String& utf8, const ostd::TextBuffer& buffer); + }; + public: class IntegerFilter : public CharacterFilter + { + public: + inline IntegerFilter(bool allowNegative = true, i32 maxDigits = -1) : m_allowNegative(allowNegative), m_maxDigits(maxDigits) {} + bool isValidChar(const String& utf8, const ostd::TextBuffer& buffer) override; + private: + bool isValidIntegerPrefix(const String& s) const; + private: + bool m_allowNegative; + i32 m_maxDigits; + }; + public: class DecimalFilter : public CharacterFilter + { + public: + inline DecimalFilter(bool allowNegative = true, char separator = '.', i32 maxIntegerDigits = -1, i32 maxFractionDigits = -1) : m_allowNegative(allowNegative), m_separator(separator), m_maxIntDigits(maxIntegerDigits), m_maxFracDigits(maxFractionDigits) {} + bool isValidChar(const String& utf8, const ostd::TextBuffer& buffer) override; + private: + bool isValidDecimalPrefix(const String& s) const; + private: + bool m_allowNegative; + char m_separator; + i32 m_maxIntDigits; + i32 m_maxFracDigits; + }; + public: class DateFilter : public CharacterFilter + { + public: enum class eFormat{ DDMMYY, DDMMYYYY, MMDDYY, MMDDYYYY, YYYYMMDD, YYMMDD }; + private: enum class eSlot { Day, Month, Year, Sep }; + + public: + DateFilter(eFormat format = eFormat::DDMMYYYY, char separator = '.') : m_format(format), m_separator(separator) { buildPositionMap(); } + bool isValidChar(const String& utf8, const ostd::TextBuffer& buffer) override; + private: + void buildPositionMap(void); + bool isValidDatePrefix(const String& s) const; + private: + stdvec m_slots; + eFormat m_format; + char m_separator; }; public: struct TextPadding { @@ -58,18 +104,20 @@ namespace ogfx void onFocusLost(const Event& event) override; void onTextEntered(const Event& event) override; void onKeyPressed(const Event& event) override; - void onKeyReleased(const Event& event) override; void onMousePressed(const Event& event) override; void onMouseReleased(const Event& event) override; void onMouseDragged(const Event& event) override; - inline void setText(const String& text) { m_buffer.setText(text); } + void setText(const String& text); + void setMaxLength(i32 codepoints); + inline void setFilter(CharacterFilter* filter) { m_charFilter = filter; } private: void rebuild_layout(BasicRenderer2D& gfx); void ensure_cursor_visible(const Rectangle& bounds); f32 layout_x_for_byte(u32 byte_offset) const; u32 byte_offset_for_pixel_x(f32 widget_local_x) const; + String clamp_input_to_max_length(const String& utf8) const; private: ostd::TextBuffer m_buffer { "", true }; @@ -80,6 +128,8 @@ namespace ogfx bool m_lastClickValid { false }; bool m_mouseSelecting { false }; f32 m_lastMouseLocalX { 0.0f }; + i32 m_maxLength { -1 }; + char m_charMask { '\0' }; CharacterFilter* m_charFilter { nullptr }; String m_lastChar { "" }; i32 m_lastKeyCode { 0 }; @@ -92,5 +142,6 @@ namespace ogfx TextPadding m_textPadding; Color m_selectionColor { 60, 110, 200, 200 }; // bluish, semi-transparent }; + } } diff --git a/src/ostd/string/String.hpp b/src/ostd/string/String.hpp index aa06f61..22fa7b3 100644 --- a/src/ostd/string/String.hpp +++ b/src/ostd/string/String.hpp @@ -199,6 +199,7 @@ namespace ostd // Utilities static u32 count_codepoints(const cpp_string& s); static bool is_valid(const cpp_string& s); + static String truncate(const String& s, u32 maxCps); }; friend std::ostream& operator<<(std::ostream& out, const String& val); diff --git a/src/ostd/string/TextBuffer.cpp b/src/ostd/string/TextBuffer.cpp index 0da5729..6fec5a0 100644 --- a/src/ostd/string/TextBuffer.cpp +++ b/src/ostd/string/TextBuffer.cpp @@ -436,6 +436,14 @@ namespace ostd replace_range(start, end, String("")); } + void TextBuffer::deleteSelection(void) + { + if (hasSelection()) { + replace_range(selectionStart(), selectionEnd(), String("")); + return; + } + } + // ============================================================ // toString diff --git a/src/ostd/string/TextBuffer.hpp b/src/ostd/string/TextBuffer.hpp index b9f7d3f..a6f2f47 100644 --- a/src/ostd/string/TextBuffer.hpp +++ b/src/ostd/string/TextBuffer.hpp @@ -121,6 +121,7 @@ namespace ostd void backspaceWord(void); void deleteWord(void); void deleteCurrentLine(void); + void deleteSelection(void); // ===================== Notifications ====================== inline void setOnChanged(ChangeCallback cb) { m_onChanged = std::move(cb); } diff --git a/src/ostd/string/utf8.cpp b/src/ostd/string/utf8.cpp index 3355382..e2dafab 100644 --- a/src/ostd/string/utf8.cpp +++ b/src/ostd/string/utf8.cpp @@ -279,4 +279,16 @@ namespace ostd return true; } + + String String::utf8::truncate(const String& s, u32 maxCps) + { + const cpp_string& bytes = s.cpp_str(); + u32 i = 0; + u32 cpCount = 0; + while (i < bytes.size() && cpCount < maxCps) { + i = String::utf8::next_codepoint_start(bytes, i); + cpCount++; + } + return s.new_substr(0, cast(i)); + } }