Finished core TextEdit widget

This commit is contained in:
OmniaX-Dev 2026-05-07 17:50:12 +02:00
parent a3eb4ba3cc
commit ede7db738c
12 changed files with 609 additions and 35 deletions

29
.zed/tasks.json Normal file
View file

@ -0,0 +1,29 @@
[
{
"label": "build",
"command": "./build",
"args": ["debug"],
"cwd": "$ZED_WORKTREE_ROOT",
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
},
{
"label": "build run",
"command": "./build",
"args": ["run"],
"cwd": "$ZED_WORKTREE_ROOT",
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
},
{
"label": "build dbg",
"command": "./build",
"args": ["dbg"],
"cwd": "$ZED_WORKTREE_ROOT",
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
},
]

38
gdb-last.log Normal file
View file

@ -0,0 +1,38 @@
This GDB supports auto-downloading debuginfo from the following URLs:
<https://debuginfod.archlinux.org>
Enable debuginfod for this session? (y or [n]) [answered N; input not from terminal]
Debuginfod has been disabled.
To make this setting permanent, add 'set debuginfod enabled off' to .gdbinit.
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
[New Thread 0x7ffff41726c0 (LWP 82519)]
[New Thread 0x7fffe1dff6c0 (LWP 82522)]
[New Thread 0x7fffe11ff6c0 (LWP 82523)]
[New Thread 0x7fffe09fe6c0 (LWP 82524)]
[New Thread 0x7fffd3fff6c0 (LWP 82525)]
[New Thread 0x7fffd33fe6c0 (LWP 82526)]
[New Thread 0x7fffd2bfd6c0 (LWP 82527)]
[New Thread 0x7fffd23fc6c0 (LWP 82528)]
[New Thread 0x7fffd1bfb6c0 (LWP 82529)]
[New Thread 0x7fffd13fa6c0 (LWP 82530)]
[New Thread 0x7fffd0bf96c0 (LWP 82531)]
[New Thread 0x7fff822bf6c0 (LWP 82538)]
[Thread 0x7fffd0bf96c0 (LWP 82531) exited]
[Thread 0x7fffd13fa6c0 (LWP 82530) exited]
[Thread 0x7fffd1bfb6c0 (LWP 82529) exited]
[Thread 0x7fffd33fe6c0 (LWP 82526) exited]
[Thread 0x7fffd2bfd6c0 (LWP 82527) exited]
[Thread 0x7fffd23fc6c0 (LWP 82528) exited]
[Thread 0x7fffe09fe6c0 (LWP 82524) exited]
[Thread 0x7fffd3fff6c0 (LWP 82525) exited]
[Thread 0x7fffe11ff6c0 (LWP 82523) exited]
[Thread 0x7fffe1dff6c0 (LWP 82522) exited]
[Thread 0x7ffff41726c0 (LWP 82519) exited]
[Thread 0x7ffff7b5bf80 (LWP 82512) exited]
[Thread 0x7fff822bf6c0 (LWP 82538) exited]
[New process 82512]
[Inferior 1 (process 82512) exited normally]
No stack.
No frame selected.
No frame selected.

View file

@ -32,6 +32,8 @@
***Add utf-8 codepoint walking functions to String
***Change the stored text in KeyEventData from a single char to the full utf-8 string
***Check if Text Entered events are enabled via SDL
***Add modifiers to Event.keyboard path
***Implement max length in TextEdit
Implement cursors in Stylesheet
FIX: Window getting exponentially bigger when Desktop scale changes
FIX: Refreshing scroll when content extent changes
@ -58,7 +60,6 @@ Add setSelectedMenuItem() function to ComboBox
FIX: ContextMenu rendering partly outside the screen when showing it close to the right Window border
Add ToolBar::addButton overload that takes a ogfx::Icon wrapper
Redesign theme overrides to be more robust
Add modifiers to Event.keyboard path
Implement a font-fallback chain in FontGlyphAtlas
Implement appendText in TextBuffer and TextEdit
Implement per-glyph caching in BasicRenderer2D
@ -66,6 +67,11 @@ Added double-click event to widgets
Implement shift-selection in TextEdit (mouse/keyboard)
Implement ctrl-selection/navigation in TextEdit
Implement home/end and pg up/pg down in TextEdit
Implement Undo stack in TextBuffer
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
@ -101,8 +107,9 @@ Implement following widgets:
***Fill
***ComboBox
***TreeView
Text Input
***Text Input
***Numeric Field
***Password Field
Text Area
Numeric Field
Color Picker
Calendar

