Finished core TextEdit widget
This commit is contained in:
parent
a3eb4ba3cc
commit
ede7db738c
12 changed files with 609 additions and 35 deletions
29
.zed/tasks.json
Normal file
29
.zed/tasks.json
Normal 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
38
gdb-last.log
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
2077
|
||||
2078
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
gfx.drawString(m_buffer.text(), { textX, textY }, getTextColor(), getFontSize());
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void TextEdit::onKeyReleased(const Event& 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -436,6 +436,14 @@ namespace ostd
|
|||
replace_range(start, end, String(""));
|
||||
}
|
||||
|
||||
void TextBuffer::deleteSelection(void)
|
||||
{
|
||||
if (hasSelection()) {
|
||||
replace_range(selectionStart(), selectionEnd(), String(""));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// toString
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue