/* OmniaFramework - A collection of useful functionality Copyright (C) 2026 OmniaX-Dev This file is part of OmniaFramework. OmniaFramework is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. OmniaFramework is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OmniaFramework. If not, see . */ #include "Text.hpp" #include "../../utils/Keycodes.hpp" #include "../../render/BasicRenderer.hpp" 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::HexadecimalFilter::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 hex. if (utf8.len() != 1) return false; const char c = utf8[0]; if (!isHexDigit(c)) return false; // Predict the post-insert string and validate it as a prefix. const String result = predictResult(utf8, buffer); return isValidHexPrefix(result); } bool TextEdit::HexadecimalFilter::isValidHexPrefix(const String& s) const { if (s.empty()) return true; const ostd::cpp_string& bytes = s.cpp_str(); u32 i = 0; // Everything from i onward must be digits. Count them while we're at it. u32 digitCount = 0; while (i < bytes.size()) { if (!isHexDigit(bytes[i])) return false; digitCount++; i++; } if (m_maxDigits > 0 && digitCount > cast(m_maxDigits)) return false; return true; } bool TextEdit::HexadecimalFilter::isHexDigit(char c) const { return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); } 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 }); setTypeName("ogfx::gui::TextEdit"); disableChildren(); setStylesheetCategoryName("textEdit"); enableFocus(); enableBackground(); enableBorder(); enableTextEnteredEvent(true); setSize({ 200, 30 }); m_cursorBlinkTimer.start(); m_buffer.setOnChanged([this](const ostd::TextBuffer&) { m_layout.dirty = true; }); m_buffer.setOnCursorMoved([this](const ostd::TextBuffer&) { if (m_cursorBlink) m_cursorBlinkTimer.start(); m_cursorState = true; }); validate(); return *this; } void TextEdit::applyTheme(const ostd::Stylesheet& theme) { enableCursorBlink(getThemeValue(theme, "cursorBlink", isCursorBlinkEnabled())); setCursorWidth(getThemeValue(theme, "cursorWidth", getCursorWidth())); setCursorColor(getThemeValue(theme, "cursorColor", getCursorColor())); setSelectionColor(getThemeValue(theme, "selectionColor", getSelectionColor())); setMaxLength(getThemeValue(theme, "macLength", getMaxLength())); setTextPadding(getThemeValue(theme, "textPaddingLeft", getTextPadding().left), getThemeValue(theme, "textPaddingRight", getTextPadding().right)); String mask = getThemeValue(theme, "characterMask", String("")); if (mask.len() != 1) clearCharacterMask(); else setCharacterMask(mask[0]); String styleStr = getThemeValue(theme, "cursorStyle", String("line")); if (styleStr == "block") setCursorStyle(CursorStyle::Block); else if (styleStr == "box") setCursorStyle(CursorStyle::Box); else if (styleStr == "underscore") setCursorStyle(CursorStyle::Underscore); else setCursorStyle(CursorStyle::Line); } void TextEdit::onDraw(ogfx::BasicRenderer2D& gfx) { const auto bounds = getGlobalBounds(); // ------------------------------------------------------------- // 1. Rebuild the layout cache if the buffer (or font/size) has // changed since last frame. We mark dirty in the buffer's // onChanged callback (set up in create()). // ------------------------------------------------------------- if (m_layout.dirty) rebuild_layout(gfx); // ------------------------------------------------------------- // 2. Make sure the cursor is in view. This adjusts m_scrollX as // needed so the cursor sits inside the widget's content area. // ------------------------------------------------------------- ensure_cursor_visible(bounds); // ------------------------------------------------------------- // 3. Compute the text content area inside the widget — the // rectangle where text is allowed to render. Anything drawn // outside this rect gets clipped away. // ------------------------------------------------------------- const Rectangle contentArea { bounds.x + m_textPadding.left, bounds.y, bounds.w - m_textPadding.left - m_textPadding.right, bounds.h }; // ------------------------------------------------------------- // 5. Compute the y position for the text baseline area. Vertically // centered using the cached line height. This is single-line // behavior; for multiline you'd loop over lines and offset y // by lineHeight per row. // ------------------------------------------------------------- const f32 textY = bounds.y + (bounds.h - m_layout.lineHeight) * 0.5f; const f32 textX = contentArea.x - m_scrollX; // ------------------------------------------------------------- // 6. Draw the selection highlight FIRST (under the text). This // way the text stays the regular color and just sits on top // of a colored band — like every text editor. // ------------------------------------------------------------- if (m_buffer.hasSelection()) { const u32 selStart = m_buffer.selectionStart(); const u32 selEnd = m_buffer.selectionEnd(); const f32 x1 = textX + layout_x_for_byte(selStart); const f32 x2 = textX + layout_x_for_byte(selEnd); gfx.fillRect( { x1, textY, x2 - x1, m_layout.lineHeight }, m_selectionColor ); } // ------------------------------------------------------------- // 8. Draw the cursor. Only when focused and the blink is in the // "on" phase. The cursor renders ON TOP of everything else so // it's visible inside selections. // ------------------------------------------------------------- if (isFocused() && (m_cursorState || !m_cursorBlink)) { const f32 cx = textX + layout_x_for_byte(m_buffer.cursorByteOffset()); auto l_char_width_at_cursor = [this](void) -> f32 { const u32 byteOff = m_buffer.cursorByteOffset(); const auto& bs = m_layout.byteOffsets; const auto& xs = m_layout.xPositions; auto it = std::lower_bound(bs.begin(), bs.end(), byteOff); if (it != bs.end() && (it + 1) != bs.end()) { const size_t idx = std::distance(bs.begin(), it); return xs[idx + 1] - xs[idx]; } return m_layout.lineHeight * 0.6f; // fallback at end-of-text }; switch (m_cursorStyle) { case CursorStyle::Line: gfx.fillRect({ cx, textY, m_cursorWidth, m_layout.lineHeight }, m_cursorColor); break; case CursorStyle::Block: { gfx.fillRect({ cx, textY, l_char_width_at_cursor(), m_layout.lineHeight }, m_cursorColor); break; } case CursorStyle::Box: { gfx.drawRect({ cx, textY, l_char_width_at_cursor(), m_layout.lineHeight }, m_cursorColor, m_cursorWidth); break; } case CursorStyle::Underscore: { const f32 underH = std::max(2.0f, m_layout.lineHeight * 0.1f); gfx.fillRect({ cx, textY + m_layout.lineHeight - underH, l_char_width_at_cursor(), m_cursorWidth }, m_cursorColor); break; } } } // ------------------------------------------------------------- // 7. Draw the text itself. Single drawString call — the layout // cache is only used for cursor / selection positioning; // actual glyph rendering goes through the renderer's existing // code path (which handles kerning, atlas, etc.). // ------------------------------------------------------------- if (!m_buffer.empty()) { if (hasCharacterMask()) gfx.drawString(String::duplicateChar(m_charMask, m_buffer.text().len()), { textX, textY }, getTextColor(), getFontSize()); else gfx.drawString(m_buffer.text(), { textX, textY }, getTextColor(), getFontSize()); } } void TextEdit::onUpdate(void) { m_keyRepeatTimer.update(); m_doubleClickTimer.update(); if (m_cursorBlink) { m_cursorBlinkTimer.update(); if (m_cursorBlinkTimer.isDone()) { m_cursorState = !m_cursorState; m_cursorBlinkTimer.start(); } } // Auto-scroll while drag-selecting past the widget edges. if (m_mouseSelecting) { const auto bounds = getGlobalBounds(); const f32 contentLeft = m_textPadding.left; const f32 contentRight = bounds.w - m_textPadding.right; // Only act when mouse is outside the content area horizontally. if (m_lastMouseLocalX < contentLeft || m_lastMouseLocalX > contentRight) { // Use the same hit-test as drag, but it'll naturally clamp to // start/end of buffer when the mouse is way outside. The // setCursorByteOffset call will trigger ensure_cursor_visible // on the next draw, which scrolls the view to follow. const u32 hit = byte_offset_for_pixel_x(m_lastMouseLocalX); m_buffer.setCursorByteOffset(hit, true); } } } void TextEdit::onFocusLost(const Event& event) { if (m_buffer.hasSelection()) m_buffer.clearSelection(); } void TextEdit::onTextEntered(const Event& event) { auto& data = *event.keyboard; const String toInsert = clamp_input_to_max_length(data.text); if (toInsert.empty()) return; if (!m_filter || m_filter->isValidChar(data.text, m_buffer)) { if (m_lastChar == toInsert && m_keyRepeatTimer.isCounting()) return; m_buffer.insertText(toInsert); m_lastChar = toInsert; m_keyRepeatTimer.start(); } } void TextEdit::onKeyPressed(const Event& event) { auto& data = *event.keyboard; if (m_lastKeyCode == data.keyCode && m_keyRepeatTimer.isCounting()) return; const bool extend = data.modifiers.anyShift(); const bool word = data.modifiers.primary(); const bool mask = hasCharacterMask(); // ----- Editing operations (chords first, since they're more specific) ----- if (data.isCopy()) { if (hasCharacterMask() && !isCopyOnMaskEnabled()) return; const String sel = m_buffer.selectedText(); if (!sel.empty()) SDL_SetClipboardText(sel); } else if (data.isCut()) { if (hasCharacterMask() && !isCopyOnMaskEnabled()) return; 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()) { if (m_buffer.canUndo()) m_buffer.undo(); } else if (data.isRedo()) { if (m_buffer.canRedo()) m_buffer.redo(); } // ----- Cursor movement ----- else if (data.keyCode == KeyCode::Left) { if (mask && word) m_buffer.moveDocumentStart(extend); else if (!mask && word) m_buffer.moveWordLeft(extend); else m_buffer.moveLeft(extend); } else if (data.keyCode == KeyCode::Right) { if (mask && word) m_buffer.moveDocumentEnd(extend); else if (!mask && 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); } // ----- Deletion ----- else if (data.keyCode == KeyCode::Backspace) { if (m_buffer.hasSelection()) { m_buffer.deleteSelection(); } else if (mask && word) { if (m_buffer.cursorByteOffset() > 0) { m_buffer.selectRange(0, m_buffer.cursorByteOffset()); m_buffer.deleteSelection(); } } else if (!mask && word) m_buffer.backspaceWord(); else m_buffer.backspace(); } else if (data.keyCode == KeyCode::Delete) { if (m_buffer.hasSelection()) { m_buffer.deleteSelection(); } else if (mask && word) { if (m_buffer.cursorByteOffset() < m_buffer.byteSize()) { m_buffer.selectRange(m_buffer.cursorByteOffset(), m_buffer.byteSize()); m_buffer.deleteSelection(); } } else if (!mask && 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) { if (callback_onActionPerformed) callback_onActionPerformed(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) { auto& data = *event.mouse; const f32 local_x = data.position_x - getGlobalBounds().x; const u32 hit = byte_offset_for_pixel_x(local_x); // Double-click detection: a second press within the timer window AND // close enough to the last press position counts as a double-click. // The position check matters — without it, two presses far apart in // a long document would falsely trigger word selection. constexpr f32 doubleClickRadius = 5.0f; const bool isDoubleClick = m_lastClickValid && m_doubleClickTimer.isCounting() && std::abs(local_x - m_lastClickLocalX) < doubleClickRadius; if (isDoubleClick) { // Word selection. selectWordAt sets both anchor and cursor; // we mark the drag flag so subsequent drag motion extends the // selection by word — though we'll keep the simple implementation // for now where dragging after a double-click extends by character. if (hasCharacterMask()) m_buffer.selectAll(); else m_buffer.selectWordAt(hit); m_mouseSelecting = true; m_lastClickValid = false; // Don't triple-click into something weird. } else { m_buffer.setCursorByteOffset(hit); m_mouseSelecting = true; m_lastClickLocalX = local_x; m_lastMouseLocalX = local_x; m_lastClickValid = true; m_doubleClickTimer.start(); } if (m_cursorBlink) m_cursorBlinkTimer.start(); m_cursorState = true; } void TextEdit::onMouseReleased(const Event& event) { m_mouseSelecting = false; } void TextEdit::onMouseDragged(const Event& event) { if (!m_mouseSelecting) return; auto& data = *event.mouse; const f32 local_x = data.position_x - getGlobalBounds().x; const u32 hit = byte_offset_for_pixel_x(local_x); // Extend selection to the dragged-to position. The anchor was set by // setCursorByteOffset(hit) in onMousePressed and stays put. m_buffer.setCursorByteOffset(hit, true); m_lastMouseLocalX = local_x; if (m_cursorBlink) m_cursorBlinkTimer.start(); 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(); m_layout.xPositions.clear(); m_layout.totalWidth = 0.0f; // Empty buffer: a single entry for "x=0 at byte 0" so the cursor // still has a position to render at. if (m_buffer.empty()) { m_layout.lineHeight = gfx.getStringDimensions(" ", getFontSize()).y; m_layout.byteOffsets.push_back(0); m_layout.xPositions.push_back(0.0f); m_layout.dirty = false; return; } const String displayText = hasCharacterMask() ? String::duplicateChar(m_charMask, m_buffer.text().len()) : m_buffer.text(); // One call to the renderer. Returns one Vec2 per codepoint: // .x = advance (incl. kerning) contributed by that glyph // .y = glyph height (same for all glyphs at this font/size) const stdvec perChar = gfx.getStringDimensionsPerCharacter(displayText, getFontSize()); if (perChar.empty()) { // Defensive: shouldn't happen with non-empty text, but if the // font failed to load we still want a valid layout. m_layout.lineHeight = 0.0f; m_layout.byteOffsets.push_back(0); m_layout.xPositions.push_back(0.0f); m_layout.dirty = false; return; } m_layout.lineHeight = perChar[0].y; // Walk both the per-character advances AND the buffer's UTF-8 bytes // in lockstep. Since processString emits exactly one glyph per // codepoint, the i-th advance corresponds to the codepoint that // starts at the i-th byte boundary. const ostd::cpp_string& s = m_buffer.text().cpp_str(); f32 x = 0.0f; u32 byte_pos = 0; size_t glyph_idx = 0; // First entry: position before any glyph. m_layout.byteOffsets.push_back(0); m_layout.xPositions.push_back(0.0f); while (byte_pos < s.size() && glyph_idx < perChar.size()) { x += perChar[glyph_idx].x; byte_pos = String::utf8::next_codepoint_start(s, byte_pos); m_layout.byteOffsets.push_back(byte_pos); m_layout.xPositions.push_back(x); glyph_idx++; } m_layout.totalWidth = x; m_layout.dirty = false; } f32 TextEdit::layout_x_for_byte(u32 byte_offset) const { // Binary search for the entry. Layout invariant: byteOffsets is // strictly increasing, so std::lower_bound gives us the right index. const auto& v = m_layout.byteOffsets; if (v.empty()) return 0.0f; if (byte_offset >= v.back()) return m_layout.xPositions.back(); auto it = std::lower_bound(v.begin(), v.end(), byte_offset); const size_t idx = std::distance(v.begin(), it); return m_layout.xPositions[idx]; } void TextEdit::ensure_cursor_visible(const Rectangle& bounds) { const f32 contentW = bounds.w - m_textPadding.left - m_textPadding.right; if (contentW <= 0.0f) return; const f32 cursorX = layout_x_for_byte(m_buffer.cursorByteOffset()); const f32 totalW = m_layout.totalWidth; // Margin: how far the cursor should stay from the visible edges. // ~2 character widths feels right; using line height as a proxy works // well across font sizes since char width scales similarly. // This single-sourced value also caps itself at 25% of contentW so it // stays sensible even in very narrow widgets. const f32 marginRaw = m_layout.lineHeight * 0.6f; const f32 margin = std::min(marginRaw, contentW * 0.25f); // Case 1: all the text fits. No scroll needed. if (totalW <= contentW) { m_scrollX = 0.0f; return; } // Case 2: text overflows. Apply margin-aware scrolling. // // The visible window in document space is [m_scrollX, m_scrollX + contentW). // We want cursorX to satisfy: // m_scrollX + margin <= cursorX <= m_scrollX + contentW - margin // — except clamped at the document edges. // If cursor is too close to the left edge of the visible window, scroll // left so there's `margin` of text visible to its left. if (cursorX < m_scrollX + margin) m_scrollX = cursorX - margin; // If cursor is too close to the right edge, scroll right so there's // `margin` of room to its right. else if (cursorX > m_scrollX + contentW - margin) m_scrollX = cursorX - contentW + margin; // Clamp scrollX to valid range. The lower bound is 0 (don't show empty // space to the left of the text). The upper bound is totalW - contentW // (don't show empty space to the right of the text — i.e., the right // edge of the text aligns with the right edge of the visible area). // // This upper clamp is what produces the "right-anchored when at end" // behavior automatically: when cursor is at end, the margin formula // wants to scroll past the end of the text, but we clamp it back so // the text's right edge sits at the visible area's right edge. if (m_scrollX < 0.0f) m_scrollX = 0.0f; if (m_scrollX > totalW - contentW) m_scrollX = totalW - contentW; } u32 TextEdit::byte_offset_for_pixel_x(f32 widget_local_x) const { const f32 doc_x = widget_local_x - m_textPadding.left + m_scrollX; const auto& xs = m_layout.xPositions; const auto& bs = m_layout.byteOffsets; if (xs.empty()) return 0; if (doc_x <= xs.front()) return bs.front(); if (doc_x >= xs.back()) return bs.back(); auto it = std::upper_bound(xs.begin(), xs.end(), doc_x); const size_t hi = std::distance(xs.begin(), it); const size_t lo = hi - 1; const f32 distLo = doc_x - xs[lo]; 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); } } }