View file

@ -1 +1 @@
2077
2078

View file

@ -22,6 +22,7 @@
#include <ostd/data/BaseObject.hpp>
#include <ostd/math/Geometry.hpp>
#include <ogfx/utils/Keycodes.hpp>
namespace ogfx
{
@ -68,18 +69,76 @@ namespace ogfx
};
class KeyEventData : public ostd::BaseObject
{
public: struct KeyModifiers
{
bool lshift { false };
bool rshift { false };
bool ctrl { false };
bool alt { false };
bool meta { false }; // Win/Cmd/Super
inline bool any(void) const { return rshift || lshift || ctrl || alt || meta; }
inline bool anyShift(void) const { return lshift || rshift; }
inline bool shiftOnly(void) const { return (lshift || rshift) && !ctrl && !alt && !meta; }
inline bool ctrlOnly(void) const { return ctrl && !lshift && !rshift && !alt && !meta; }
inline bool metaOnly(void) const { return meta && !lshift && !rshift && !alt && !ctrl; }
inline bool altOnly(void) const { return alt && !lshift && !rshift && !meta && !ctrl; }
inline bool primaryOnly(void) const
{
#ifdef MAC_OS
return metaOnly();
#else
return ctrlOnly();
#endif
}
inline bool primaryShiftOnly(void) const
{
#ifdef MAC_OS
return meta && anyShift() && !ctrl && !alt;
#else
return ctrl && anyShift() && !meta && !alt;
#endif
}
inline bool primaryAltOnly(void) const
{
#ifdef MAC_OS
return meta && alt && !ctrl && !anyShift();
#else
return ctrl && alt && !ctrl && !anyShift();
#endif
}
inline bool shiftAltOnly(void) const
{
return alt && anyShift() && !ctrl && !meta;
}
inline bool primary(void) const
{
#ifdef MAC_OS
return meta;
#else
return ctrl;
#endif
}
};
public: enum class eKeyEvent { Pressed = 0, Released, Text };
public:
inline KeyEventData(WindowCore& parent, i32 key, const String& _text, eKeyEvent evt) : parentWindow(parent), keyCode(key), text(_text), eventType(evt)
inline KeyEventData(WindowCore& parent, i32 key, const String& _text, eKeyEvent evt, const KeyModifiers& mod) : parentWindow(parent), keyCode(key), text(_text), eventType(evt), modifiers(mod)
{
setTypeName("ogfx::KeyEventData");
validate();
}
inline bool isCopy(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::C; }
inline bool isCut(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::X; }
inline bool isPaste(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::V; }
inline bool isSelectAll(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::A; }
inline bool isUndo(void) const { return eventType == eKeyEvent::Pressed && modifiers.primaryOnly() && keyCode == KeyCode::Z; }
inline bool isRedo(void) const { return eventType == eKeyEvent::Pressed && ((modifiers.primaryShiftOnly() && keyCode == KeyCode::Z) || (modifiers.primaryOnly() && keyCode == KeyCode::Y)); }
public:
i32 keyCode;
String text;
eKeyEvent eventType;
KeyModifiers modifiers;
WindowCore& parentWindow;
};
class DropEventData : public ostd::BaseObject

View file

@ -525,6 +525,15 @@ namespace ogfx
void WindowCore::__handle_event(SDL_Event& event)
{
auto l_keymod_status = [](SDL_Keymod m) -> KeyEventData::KeyModifiers {
return {
.lshift = (m & SDL_KMOD_LSHIFT) != 0,
.rshift = (m & SDL_KMOD_RSHIFT) != 0,
.ctrl = (m & SDL_KMOD_CTRL) != 0,
.alt = (m & SDL_KMOD_ALT) != 0,
.meta = (m & SDL_KMOD_GUI) != 0,
};
};
if (event.type == REDRAW_EVENT)
{
//Doesn't need to do anything, the event exists just to make SDL_WaitEventTimeout() return early
@ -619,17 +628,17 @@ namespace ogfx
}
else if (event.type == SDL_EVENT_TEXT_INPUT)
{
KeyEventData ked(*this, 0, event.text.text, KeyEventData::eKeyEvent::Text);
KeyEventData ked(*this, 0, event.text.text, KeyEventData::eKeyEvent::Text, l_keymod_status(event.key.mod));
ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::TextEntered, ostd::Signal::Priority::RealTime, ked);
}
else if (event.type == SDL_EVENT_KEY_DOWN)
{
KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Pressed);
KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Pressed, l_keymod_status(event.key.mod));
ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::KeyPressed, ostd::Signal::Priority::RealTime, ked);
}
else if (event.type == SDL_EVENT_KEY_UP)
{
KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Released);
KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Released, l_keymod_status(event.key.mod));
ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::KeyReleased, ostd::Signal::Priority::RealTime, ked);
}
__on_event(event);

View file

@ -26,6 +26,224 @@ namespace ogfx
{
namespace gui
{
String TextEdit::CharacterFilter::predictResult(const String& utf8, const ostd::TextBuffer& buffer)
{
const String& current = buffer.text();
const u32 selStart = buffer.selectionStart();
const u32 selEnd = buffer.selectionEnd();
String head = current.new_substr(0, cast<i32>(selStart));
String tail = current.new_substr(selEnd, cast<i32>(current.len()));
return head + utf8 + tail;
}
bool TextEdit::IntegerFilter::isValidChar(const String& utf8, const ostd::TextBuffer& buffer)
{
// Reject anything that isn't a single ASCII byte. Multi-codepoint
// input (IME, emoji, accented chars) is never valid in an integer.
if (utf8.len() != 1) return false;
const char c = utf8[0];
const bool isDigit = (c >= '0' && c <= '9');
const bool isSign = (c == '-');
if (!isDigit && !isSign) return false;
// Predict the post-insert string and validate it as a prefix.
const String result = predictResult(utf8, buffer);
return isValidIntegerPrefix(result);
}
bool TextEdit::IntegerFilter::isValidIntegerPrefix(const String& s) const
{
if (s.empty()) return true;
const ostd::cpp_string& bytes = s.cpp_str();
u32 i = 0;
// Optional leading minus.
if (bytes[0] == '-') {
if (!m_allowNegative) return false;
i = 1;
if (bytes.size() == 1) return true; // Just "-" by itself: valid prefix.
}
// Everything from i onward must be digits. Count them while we're at it.
u32 digitCount = 0;
while (i < bytes.size()) {
if (bytes[i] < '0' || bytes[i] > '9') return false;
digitCount++;
i++;
}
if (m_maxDigits > 0 && digitCount > cast<u32>(m_maxDigits))
return false;
return true;
}
bool TextEdit::DecimalFilter::isValidChar(const String& utf8, const ostd::TextBuffer& buffer)
{
if (utf8.len() != 1) return false;
const char c = utf8[0];
const bool isDigit = (c >= '0' && c <= '9');
const bool isSign = (c == '-');
const bool isSep = (c == m_separator);
if (!isDigit && !isSign && !isSep) return false;
const String result = predictResult(utf8, buffer);
return isValidDecimalPrefix(result);
}
bool TextEdit::DecimalFilter::isValidDecimalPrefix(const String& s) const
{
if (s.empty()) return true;
const ostd::cpp_string& bytes = s.cpp_str();
u32 i = 0;
if (bytes[0] == '-') {
if (!m_allowNegative) return false;
i = 1;
if (bytes.size() == 1) return true; // Just "-": valid prefix.
}
u32 intDigits = 0;
u32 fracDigits = 0;
bool seenSep = false;
while (i < bytes.size()) {
const char c = bytes[i];
if (c == m_separator) {
if (seenSep) return false; // Two separators: invalid.
if (intDigits == 0) return false; // ".5" without a leading digit:
// we choose to require it for now.
seenSep = true;
}
else if (c >= '0' && c <= '9') {
if (seenSep) fracDigits++;
else intDigits++;
}
else {
return false;
}
i++;
}
if (m_maxIntDigits > 0 && intDigits > cast<u32>(m_maxIntDigits)) return false;
if (m_maxFracDigits > 0 && fracDigits > cast<u32>(m_maxFracDigits)) return false;
return true;
}
bool TextEdit::DateFilter::isValidChar(const String& utf8, const ostd::TextBuffer& buffer)
{
if (utf8.len() != 1) return false;
const String result = predictResult(utf8, buffer);
return isValidDatePrefix(result);
}
void TextEdit::DateFilter::buildPositionMap(void)
{
m_slots.clear();
// Local helper to push a run of digit slots.
auto pushDigits = [&](eSlot s, u32 n) {
for (u32 i = 0; i < n; i++) m_slots.push_back(s);
};
auto pushSep = [&]() { m_slots.push_back(eSlot::Sep); };
switch (m_format) {
case eFormat::DDMMYY:
pushDigits(eSlot::Day, 2); pushSep();
pushDigits(eSlot::Month, 2); pushSep();
pushDigits(eSlot::Year, 2); break;
case eFormat::DDMMYYYY:
pushDigits(eSlot::Day, 2); pushSep();
pushDigits(eSlot::Month, 2); pushSep();
pushDigits(eSlot::Year, 4); break;
case eFormat::MMDDYY:
pushDigits(eSlot::Month, 2); pushSep();
pushDigits(eSlot::Day, 2); pushSep();
pushDigits(eSlot::Year, 2); break;
case eFormat::MMDDYYYY:
pushDigits(eSlot::Month, 2); pushSep();
pushDigits(eSlot::Day, 2); pushSep();
pushDigits(eSlot::Year, 4); break;
case eFormat::YYYYMMDD:
pushDigits(eSlot::Year, 4); pushSep();
pushDigits(eSlot::Month, 2); pushSep();
pushDigits(eSlot::Day, 2); break;
case eFormat::YYMMDD:
pushDigits(eSlot::Year, 2); pushSep();
pushDigits(eSlot::Month, 2); pushSep();
pushDigits(eSlot::Day, 2); break;
}
}
bool TextEdit::DateFilter::isValidDatePrefix(const String& s) const
{
const ostd::cpp_string& bytes = s.cpp_str();
if (bytes.size() > m_slots.size()) return false;
// Track the running values so we can validate ranges as soon as
// a field is fully entered.
u32 dayVal = 0, dayChars = 0;
u32 monthVal = 0, monthChars = 0;
u32 yearVal = 0, yearChars = 0;
for (u32 i = 0; i < bytes.size(); i++) {
const char c = bytes[i];
const eSlot expected = m_slots[i];
if (expected == eSlot::Sep) {
if (c != m_separator) return false;
// Separator means a field just ended — validate it.
// (Only the field that *just ended* needs validation; earlier
// fields were already validated when their separators were typed.)
}
else {
if (c < '0' || c > '9') return false;
const u32 d = cast<u32>(c - '0');
if (expected == eSlot::Day) { dayVal = dayVal * 10 + d; dayChars++; }
if (expected == eSlot::Month) { monthVal = monthVal * 10 + d; monthChars++; }
if (expected == eSlot::Year) { yearVal = yearVal * 10 + d; yearChars++; }
}
// Range validation as soon as a field is fully populated.
if (expected == eSlot::Day && dayChars == 2) {
if (dayVal < 1 || dayVal > 31) return false;
}
if (expected == eSlot::Month && monthChars == 2) {
if (monthVal < 1 || monthVal > 12) return false;
}
// No range check for year — historically valid years span millennia.
// Also reject impossible *partial* values: a 2-digit day field
// can't start with '4'-'9' if the second digit would always make
// it exceed 31. Day field: first digit must be 0-3.
// Month field: first digit must be 0-1.
if (expected == eSlot::Day && dayChars == 1) {
if (dayVal > 3) return false;
}
if (expected == eSlot::Month && monthChars == 1) {
if (monthVal > 1) return false;
}
}
return true;
}
TextEdit& TextEdit::create(void)
{
setPadding({ 0, 0, 0, 0 });
@ -118,7 +336,12 @@ namespace ogfx
// 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
@ -182,16 +405,15 @@ namespace ogfx
void TextEdit::onTextEntered(const Event& event)
{
auto& data = *event.keyboard;
if (!m_charFilter || m_charFilter->isValidChar(data.text))
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_lastChar == data.text && m_keyRepeatTimer.isCounting())
if (m_lastChar == toInsert && m_keyRepeatTimer.isCounting())
return;
m_buffer.insertText(data.text);
m_lastChar = data.text;
m_buffer.insertText(toInsert);
m_lastChar = toInsert;
m_keyRepeatTimer.start();
if (m_cursorBlink)
m_cursorBlinkTimer.start();
m_cursorState = true;
}
}
@ -200,37 +422,114 @@ namespace ogfx
auto& data = *event.keyboard;
if (m_lastKeyCode == data.keyCode && m_keyRepeatTimer.isCounting())
return;
if (data.keyCode == KeyCode::Backspace)
const bool extend = data.modifiers.anyShift();
const bool word = data.modifiers.primary();
// ----- Editing operations (chords first, since they're more specific) -----
if (data.isCopy())
{
m_keyRepeatTimer.start();
m_lastKeyCode = data.keyCode;
m_buffer.backspace();
const String sel = m_buffer.selectedText();
if (!sel.empty())
SDL_SetClipboardText(sel);
}
else if (data.isCut())
{
const String sel = m_buffer.selectedText();
if (!sel.empty())
{
SDL_SetClipboardText(sel);
m_buffer.deleteSelection();
}
}
else if (data.isPaste())
{
const String clipboardText = SDL_GetClipboardText();
if (!clipboardText.empty())
{
const String toInsert = clamp_input_to_max_length(clipboardText);
if (!toInsert.empty())
m_buffer.insertText(toInsert);
}
}
else if (data.isSelectAll())
{
m_buffer.selectAll();
}
else if (data.isUndo())
{
std::cout << "Undo is not implemented yet!\n";
}
else if (data.isRedo())
{
std::cout << "Redo is not implemented yet!\n";
}
// ----- Cursor movement -----
else if (data.keyCode == KeyCode::Left)
{
m_keyRepeatTimer.start();
m_lastKeyCode = data.keyCode;
m_buffer.moveLeft();
if (word) m_buffer.moveWordLeft(extend);
else m_buffer.moveLeft(extend);
}
else if (data.keyCode == KeyCode::Right)
{
m_keyRepeatTimer.start();
if (word) m_buffer.moveWordRight(extend);
else m_buffer.moveRight(extend);
}
else if (data.keyCode == KeyCode::Home || data.keyCode == KeyCode::Pageup)
{
// PageUp on single-line == Home. When you go multiline, split these.
if (word) m_buffer.moveDocumentStart(extend);
else m_buffer.moveLineStart(extend);
}
else if (data.keyCode == KeyCode::End || data.keyCode == KeyCode::Pagedown)
{
if (word) m_buffer.moveDocumentEnd(extend);
else m_buffer.moveLineEnd(extend);
}
m_lastKeyCode = data.keyCode;
m_buffer.moveRight();
// ----- Deletion -----
else if (data.keyCode == KeyCode::Backspace)
{
if (word) m_buffer.backspaceWord();
else m_buffer.backspace();
}
else if (data.keyCode == KeyCode::Delete)
{
if (word) m_buffer.deleteWord();
else m_buffer.deleteForward();
}
// ----- Other -----
else if (data.keyCode == KeyCode::Escape)
{
// Clear selection if there is one; otherwise no-op (the widget could
// give up focus here, but that's an opinionated choice — leave it
// up to the application via callback_onEscape if you want).
if (m_buffer.hasSelection())
m_buffer.clearSelection();
}
else if (data.keyCode == KeyCode::Return)
{
m_keyRepeatTimer.start();
m_lastKeyCode = data.keyCode;
if (callback_onActionPerformed)
callback_onActionPerformed(event);
}
else
{
// Unrecognized key — don't engage the repeat throttle, and don't
// record m_lastKeyCode. Returning here keeps the throttle accurate
// (if the user presses an unhandled key followed by a real one,
// the real one fires immediately rather than being suppressed).
return;
}
void TextEdit::onKeyReleased(const Event& event)
{
// Single bookkeeping point for every accepted branch.
m_keyRepeatTimer.start();
m_lastKeyCode = data.keyCode;
}
void TextEdit::onMousePressed(const Event& event)
@ -294,6 +593,34 @@ namespace ogfx
m_cursorState = true;
}
void TextEdit::setText(const String& text)
{
if (m_maxLength <= 0) {
m_buffer.setText(text);
return;
}
const u32 inputCps = String::utf8::count_codepoints(text.cpp_str());
if (inputCps <= cast<u32>(m_maxLength)) {
m_buffer.setText(text);
return;
}
m_buffer.setText(String::utf8::truncate(text, cast<u32>(m_maxLength)));
}
void TextEdit::setMaxLength(i32 codepoints)
{
m_maxLength = codepoints;
if (m_maxLength <= 0) return;
const u32 currentCps = m_buffer.codepointCount();
if (currentCps <= cast<u32>(m_maxLength)) return;
const String truncated = String::utf8::truncate(m_buffer.text(), cast<u32>(m_maxLength));
m_buffer.setText(truncated);
}
void TextEdit::rebuild_layout(BasicRenderer2D& gfx)
{
m_layout.byteOffsets.clear();
@ -441,5 +768,37 @@ namespace ogfx
const f32 distHi = xs[hi] - doc_x;
return cast<u32>((distLo <= distHi) ? bs[lo] : bs[hi]);
}
String TextEdit::clamp_input_to_max_length(const String& utf8) const
{
if (m_maxLength <= 0) return utf8;
const u32 currentCps = m_buffer.codepointCount();
u32 selCps = 0;
if (m_buffer.hasSelection()) {
const u32 a = m_buffer.selectionStart();
const u32 b = m_buffer.selectionEnd();
const String selected = m_buffer.text().new_substr(a, cast<i32>(b));
selCps = String::utf8::count_codepoints(selected.cpp_str());
}
const u32 insertedCps = String::utf8::count_codepoints(utf8.cpp_str());
const u32 resultCps = currentCps - selCps + insertedCps;
const u32 capCps = cast<u32>(m_maxLength);
if (resultCps <= capCps) return utf8;
// Overflow. Truncate ONLY in the appending case: cursor at end, no selection.
const bool atEnd = (m_buffer.cursorByteOffset() == m_buffer.byteSize());
if (!atEnd || m_buffer.hasSelection()) {
// Mid-buffer insert or replace-selection: reject the whole input.
return String("");
}
// Appending case: truncate to fit.
const u32 availableCps = capCps - currentCps;
return String::utf8::truncate(utf8, availableCps);
}
}
}

