Polished TextEdit widget
This commit is contained in:
parent
d5bf65465a
commit
04e595ca9c
4 changed files with 156 additions and 46 deletions
|
|
@ -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 = "*"
|
||||
}
|
||||
% ======================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,12 +544,34 @@ namespace ogfx
|
|||
|
||||
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 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();
|
||||
}
|
||||
|
||||
|
|
@ -556,6 +626,9 @@ 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.
|
||||
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())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
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 };
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue