From 04e595ca9c3891c7418a45b52b366267ef5f4370 Mon Sep 17 00:00:00 2001 From: OmniaX-Dev Date: Fri, 8 May 2026 07:17:51 +0200 Subject: [PATCH] Polished TextEdit widget --- extra/DefaultThemeDark.oss | 6 ++ other/TODO.txt | 1 + src/ogfx/gui/widgets/Text.cpp | 145 ++++++++++++++++++++++++++-------- src/ogfx/gui/widgets/Text.hpp | 50 +++++++++--- 4 files changed, 156 insertions(+), 46 deletions(-) diff --git a/extra/DefaultThemeDark.oss b/extra/DefaultThemeDark.oss index bb1ee80..51d3ec3 100644 --- a/extra/DefaultThemeDark.oss +++ b/extra/DefaultThemeDark.oss @@ -397,5 +397,11 @@ macro set_common_values(_borderColor = $borderColor, _fontSize = 18, _showBorder set_focus_border($blueAccent) backgroundColor = $darkColor textColor = $textColor + cursorBlink = true + cursorWidth = 2 + cursorColor = $crimsonAccent + selectionColor = $blueAccent + cursorStyle = "line" + characterMask = "*" } % ====================== diff --git a/other/TODO.txt b/other/TODO.txt index 8ad30c9..c936b57 100644 --- a/other/TODO.txt +++ b/other/TODO.txt @@ -72,6 +72,7 @@ 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 +Add per-widget font diff --git a/src/ogfx/gui/widgets/Text.cpp b/src/ogfx/gui/widgets/Text.cpp index e936c17..f072db2 100644 --- a/src/ogfx/gui/widgets/Text.cpp +++ b/src/ogfx/gui/widgets/Text.cpp @@ -270,7 +270,23 @@ namespace ogfx 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) @@ -329,20 +345,6 @@ namespace ogfx ); } - // ------------------------------------------------------------- - // 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 (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 // "on" phase. The cursor renders ON TOP of everything else so @@ -351,10 +353,53 @@ namespace ogfx if (isFocused() && (m_cursorState || !m_cursorBlink)) { const f32 cx = textX + layout_x_for_byte(m_buffer.cursorByteOffset()); - gfx.fillRect( - { cx, textY, m_cursorWidth, m_layout.lineHeight }, - m_cursorColor - ); + 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()); } } @@ -392,14 +437,10 @@ namespace ogfx } } - void TextEdit::onFocusGained(const Event& event) - { - - } - void TextEdit::onFocusLost(const Event& event) { - + if (m_buffer.hasSelection()) + m_buffer.clearSelection(); } void TextEdit::onTextEntered(const Event& event) @@ -407,7 +448,7 @@ namespace ogfx auto& data = *event.keyboard; 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_filter || m_filter->isValidChar(data.text, m_buffer)) { if (m_lastChar == toInsert && m_keyRepeatTimer.isCounting()) return; @@ -425,17 +466,22 @@ namespace ogfx 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()) { @@ -472,12 +518,14 @@ namespace ogfx else if (data.keyCode == KeyCode::Left) { - if (word) m_buffer.moveWordLeft(extend); + 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 (word) m_buffer.moveWordRight(extend); + 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) @@ -496,13 +544,35 @@ namespace ogfx else if (data.keyCode == KeyCode::Backspace) { - if (word) m_buffer.backspaceWord(); - else m_buffer.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 (word) m_buffer.deleteWord(); - else m_buffer.deleteForward(); + 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 ----- @@ -556,7 +626,10 @@ namespace ogfx // 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. - m_buffer.selectWordAt(hit); + if (hasCharacterMask()) + m_buffer.selectAll(); + else + m_buffer.selectWordAt(hit); m_mouseSelecting = true; m_lastClickValid = false; // Don't triple-click into something weird. } @@ -640,11 +713,15 @@ namespace ogfx 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(m_buffer.text(), getFontSize()); + gfx.getStringDimensionsPerCharacter(displayText, getFontSize()); if (perChar.empty()) { diff --git a/src/ogfx/gui/widgets/Text.hpp b/src/ogfx/gui/widgets/Text.hpp index 9c0e26a..87a1b31 100644 --- a/src/ogfx/gui/widgets/Text.hpp +++ b/src/ogfx/gui/widgets/Text.hpp @@ -94,13 +94,14 @@ namespace ogfx f32 lineHeight { 0 }; bool dirty { true }; }; + public: enum class CursorStyle { Line, Block, Box, Underscore }; public: inline TextEdit(Window& window) : Widget({ 0, 0, 0, 0 }, window) { create(); } TextEdit& create(void); void applyTheme(const ostd::Stylesheet& theme) override; void onDraw(ogfx::BasicRenderer2D& gfx) override; void onUpdate(void) override; - void onFocusGained(const Event& event) override; + void onFocusLost(const Event& event) override; void onTextEntered(const Event& event) override; void onKeyPressed(const Event& event) override; @@ -108,9 +109,28 @@ namespace ogfx void onMouseReleased(const Event& event) override; void onMouseDragged(const Event& event) override; + inline void useCustomFilter(CharacterFilter* filter) { m_owned_filter.reset(); m_filter = filter; } + inline void useDateFilter(DateFilter::eFormat format = DateFilter::eFormat::DDMMYYYY, char separator = '.') { use_filter(std::make_unique(format, separator)); } + inline void useIntegerFilter(bool allowNegative = true, i32 maxDigits = -1) { use_filter(std::make_unique(allowNegative, maxDigits)); } + inline void useDecimalFilter(bool allowNegative = true, char separator = '.', i32 maxIntegerDigits = -1, i32 maxFractionDigits = -1) { use_filter(std::make_unique(allowNegative, separator, maxIntegerDigits, maxFractionDigits)); } + void setText(const String& text); + inline String getText(void) const { return m_buffer.text(); } + inline String getSelectedText(void) const { return m_buffer.selectedText(); } void setMaxLength(i32 codepoints); - inline void setFilter(CharacterFilter* filter) { m_charFilter = filter; } + inline i32 getMaxLength(void) const { return m_maxLength; } + inline void setTextPadding(f32 left, f32 right) { m_textPadding.left = left; m_textPadding.right = right; } + inline TextPadding getTextPadding(void) const { return m_textPadding; } + inline void setCharacterMask(char mask) { m_charMask = mask; } + inline char getCharacterMask(void) const { return m_charMask; } + inline void clearCharacterMask(void) { m_charMask = '\0'; } + inline bool hasCharacterMask(void) const { return std::isprint(m_charMask); } + OSTD_BOOL_PARAM_GETSET_E(CursorBlink, m_cursorBlink); + OSTD_BOOL_PARAM_GETSET_E(CopyOnMask, m_copyOnMask); + OSTD_PARAM_GETSET(f32, CursorWidth, m_cursorWidth); + OSTD_PARAM_GETSET(Color, CursorColor, m_cursorColor); + OSTD_PARAM_GETSET(Color, SelectionColor, m_selectionColor); + OSTD_PARAM_GETSET(CursorStyle, CursorStyle, m_cursorStyle); private: void rebuild_layout(BasicRenderer2D& gfx); @@ -118,29 +138,35 @@ namespace ogfx 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; + inline void use_filter(std::unique_ptr f) { m_owned_filter = std::move(f); m_filter = m_owned_filter.get(); } private: ostd::TextBuffer m_buffer { "", true }; ostd::BasicCounter m_cursorBlinkTimer { 30 }; ostd::BasicCounter m_keyRepeatTimer { 1 }; - ostd::BasicCounter m_doubleClickTimer { 24 }; // ~400ms at 60fps + ostd::BasicCounter m_doubleClickTimer { 24 }; + std::unique_ptr m_owned_filter; // may be null + CharacterFilter* m_filter { nullptr }; // observer, always used + f32 m_lastClickLocalX { 0.0f }; 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 }; + f32 m_scrollX { 0.0f }; String m_lastChar { "" }; i32 m_lastKeyCode { 0 }; - bool m_cursorBlink { true }; bool m_cursorState { false }; - f32 m_cursorWidth { 2 }; - Color m_cursorColor { Colors::Red }; - GlyphLayout m_layout; - f32 m_scrollX { 0.0f }; + GlyphLayout m_layout; + bool m_copyOnMask { false }; + + i32 m_maxLength { -1 }; TextPadding m_textPadding; - Color m_selectionColor { 60, 110, 200, 200 }; // bluish, semi-transparent + char m_charMask { '\0' }; + bool m_cursorBlink { true }; + f32 m_cursorWidth { 2 }; + Color m_cursorColor { Colors::Crimson }; + Color m_selectionColor { 60, 110, 200, 200 }; + CursorStyle m_cursorStyle { CursorStyle::Line }; }; }