View file

@ -33,7 +33,53 @@ namespace ogfx
public: class CharacterFilter
{
public:
inline virtual bool isValidChar(const String& utf8) { return true; }
virtual ~CharacterFilter(void) = default;
inline virtual bool isValidChar(const String& utf8, const ostd::TextBuffer& buffer) = 0;
protected:
// Helper: build the string that would result from inserting `utf8`
// at the current cursor (replacing any selection). Use this in
// subclasses to ask "is the resulting string a valid prefix?"
static String predictResult(const String& utf8, const ostd::TextBuffer& buffer);
};
public: class IntegerFilter : public CharacterFilter
{
public:
inline IntegerFilter(bool allowNegative = true, i32 maxDigits = -1) : m_allowNegative(allowNegative), m_maxDigits(maxDigits) {}
bool isValidChar(const String& utf8, const ostd::TextBuffer& buffer) override;
private:
bool isValidIntegerPrefix(const String& s) const;
private:
bool m_allowNegative;
i32 m_maxDigits;
};
public: class DecimalFilter : public CharacterFilter
{
public:
inline DecimalFilter(bool allowNegative = true, char separator = '.', i32 maxIntegerDigits = -1, i32 maxFractionDigits = -1) : m_allowNegative(allowNegative), m_separator(separator), m_maxIntDigits(maxIntegerDigits), m_maxFracDigits(maxFractionDigits) {}
bool isValidChar(const String& utf8, const ostd::TextBuffer& buffer) override;
private:
bool isValidDecimalPrefix(const String& s) const;
private:
bool m_allowNegative;
char m_separator;
i32 m_maxIntDigits;
i32 m_maxFracDigits;
};
public: class DateFilter : public CharacterFilter
{
public: enum class eFormat{ DDMMYY, DDMMYYYY, MMDDYY, MMDDYYYY, YYYYMMDD, YYMMDD };
private: enum class eSlot { Day, Month, Year, Sep };
public:
DateFilter(eFormat format = eFormat::DDMMYYYY, char separator = '.') : m_format(format), m_separator(separator) { buildPositionMap(); }
bool isValidChar(const String& utf8, const ostd::TextBuffer& buffer) override;
private:
void buildPositionMap(void);
bool isValidDatePrefix(const String& s) const;
private:
stdvec<eSlot> m_slots;
eFormat m_format;
char m_separator;
};
public: struct TextPadding
{
@ -58,18 +104,20 @@ namespace ogfx
void onFocusLost(const Event& event) override;
void onTextEntered(const Event& event) override;
void onKeyPressed(const Event& event) override;
void onKeyReleased(const Event& event) override;
void onMousePressed(const Event& event) override;
void onMouseReleased(const Event& event) override;
void onMouseDragged(const Event& event) override;
inline void setText(const String& text) { m_buffer.setText(text); }
void setText(const String& text);
void setMaxLength(i32 codepoints);
inline void setFilter(CharacterFilter* filter) { m_charFilter = filter; }
private:
void rebuild_layout(BasicRenderer2D& gfx);
void ensure_cursor_visible(const Rectangle& bounds);
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;
private:
ostd::TextBuffer m_buffer { "", true };
@ -80,6 +128,8 @@ namespace ogfx
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 };
String m_lastChar { "" };
i32 m_lastKeyCode { 0 };
@ -92,5 +142,6 @@ namespace ogfx
TextPadding m_textPadding;
Color m_selectionColor { 60, 110, 200, 200 }; // bluish, semi-transparent
};
}
}

View file

@ -199,6 +199,7 @@ namespace ostd
// Utilities
static u32 count_codepoints(const cpp_string& s);
static bool is_valid(const cpp_string& s);
static String truncate(const String& s, u32 maxCps);
};
friend std::ostream& operator<<(std::ostream& out, const String& val);

View file

@ -436,6 +436,14 @@ namespace ostd
replace_range(start, end, String(""));
}
void TextBuffer::deleteSelection(void)
{
if (hasSelection()) {
replace_range(selectionStart(), selectionEnd(), String(""));
return;
}
}
// ============================================================
// toString

View file

@ -121,6 +121,7 @@ namespace ostd
void backspaceWord(void);
void deleteWord(void);
void deleteCurrentLine(void);
void deleteSelection(void);
// ===================== Notifications ======================
inline void setOnChanged(ChangeCallback cb) { m_onChanged = std::move(cb); }

View file

@ -279,4 +279,16 @@ namespace ostd
return true;
}
String String::utf8::truncate(const String& s, u32 maxCps)
{
const cpp_string& bytes = s.cpp_str();
u32 i = 0;
u32 cpCount = 0;
while (i < bytes.size() && cpCount < maxCps) {
i = String::utf8::next_codepoint_start(bytes, i);
cpCount++;
}
return s.new_substr(0, cast<i32>(i));
}
}