Polished TextEdit widget

This commit is contained in:
OmniaX-Dev 2026-05-08 07:17:51 +02:00
parent d5bf65465a
commit 04e595ca9c
4 changed files with 156 additions and 46 deletions

View file

@ -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 = "*"
}
% ======================

View file

@ -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

View file

@ -270,7 +270,23 @@ namespace ogfx
void TextEdit::applyTheme(const ostd::Stylesheet& theme)
{
enableCursorBlink(getThemeValue<bool>(theme, "cursorBlink", isCursorBlinkEnabled()));
setCursorWidth(getThemeValue<f32>(theme, "cursorWidth", getCursorWidth()));
setCursorColor(getThemeValue<Color>(theme, "cursorColor", getCursorColor()));
setSelectionColor(getThemeValue<Color>(theme, "selectionColor", getSelectionColor()));
setMaxLength(getThemeValue<i32>(theme, "macLength", getMaxLength()));
setTextPadding(getThemeValue<f32>(theme, "textPaddingLeft", getTextPadding().left),
getThemeValue<f32>(theme, "textPaddingRight", getTextPadding().right));
String mask = getThemeValue<String>(theme, "characterMask", String(""));
if (mask.len() != 1)
clearCharacterMask();
else
setCharacterMask(mask[0]);
String styleStr = getThemeValue<String>(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<Vec2> perChar =
gfx.getStringDimensionsPerCharacter(m_buffer.text(), getFontSize());
gfx.getStringDimensionsPerCharacter(displayText, getFontSize());
if (perChar.empty())
{

View file

@ -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<DateFilter>(format, separator)); }
inline void useIntegerFilter(bool allowNegative = true, i32 maxDigits = -1) { use_filter(std::make_unique<IntegerFilter>(allowNegative, maxDigits)); }
inline void useDecimalFilter(bool allowNegative = true, char separator = '.', i32 maxIntegerDigits = -1, i32 maxFractionDigits = -1) { use_filter(std::make_unique<DecimalFilter>(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<CharacterFilter> 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<CharacterFilter> 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 };
};
}