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)
|
set_focus_border($blueAccent)
|
||||||
backgroundColor = $darkColor
|
backgroundColor = $darkColor
|
||||||
textColor = $textColor
|
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
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,13 +544,35 @@ namespace ogfx
|
||||||
|
|
||||||
else if (data.keyCode == KeyCode::Backspace)
|
else if (data.keyCode == KeyCode::Backspace)
|
||||||
{
|
{
|
||||||
if (word) m_buffer.backspaceWord();
|
if (m_buffer.hasSelection())
|
||||||
else m_buffer.backspace();
|
{
|
||||||
|
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)
|
else if (data.keyCode == KeyCode::Delete)
|
||||||
{
|
{
|
||||||
if (word) m_buffer.deleteWord();
|
if (m_buffer.hasSelection())
|
||||||
else m_buffer.deleteForward();
|
{
|
||||||
|
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 -----
|
// ----- Other -----
|
||||||
|
|
@ -556,7 +626,10 @@ 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.
|
||||||
m_buffer.selectWordAt(hit);
|
if (hasCharacterMask())
|
||||||
|
m_buffer.selectAll();
|
||||||
|
else
|
||||||
|
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())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
GlyphLayout m_layout;
|
||||||
Color m_cursorColor { Colors::Red };
|
bool m_copyOnMask { false };
|
||||||
GlyphLayout m_layout;
|
|
||||||
f32 m_scrollX { 0.0f };
|
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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue