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
|
***Add utf-8 codepoint walking functions to String
|
||||||
***Change the stored text in KeyEventData from a single char to the full utf-8 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
|
***Check if Text Entered events are enabled via SDL
|
||||||
|
***Add modifiers to Event.keyboard path
|
||||||
|
***Implement max length in TextEdit
|
||||||
Implement cursors in Stylesheet
|
Implement cursors in Stylesheet
|
||||||
FIX: Window getting exponentially bigger when Desktop scale changes
|
FIX: Window getting exponentially bigger when Desktop scale changes
|
||||||
FIX: Refreshing scroll when content extent 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
|
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
|
Add ToolBar::addButton overload that takes a ogfx::Icon wrapper
|
||||||
Redesign theme overrides to be more robust
|
Redesign theme overrides to be more robust
|
||||||
Add modifiers to Event.keyboard path
|
|
||||||
Implement a font-fallback chain in FontGlyphAtlas
|
Implement a font-fallback chain in FontGlyphAtlas
|
||||||
Implement appendText in TextBuffer and TextEdit
|
Implement appendText in TextBuffer and TextEdit
|
||||||
Implement per-glyph caching in BasicRenderer2D
|
Implement per-glyph caching in BasicRenderer2D
|
||||||
|
|
@ -66,6 +67,11 @@ Added double-click event to widgets
|
||||||
Implement shift-selection in TextEdit (mouse/keyboard)
|
Implement shift-selection in TextEdit (mouse/keyboard)
|
||||||
Implement ctrl-selection/navigation in TextEdit
|
Implement ctrl-selection/navigation in TextEdit
|
||||||
Implement home/end and pg up/pg down 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
|
***Fill
|
||||||
***ComboBox
|
***ComboBox
|
||||||
***TreeView
|
***TreeView
|
||||||
Text Input
|
***Text Input
|
||||||
|
***Numeric Field
|
||||||
|
***Password Field
|
||||||
Text Area
|
Text Area
|
||||||
Numeric Field
|
|
||||||
Color Picker
|
Color Picker
|
||||||
Calendar
|
Calendar
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
2077
|
2078
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
|
|
||||||
#include <ostd/data/BaseObject.hpp>
|
#include <ostd/data/BaseObject.hpp>
|
||||||
#include <ostd/math/Geometry.hpp>
|
#include <ostd/math/Geometry.hpp>
|
||||||
|
#include <ogfx/utils/Keycodes.hpp>
|
||||||
|
|
||||||
namespace ogfx
|
namespace ogfx
|
||||||
{
|
{
|
||||||
|
|
@ -68,18 +69,76 @@ namespace ogfx
|
||||||
};
|
};
|
||||||
class KeyEventData : public ostd::BaseObject
|
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: enum class eKeyEvent { Pressed = 0, Released, Text };
|
||||||
public:
|
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");
|
setTypeName("ogfx::KeyEventData");
|
||||||
validate();
|
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:
|
public:
|
||||||
i32 keyCode;
|
i32 keyCode;
|
||||||
String text;
|
String text;
|
||||||
eKeyEvent eventType;
|
eKeyEvent eventType;
|
||||||
|
KeyModifiers modifiers;
|
||||||
WindowCore& parentWindow;
|
WindowCore& parentWindow;
|
||||||
};
|
};
|
||||||
class DropEventData : public ostd::BaseObject
|
class DropEventData : public ostd::BaseObject
|
||||||
|
|
|
||||||
|
|
@ -525,6 +525,15 @@ namespace ogfx
|
||||||
|
|
||||||
void WindowCore::__handle_event(SDL_Event& event)
|
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)
|
if (event.type == REDRAW_EVENT)
|
||||||
{
|
{
|
||||||
//Doesn't need to do anything, the event exists just to make SDL_WaitEventTimeout() return early
|
//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)
|
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);
|
ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::TextEntered, ostd::Signal::Priority::RealTime, ked);
|
||||||
}
|
}
|
||||||
else if (event.type == SDL_EVENT_KEY_DOWN)
|
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);
|
ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::KeyPressed, ostd::Signal::Priority::RealTime, ked);
|
||||||
}
|
}
|
||||||
else if (event.type == SDL_EVENT_KEY_UP)
|
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);
|
ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::KeyReleased, ostd::Signal::Priority::RealTime, ked);
|
||||||
}
|
}
|
||||||
__on_event(event);
|
__on_event(event);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,224 @@ namespace ogfx
|
||||||
{
|
{
|
||||||
namespace gui
|
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)
|
TextEdit& TextEdit::create(void)
|
||||||
{
|
{
|
||||||
setPadding({ 0, 0, 0, 0 });
|
setPadding({ 0, 0, 0, 0 });
|
||||||
|
|
@ -118,7 +336,12 @@ namespace ogfx
|
||||||
// code path (which handles kerning, atlas, etc.).
|
// code path (which handles kerning, atlas, etc.).
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
if (!m_buffer.empty())
|
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());
|
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
|
||||||
|
|
@ -182,16 +405,15 @@ namespace ogfx
|
||||||
void TextEdit::onTextEntered(const Event& event)
|
void TextEdit::onTextEntered(const Event& event)
|
||||||
{
|
{
|
||||||
auto& data = *event.keyboard;
|
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;
|
return;
|
||||||
m_buffer.insertText(data.text);
|
m_buffer.insertText(toInsert);
|
||||||
m_lastChar = data.text;
|
m_lastChar = toInsert;
|
||||||
m_keyRepeatTimer.start();
|
m_keyRepeatTimer.start();
|
||||||
if (m_cursorBlink)
|
|
||||||
m_cursorBlinkTimer.start();
|
|
||||||
m_cursorState = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,37 +422,114 @@ namespace ogfx
|
||||||
auto& data = *event.keyboard;
|
auto& data = *event.keyboard;
|
||||||
if (m_lastKeyCode == data.keyCode && m_keyRepeatTimer.isCounting())
|
if (m_lastKeyCode == data.keyCode && m_keyRepeatTimer.isCounting())
|
||||||
return;
|
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();
|
const String sel = m_buffer.selectedText();
|
||||||
m_lastKeyCode = data.keyCode;
|
if (!sel.empty())
|
||||||
m_buffer.backspace();
|
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)
|
else if (data.keyCode == KeyCode::Left)
|
||||||
{
|
{
|
||||||
m_keyRepeatTimer.start();
|
if (word) m_buffer.moveWordLeft(extend);
|
||||||
m_lastKeyCode = data.keyCode;
|
else m_buffer.moveLeft(extend);
|
||||||
m_buffer.moveLeft();
|
|
||||||
}
|
}
|
||||||
else if (data.keyCode == KeyCode::Right)
|
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;
|
// ----- Deletion -----
|
||||||
m_buffer.moveRight();
|
|
||||||
|
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)
|
else if (data.keyCode == KeyCode::Return)
|
||||||
{
|
{
|
||||||
m_keyRepeatTimer.start();
|
|
||||||
m_lastKeyCode = data.keyCode;
|
|
||||||
if (callback_onActionPerformed)
|
if (callback_onActionPerformed)
|
||||||
callback_onActionPerformed(event);
|
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)
|
void TextEdit::onMousePressed(const Event& event)
|
||||||
|
|
@ -294,6 +593,34 @@ namespace ogfx
|
||||||
m_cursorState = true;
|
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)
|
void TextEdit::rebuild_layout(BasicRenderer2D& gfx)
|
||||||
{
|
{
|
||||||
m_layout.byteOffsets.clear();
|
m_layout.byteOffsets.clear();
|
||||||
|
|
@ -441,5 +768,37 @@ namespace ogfx
|
||||||
const f32 distHi = xs[hi] - doc_x;
|
const f32 distHi = xs[hi] - doc_x;
|
||||||
return cast<u32>((distLo <= distHi) ? bs[lo] : bs[hi]);
|
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: class CharacterFilter
|
||||||
{
|
{
|
||||||
public:
|
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
|
public: struct TextPadding
|
||||||
{
|
{
|
||||||
|
|
@ -58,18 +104,20 @@ namespace ogfx
|
||||||
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;
|
||||||
void onKeyReleased(const Event& event) override;
|
|
||||||
void onMousePressed(const Event& event) override;
|
void onMousePressed(const Event& event) override;
|
||||||
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 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:
|
private:
|
||||||
void rebuild_layout(BasicRenderer2D& gfx);
|
void rebuild_layout(BasicRenderer2D& gfx);
|
||||||
void ensure_cursor_visible(const Rectangle& bounds);
|
void ensure_cursor_visible(const Rectangle& bounds);
|
||||||
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;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
ostd::TextBuffer m_buffer { "", true };
|
ostd::TextBuffer m_buffer { "", true };
|
||||||
|
|
@ -80,6 +128,8 @@ namespace ogfx
|
||||||
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 };
|
||||||
|
char m_charMask { '\0' };
|
||||||
CharacterFilter* m_charFilter { nullptr };
|
CharacterFilter* m_charFilter { nullptr };
|
||||||
String m_lastChar { "" };
|
String m_lastChar { "" };
|
||||||
i32 m_lastKeyCode { 0 };
|
i32 m_lastKeyCode { 0 };
|
||||||
|
|
@ -92,5 +142,6 @@ namespace ogfx
|
||||||
TextPadding m_textPadding;
|
TextPadding m_textPadding;
|
||||||
Color m_selectionColor { 60, 110, 200, 200 }; // bluish, semi-transparent
|
Color m_selectionColor { 60, 110, 200, 200 }; // bluish, semi-transparent
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ namespace ostd
|
||||||
// Utilities
|
// Utilities
|
||||||
static u32 count_codepoints(const cpp_string& s);
|
static u32 count_codepoints(const cpp_string& s);
|
||||||
static bool is_valid(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);
|
friend std::ostream& operator<<(std::ostream& out, const String& val);
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,14 @@ namespace ostd
|
||||||
replace_range(start, end, String(""));
|
replace_range(start, end, String(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TextBuffer::deleteSelection(void)
|
||||||
|
{
|
||||||
|
if (hasSelection()) {
|
||||||
|
replace_range(selectionStart(), selectionEnd(), String(""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// toString
|
// toString
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ namespace ostd
|
||||||
void backspaceWord(void);
|
void backspaceWord(void);
|
||||||
void deleteWord(void);
|
void deleteWord(void);
|
||||||
void deleteCurrentLine(void);
|
void deleteCurrentLine(void);
|
||||||
|
void deleteSelection(void);
|
||||||
|
|
||||||
// ===================== Notifications ======================
|
// ===================== Notifications ======================
|
||||||
inline void setOnChanged(ChangeCallback cb) { m_onChanged = std::move(cb); }
|
inline void setOnChanged(ChangeCallback cb) { m_onChanged = std::move(cb); }
|
||||||
|
|
|
||||||
|
|
@ -279,4 +279,16 @@ namespace ostd
|
||||||
|
|
||||||
return true;
|
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