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) set_focus_border($blueAccent)
backgroundColor = $darkColor backgroundColor = $darkColor
textColor = $textColor 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 Implement notification system
Fix cursor not advancing when selection is present and left arrow is pressed in TextEdit Fix cursor not advancing when selection is present and left arrow is pressed in TextEdit
Check widgets/layouts interactions Check widgets/layouts interactions
Add per-widget font

View file

@ -270,7 +270,23 @@ namespace ogfx
void TextEdit::applyTheme(const ostd::Stylesheet& theme) 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) 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 // 8. Draw the cursor. Only when focused and the blink is in the
// "on" phase. The cursor renders ON TOP of everything else so // "on" phase. The cursor renders ON TOP of everything else so
@ -351,10 +353,53 @@ namespace ogfx
if (isFocused() && (m_cursorState || !m_cursorBlink)) if (isFocused() && (m_cursorState || !m_cursorBlink))
{ {
const f32 cx = textX + layout_x_for_byte(m_buffer.cursorByteOffset()); const f32 cx = textX + layout_x_for_byte(m_buffer.cursorByteOffset());
gfx.fillRect( auto l_char_width_at_cursor = [this](void) -> f32 {
{ cx, textY, m_cursorWidth, m_layout.lineHeight }, const u32 byteOff = m_buffer.cursorByteOffset();
m_cursorColor 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) void TextEdit::onFocusLost(const Event& event)
{ {
if (m_buffer.hasSelection())
m_buffer.clearSelection();
} }
void TextEdit::onTextEntered(const Event& event) void TextEdit::onTextEntered(const Event& event)
@ -407,7 +448,7 @@ namespace ogfx
auto& data = *event.keyboard; auto& data = *event.keyboard;
const String toInsert = clamp_input_to_max_length(data.text); const String toInsert = clamp_input_to_max_length(data.text);
if (toInsert.empty()) return; 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()) if (m_lastChar == toInsert && m_keyRepeatTimer.isCounting())
return; return;
@ -425,17 +466,22 @@ namespace ogfx
const bool extend = data.modifiers.anyShift(); const bool extend = data.modifiers.anyShift();
const bool word = data.modifiers.primary(); const bool word = data.modifiers.primary();
const bool mask = hasCharacterMask();
// ----- Editing operations (chords first, since they're more specific) ----- // ----- Editing operations (chords first, since they're more specific) -----
if (data.isCopy()) if (data.isCopy())
{ {
if (hasCharacterMask() && !isCopyOnMaskEnabled())
return;
const String sel = m_buffer.selectedText(); const String sel = m_buffer.selectedText();
if (!sel.empty()) if (!sel.empty())
SDL_SetClipboardText(sel); SDL_SetClipboardText(sel);
} }
else if (data.isCut()) else if (data.isCut())
{ {
if (hasCharacterMask() && !isCopyOnMaskEnabled())
return;
const String sel = m_buffer.selectedText(); const String sel = m_buffer.selectedText();
if (!sel.empty()) if (!sel.empty())
{ {
@ -472,12 +518,14 @@ namespace ogfx
else if (data.keyCode == KeyCode::Left) 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 m_buffer.moveLeft(extend);
} }
else if (data.keyCode == KeyCode::Right) 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 m_buffer.moveRight(extend);
} }
else if (data.keyCode == KeyCode::Home || data.keyCode == KeyCode::Pageup) else if (data.keyCode == KeyCode::Home || data.keyCode == KeyCode::Pageup)
@ -496,12 +544,34 @@ namespace ogfx
else if (data.keyCode == KeyCode::Backspace) else if (data.keyCode == KeyCode::Backspace)
{ {
if (word) m_buffer.backspaceWord(); 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 m_buffer.backspace();
} }
else if (data.keyCode == KeyCode::Delete) else if (data.keyCode == KeyCode::Delete)
{ {
if (word) m_buffer.deleteWord(); 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(); else m_buffer.deleteForward();
} }
@ -556,6 +626,9 @@ namespace ogfx
// we mark the drag flag so subsequent drag motion extends the // we mark the drag flag so subsequent drag motion extends the
// selection by word — though we'll keep the simple implementation // selection by word — though we'll keep the simple implementation
// for now where dragging after a double-click extends by character. // for now where dragging after a double-click extends by character.
if (hasCharacterMask())
m_buffer.selectAll();
else
m_buffer.selectWordAt(hit); m_buffer.selectWordAt(hit);
m_mouseSelecting = true; m_mouseSelecting = true;
m_lastClickValid = false; // Don't triple-click into something weird. m_lastClickValid = false; // Don't triple-click into something weird.
@ -640,11 +713,15 @@ namespace ogfx
return; 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: // One call to the renderer. Returns one Vec2 per codepoint:
// .x = advance (incl. kerning) contributed by that glyph // .x = advance (incl. kerning) contributed by that glyph
// .y = glyph height (same for all glyphs at this font/size) // .y = glyph height (same for all glyphs at this font/size)
const stdvec<Vec2> perChar = const stdvec<Vec2> perChar =
gfx.getStringDimensionsPerCharacter(m_buffer.text(), getFontSize()); gfx.getStringDimensionsPerCharacter(displayText, getFontSize());
if (perChar.empty()) if (perChar.empty())
{ {

View file

@ -94,13 +94,14 @@ namespace ogfx
f32 lineHeight { 0 }; f32 lineHeight { 0 };
bool dirty { true }; bool dirty { true };
}; };
public: enum class CursorStyle { Line, Block, Box, Underscore };
public: public:
inline TextEdit(Window& window) : Widget({ 0, 0, 0, 0 }, window) { create(); } inline TextEdit(Window& window) : Widget({ 0, 0, 0, 0 }, window) { create(); }
TextEdit& create(void); TextEdit& create(void);
void applyTheme(const ostd::Stylesheet& theme) override; void applyTheme(const ostd::Stylesheet& theme) override;
void onDraw(ogfx::BasicRenderer2D& gfx) override; void onDraw(ogfx::BasicRenderer2D& gfx) override;
void onUpdate(void) override; void onUpdate(void) override;
void onFocusGained(const Event& event) override;
void onFocusLost(const Event& event) override; void onFocusLost(const Event& event) override;
void onTextEntered(const Event& event) override; void onTextEntered(const Event& event) override;
void onKeyPressed(const Event& event) override; void onKeyPressed(const Event& event) override;
@ -108,9 +109,28 @@ namespace ogfx
void onMouseReleased(const Event& event) override; void onMouseReleased(const Event& event) override;
void onMouseDragged(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); 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); 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: private:
void rebuild_layout(BasicRenderer2D& gfx); void rebuild_layout(BasicRenderer2D& gfx);
@ -118,29 +138,35 @@ namespace ogfx
f32 layout_x_for_byte(u32 byte_offset) const; f32 layout_x_for_byte(u32 byte_offset) const;
u32 byte_offset_for_pixel_x(f32 widget_local_x) const; u32 byte_offset_for_pixel_x(f32 widget_local_x) const;
String clamp_input_to_max_length(const String& utf8) 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: private:
ostd::TextBuffer m_buffer { "", true }; ostd::TextBuffer m_buffer { "", true };
ostd::BasicCounter m_cursorBlinkTimer { 30 }; ostd::BasicCounter m_cursorBlinkTimer { 30 };
ostd::BasicCounter m_keyRepeatTimer { 1 }; 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 }; f32 m_lastClickLocalX { 0.0f };
bool m_lastClickValid { false }; bool m_lastClickValid { false };
bool m_mouseSelecting { false }; bool m_mouseSelecting { false };
f32 m_lastMouseLocalX { 0.0f }; f32 m_lastMouseLocalX { 0.0f };
i32 m_maxLength { -1 }; f32 m_scrollX { 0.0f };
char m_charMask { '\0' };
CharacterFilter* m_charFilter { nullptr };
String m_lastChar { "" }; String m_lastChar { "" };
i32 m_lastKeyCode { 0 }; i32 m_lastKeyCode { 0 };
bool m_cursorBlink { true };
bool m_cursorState { false }; bool m_cursorState { false };
f32 m_cursorWidth { 2 };
Color m_cursorColor { Colors::Red };
GlyphLayout m_layout; GlyphLayout m_layout;
f32 m_scrollX { 0.0f }; bool m_copyOnMask { false };
i32 m_maxLength { -1 };
TextPadding m_textPadding; 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 };
}; };
} }