Started work on TextEdit widget

This commit is contained in:
OmniaX-Dev 2026-05-07 06:39:43 +02:00
parent 655d7b1f98
commit a3eb4ba3cc
17 changed files with 900 additions and 351 deletions

17
build
View file

@ -43,5 +43,22 @@ else
cd bin
./ostd_test
cd ..
elif [[ "$1" == "dbg" ]]; then
printf "\n\033[0;32mDebugging Test Application...\n\n\033[0m"
cd bin
gdb -batch \
-ex 'set pagination off' \
-ex 'set print pretty on' \
-ex 'set logging file ../gdb-last.log' \
-ex 'set logging overwrite on' \
-ex 'set logging on' \
-ex 'handle SIGPIPE nostop noprint pass' \
-ex run \
-ex 'bt full' \
-ex 'info locals' \
-ex 'info args' \
-ex quit \
--args ./ostd_test
cd ..
fi
fi

View file

@ -388,3 +388,14 @@ macro set_common_values(_borderColor = $borderColor, _fontSize = 18, _showBorder
set_focus_border($blueAccent)
}
% ======================
% ====== TextEdit ======
(textEdit) {
set_common_values()
set_focus_border($blueAccent)
backgroundColor = $darkColor
textColor = $textColor
}
% ======================

View file

@ -29,6 +29,9 @@
***FIX: KeyPress crash on TreeView
***FIX: Animated icons not working in TreeView
***Check if possible, then change all WindowCore& refs to Window&
***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
Implement cursors in Stylesheet
FIX: Window getting exponentially bigger when Desktop scale changes
FIX: Refreshing scroll when content extent changes
@ -56,15 +59,15 @@ FIX: ContextMenu rendering partly outside the screen when showing it close to th
Add ToolBar::addButton overload that takes a ogfx::Icon wrapper
Redesign theme overrides to be more robust
Add modifiers to Event.keyboard path
Text:
Check if Text Entered events are enabled via SDL
Add utf-8 codepoint walking functions to String
Change the stored text in KeyEventData from a single char to the full utf-8 string
Implement a font-fallback chain in FontGlyphAtlas
Implement appendText in TextBuffer and TextEdit
Implement per-glyph caching in BasicRenderer2D
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

View file

@ -1 +1 @@
2076
2077

View file

@ -70,7 +70,7 @@ namespace ogfx
{
public: enum class eKeyEvent { Pressed = 0, Released, Text };
public:
inline KeyEventData(WindowCore& parent, i32 key, char _text, eKeyEvent evt) : parentWindow(parent), keyCode(key), text(_text), eventType(evt)
inline KeyEventData(WindowCore& parent, i32 key, const String& _text, eKeyEvent evt) : parentWindow(parent), keyCode(key), text(_text), eventType(evt)
{
setTypeName("ogfx::KeyEventData");
validate();
@ -78,7 +78,7 @@ namespace ogfx
public:
i32 keyCode;
char text;
String text;
eKeyEvent eventType;
WindowCore& parentWindow;
};

View file

@ -21,7 +21,7 @@
#include "RawTextInput.hpp"
#include "../gui/Window.hpp"
#include "../../io/Logger.hpp"
#include "utils/Keycodes.hpp"
#include "../utils/Keycodes.hpp"
namespace ogfx
{
@ -55,9 +55,9 @@ namespace ogfx
OX_ERROR("Invalid character filter in RawTextInput event listener.");
return; //TODO: Better error
}
if (parent.m_charFilter->isValidChar(data.text))
if (parent.m_charFilter->isValidChar(data.text[0]))
{
if (parent.m_lastChar == data.text && parent.m_keyRepeatCounter.isCounting())
if (parent.m_lastChar == data.text[0] && parent.m_keyRepeatCounter.isCounting())
{
onSignalHandled(signal);
return;
@ -66,10 +66,10 @@ namespace ogfx
String s2 = "";
if (parent.m_cursorPosition < parent.m_text.len())
s2 = parent.m_text.new_substr(parent.m_cursorPosition);
s1.addChar(data.text).add(s2);
s1.addChar(data.text[0]).add(s2);
parent.m_text = s1;
parent.m_cursorPosition++;
parent.m_lastChar = data.text;
parent.m_lastChar = data.text[0];
parent.m_keyRepeatCounter.start();
if (parent.m_theme.cursorBlink)
parent.m_theme.cursorBlinkCounter.start();

View file

@ -217,28 +217,6 @@ namespace ogfx
}
}
void WidgetManager::onKeyPressed(const Event& event)
{
auto focused = m_window.getFocusManager().getFocused();
if (!focused || !focused->isVisible()) return;
focused->__onKeyPressed(event);
}
void WidgetManager::onKeyReleased(const Event& event)
{
auto focus = m_window.getFocusManager();
auto focused = focus.getFocused();
if (!focused || !focused->isVisible()) return;
focused->__onKeyReleased(event);
}
void WidgetManager::onTextEntered(const Event& event)
{
auto focused = m_window.getFocusManager().getFocused();
if (!focused || !focused->isVisible()) return;
focused->__onTextEntered(event);
}
void WidgetManager::onWindowClosed(const Event& event)
{
for (auto& w : m_widgetList)

View file

@ -50,9 +50,6 @@ namespace ogfx
void onMouseMoved(const Event& event);
void onMouseScrolled(const Event& event);
void onMouseDragged(const Event& event);
void onKeyPressed(const Event& event);
void onKeyReleased(const Event& event);
void onTextEntered(const Event& event);
void onWindowClosed(const Event& event);
void onWindowResized(const Event& event);
void onWindowFocused(const Event& event);

View file

@ -30,6 +30,7 @@
#include <ogfx/gui/widgets/Slider.hpp>
#include <ogfx/gui/widgets/ComboBox.hpp>
#include <ogfx/gui/widgets/TreeView.hpp>
#include <ogfx/gui/widgets/Text.hpp>
namespace ogfx
{

View file

@ -617,21 +617,21 @@ namespace ogfx
MouseEventData mmd = get_mouse_state(event);
ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::MouseReleased, ostd::Signal::Priority::RealTime, mmd);
}
else if (event.type == SDL_EVENT_TEXT_INPUT)
{
KeyEventData ked(*this, 0, event.text.text, KeyEventData::eKeyEvent::Text);
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, 0, KeyEventData::eKeyEvent::Pressed);
KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Pressed);
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, 0, KeyEventData::eKeyEvent::Released);
KeyEventData ked(*this, (i32)event.key.key, "", KeyEventData::eKeyEvent::Released);
ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::KeyReleased, ostd::Signal::Priority::RealTime, ked);
}
else if (event.type == SDL_EVENT_TEXT_INPUT)
{
KeyEventData ked(*this, 0, event.text.text[0], KeyEventData::eKeyEvent::Text);
ostd::SignalHandler::emitSignal(ostd::BuiltinSignals::TextEntered, ostd::Signal::Priority::RealTime, ked);
}
__on_event(event);
}
@ -1002,7 +1002,9 @@ namespace ogfx
evt.__original_signal_id = ostd::BuiltinSignals::KeyPressed;
m_toolbar.__onKeyPressed(evt);
m_statusbar.__onKeyPressed(evt);
m_rootWidget.__onKeyPressed(evt);
auto focused = m_focusManager.getFocused();
if (focused)
focused->__onKeyPressed(evt);
}
else if (signal.ID == ostd::BuiltinSignals::KeyReleased)
{
@ -1011,7 +1013,9 @@ namespace ogfx
m_focusManager.onKeyReleased(evt);
m_toolbar.__onKeyReleased(evt);
m_statusbar.__onKeyReleased(evt);
m_rootWidget.__onKeyReleased(evt);
auto focused = m_focusManager.getFocused();
if (focused)
focused->__onKeyReleased(evt);
if (evt.keyboard->keyCode == KeyCode::Escape)
close();
}
@ -1021,7 +1025,9 @@ namespace ogfx
evt.__original_signal_id = ostd::BuiltinSignals::TextEntered;
m_toolbar.__onTextEntered(evt);
m_statusbar.__onTextEntered(evt);
m_rootWidget.__onTextEntered(evt);
auto focused = m_focusManager.getFocused();
if (focused)
focused->__onTextEntered(evt);
}
onSignal(signal);
}

View file

@ -19,11 +19,427 @@
*/
#include "Text.hpp"
#include "../../utils/Keycodes.hpp"
#include "../../render/BasicRenderer.hpp"
namespace ostd
namespace ogfx
{
namespace gui
{
TextEdit& TextEdit::create(void)
{
setPadding({ 0, 0, 0, 0 });
setTypeName("ogfx::gui::TextEdit");
disableChildren();
setStylesheetCategoryName("textEdit");
enableFocus();
enableBackground();
enableBorder();
enableTextEnteredEvent(true);
setSize({ 200, 30 });
m_cursorBlinkTimer.start();
m_buffer.setOnChanged([this](const ostd::TextBuffer&) {
m_layout.dirty = true;
});
m_buffer.setOnCursorMoved([this](const ostd::TextBuffer&) {
if (m_cursorBlink)
m_cursorBlinkTimer.start();
m_cursorState = true;
});
validate();
return *this;
}
void TextEdit::applyTheme(const ostd::Stylesheet& theme)
{
}
void TextEdit::onDraw(ogfx::BasicRenderer2D& gfx)
{
const auto bounds = getGlobalBounds();
// -------------------------------------------------------------
// 1. Rebuild the layout cache if the buffer (or font/size) has
// changed since last frame. We mark dirty in the buffer's
// onChanged callback (set up in create()).
// -------------------------------------------------------------
if (m_layout.dirty)
rebuild_layout(gfx);
// -------------------------------------------------------------
// 2. Make sure the cursor is in view. This adjusts m_scrollX as
// needed so the cursor sits inside the widget's content area.
// -------------------------------------------------------------
ensure_cursor_visible(bounds);
// -------------------------------------------------------------
// 3. Compute the text content area inside the widget — the
// rectangle where text is allowed to render. Anything drawn
// outside this rect gets clipped away.
// -------------------------------------------------------------
const Rectangle contentArea {
bounds.x + m_textPadding.left,
bounds.y,
bounds.w - m_textPadding.left - m_textPadding.right,
bounds.h
};
// -------------------------------------------------------------
// 5. Compute the y position for the text baseline area. Vertically
// centered using the cached line height. This is single-line
// behavior; for multiline you'd loop over lines and offset y
// by lineHeight per row.
// -------------------------------------------------------------
const f32 textY = bounds.y + (bounds.h - m_layout.lineHeight) * 0.5f;
const f32 textX = contentArea.x - m_scrollX;
// -------------------------------------------------------------
// 6. Draw the selection highlight FIRST (under the text). This
// way the text stays the regular color and just sits on top
// of a colored band — like every text editor.
// -------------------------------------------------------------
if (m_buffer.hasSelection())
{
const u32 selStart = m_buffer.selectionStart();
const u32 selEnd = m_buffer.selectionEnd();
const f32 x1 = textX + layout_x_for_byte(selStart);
const f32 x2 = textX + layout_x_for_byte(selEnd);
gfx.fillRect(
{ x1, textY, x2 - x1, m_layout.lineHeight },
m_selectionColor
);
}
// -------------------------------------------------------------
// 7. Draw the text itself. Single drawString call — the layout
// cache is only used for cursor / selection positioning;
// actual glyph rendering goes through the renderer's existing
// code path (which handles kerning, atlas, etc.).
// -------------------------------------------------------------
if (!m_buffer.empty())
gfx.drawString(m_buffer.text(), { textX, textY }, getTextColor(), getFontSize());
// -------------------------------------------------------------
// 8. Draw the cursor. Only when focused and the blink is in the
// "on" phase. The cursor renders ON TOP of everything else so
// it's visible inside selections.
// -------------------------------------------------------------
if (isFocused() && (m_cursorState || !m_cursorBlink))
{
const f32 cx = textX + layout_x_for_byte(m_buffer.cursorByteOffset());
gfx.fillRect(
{ cx, textY, m_cursorWidth, m_layout.lineHeight },
m_cursorColor
);
}
}
void TextEdit::onUpdate(void)
{
m_keyRepeatTimer.update();
m_doubleClickTimer.update();
if (m_cursorBlink)
{
m_cursorBlinkTimer.update();
if (m_cursorBlinkTimer.isDone())
{
m_cursorState = !m_cursorState;
m_cursorBlinkTimer.start();
}
}
// Auto-scroll while drag-selecting past the widget edges.
if (m_mouseSelecting)
{
const auto bounds = getGlobalBounds();
const f32 contentLeft = m_textPadding.left;
const f32 contentRight = bounds.w - m_textPadding.right;
// Only act when mouse is outside the content area horizontally.
if (m_lastMouseLocalX < contentLeft || m_lastMouseLocalX > contentRight)
{
// Use the same hit-test as drag, but it'll naturally clamp to
// start/end of buffer when the mouse is way outside. The
// setCursorByteOffset call will trigger ensure_cursor_visible
// on the next draw, which scrolls the view to follow.
const u32 hit = byte_offset_for_pixel_x(m_lastMouseLocalX);
m_buffer.setCursorByteOffset(hit, true);
}
}
}
void TextEdit::onFocusGained(const Event& event)
{
}
void TextEdit::onFocusLost(const Event& event)
{
}
void TextEdit::onTextEntered(const Event& event)
{
auto& data = *event.keyboard;
if (!m_charFilter || m_charFilter->isValidChar(data.text))
{
if (m_lastChar == data.text && m_keyRepeatTimer.isCounting())
return;
m_buffer.insertText(data.text);
m_lastChar = data.text;
m_keyRepeatTimer.start();
if (m_cursorBlink)
m_cursorBlinkTimer.start();
m_cursorState = true;
}
}
void TextEdit::onKeyPressed(const Event& event)
{
auto& data = *event.keyboard;
if (m_lastKeyCode == data.keyCode && m_keyRepeatTimer.isCounting())
return;
if (data.keyCode == KeyCode::Backspace)
{
m_keyRepeatTimer.start();
m_lastKeyCode = data.keyCode;
m_buffer.backspace();
}
else if (data.keyCode == KeyCode::Left)
{
m_keyRepeatTimer.start();
m_lastKeyCode = data.keyCode;
m_buffer.moveLeft();
}
else if (data.keyCode == KeyCode::Right)
{
m_keyRepeatTimer.start();
m_lastKeyCode = data.keyCode;
m_buffer.moveRight();
}
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)
{
}
void TextEdit::onMousePressed(const Event& event)
{
auto& data = *event.mouse;
const f32 local_x = data.position_x - getGlobalBounds().x;
const u32 hit = byte_offset_for_pixel_x(local_x);
// Double-click detection: a second press within the timer window AND
// close enough to the last press position counts as a double-click.
// The position check matters — without it, two presses far apart in
// a long document would falsely trigger word selection.
constexpr f32 doubleClickRadius = 5.0f;
const bool isDoubleClick =
m_lastClickValid
&& m_doubleClickTimer.isCounting()
&& std::abs(local_x - m_lastClickLocalX) < doubleClickRadius;
if (isDoubleClick)
{
// Word selection. selectWordAt sets both anchor and cursor;
// we mark the drag flag so subsequent drag motion extends the
// selection by word — though we'll keep the simple implementation
// for now where dragging after a double-click extends by character.
m_buffer.selectWordAt(hit);
m_mouseSelecting = true;
m_lastClickValid = false; // Don't triple-click into something weird.
}
else
{
m_buffer.setCursorByteOffset(hit);
m_mouseSelecting = true;
m_lastClickLocalX = local_x;
m_lastMouseLocalX = local_x;
m_lastClickValid = true;
m_doubleClickTimer.start();
}
if (m_cursorBlink) m_cursorBlinkTimer.start();
m_cursorState = true;
}
void TextEdit::onMouseReleased(const Event& event)
{
m_mouseSelecting = false;
}
void TextEdit::onMouseDragged(const Event& event)
{
if (!m_mouseSelecting) return;
auto& data = *event.mouse;
const f32 local_x = data.position_x - getGlobalBounds().x;
const u32 hit = byte_offset_for_pixel_x(local_x);
// Extend selection to the dragged-to position. The anchor was set by
// setCursorByteOffset(hit) in onMousePressed and stays put.
m_buffer.setCursorByteOffset(hit, true);
m_lastMouseLocalX = local_x;
if (m_cursorBlink) m_cursorBlinkTimer.start();
m_cursorState = true;
}
void TextEdit::rebuild_layout(BasicRenderer2D& gfx)
{
m_layout.byteOffsets.clear();
m_layout.xPositions.clear();
m_layout.totalWidth = 0.0f;
// Empty buffer: a single entry for "x=0 at byte 0" so the cursor
// still has a position to render at.
if (m_buffer.empty())
{
m_layout.lineHeight = gfx.getStringDimensions(" ", getFontSize()).y;
m_layout.byteOffsets.push_back(0);
m_layout.xPositions.push_back(0.0f);
m_layout.dirty = false;
return;
}
// One call to the renderer. Returns one Vec2 per codepoint:
// .x = advance (incl. kerning) contributed by that glyph
// .y = glyph height (same for all glyphs at this font/size)
const stdvec<Vec2> perChar =
gfx.getStringDimensionsPerCharacter(m_buffer.text(), getFontSize());
if (perChar.empty())
{
// Defensive: shouldn't happen with non-empty text, but if the
// font failed to load we still want a valid layout.
m_layout.lineHeight = 0.0f;
m_layout.byteOffsets.push_back(0);
m_layout.xPositions.push_back(0.0f);
m_layout.dirty = false;
return;
}
m_layout.lineHeight = perChar[0].y;
// Walk both the per-character advances AND the buffer's UTF-8 bytes
// in lockstep. Since processString emits exactly one glyph per
// codepoint, the i-th advance corresponds to the codepoint that
// starts at the i-th byte boundary.
const ostd::cpp_string& s = m_buffer.text().cpp_str();
f32 x = 0.0f;
u32 byte_pos = 0;
size_t glyph_idx = 0;
// First entry: position before any glyph.
m_layout.byteOffsets.push_back(0);
m_layout.xPositions.push_back(0.0f);
while (byte_pos < s.size() && glyph_idx < perChar.size())
{
x += perChar[glyph_idx].x;
byte_pos = String::utf8::next_codepoint_start(s, byte_pos);
m_layout.byteOffsets.push_back(byte_pos);
m_layout.xPositions.push_back(x);
glyph_idx++;
}
m_layout.totalWidth = x;
m_layout.dirty = false;
}
f32 TextEdit::layout_x_for_byte(u32 byte_offset) const
{
// Binary search for the entry. Layout invariant: byteOffsets is
// strictly increasing, so std::lower_bound gives us the right index.
const auto& v = m_layout.byteOffsets;
if (v.empty()) return 0.0f;
if (byte_offset >= v.back()) return m_layout.xPositions.back();
auto it = std::lower_bound(v.begin(), v.end(), byte_offset);
const size_t idx = std::distance(v.begin(), it);
return m_layout.xPositions[idx];
}
void TextEdit::ensure_cursor_visible(const Rectangle& bounds)
{
const f32 contentW = bounds.w - m_textPadding.left - m_textPadding.right;
if (contentW <= 0.0f) return;
const f32 cursorX = layout_x_for_byte(m_buffer.cursorByteOffset());
const f32 totalW = m_layout.totalWidth;
// Margin: how far the cursor should stay from the visible edges.
// ~2 character widths feels right; using line height as a proxy works
// well across font sizes since char width scales similarly.
// This single-sourced value also caps itself at 25% of contentW so it
// stays sensible even in very narrow widgets.
const f32 marginRaw = m_layout.lineHeight * 0.6f;
const f32 margin = std::min(marginRaw, contentW * 0.25f);
// Case 1: all the text fits. No scroll needed.
if (totalW <= contentW)
{
m_scrollX = 0.0f;
return;
}
// Case 2: text overflows. Apply margin-aware scrolling.
//
// The visible window in document space is [m_scrollX, m_scrollX + contentW).
// We want cursorX to satisfy:
// m_scrollX + margin <= cursorX <= m_scrollX + contentW - margin
// — except clamped at the document edges.
// If cursor is too close to the left edge of the visible window, scroll
// left so there's `margin` of text visible to its left.
if (cursorX < m_scrollX + margin)
m_scrollX = cursorX - margin;
// If cursor is too close to the right edge, scroll right so there's
// `margin` of room to its right.
else if (cursorX > m_scrollX + contentW - margin)
m_scrollX = cursorX - contentW + margin;
// Clamp scrollX to valid range. The lower bound is 0 (don't show empty
// space to the left of the text). The upper bound is totalW - contentW
// (don't show empty space to the right of the text — i.e., the right
// edge of the text aligns with the right edge of the visible area).
//
// This upper clamp is what produces the "right-anchored when at end"
// behavior automatically: when cursor is at end, the margin formula
// wants to scroll past the end of the text, but we clamp it back so
// the text's right edge sits at the visible area's right edge.
if (m_scrollX < 0.0f)
m_scrollX = 0.0f;
if (m_scrollX > totalW - contentW)
m_scrollX = totalW - contentW;
}
u32 TextEdit::byte_offset_for_pixel_x(f32 widget_local_x) const
{
const f32 doc_x = widget_local_x - m_textPadding.left + m_scrollX;
const auto& xs = m_layout.xPositions;
const auto& bs = m_layout.byteOffsets;
if (xs.empty()) return 0;
if (doc_x <= xs.front()) return bs.front();
if (doc_x >= xs.back()) return bs.back();
auto it = std::upper_bound(xs.begin(), xs.end(), doc_x);
const size_t hi = std::distance(xs.begin(), it);
const size_t lo = hi - 1;
const f32 distLo = doc_x - xs[lo];
const f32 distHi = xs[hi] - doc_x;
return cast<u32>((distLo <= distHi) ? bs[lo] : bs[hi]);
}
}
}

View file

@ -20,11 +20,77 @@
#pragma once
#include "Widget.hpp"
#include <ogfx/gui/widgets/Widget.hpp>
#include <ostd/string/TextBuffer.hpp>
#include <ostd/utils/Time.hpp>
namespace ostd
namespace ogfx
{
namespace gui
{
class TextEdit : public Widget
{
public: class CharacterFilter
{
public:
inline virtual bool isValidChar(const String& utf8) { return true; }
};
public: struct TextPadding
{
f32 left { 6.0f };
f32 right { 6.0f };
};
public: struct GlyphLayout
{
stdvec<u32> byteOffsets; // size N+1: byte offset before each codepoint, plus end
stdvec<f32> xPositions; // size N+1: pixel x before each codepoint, plus end-x
f32 totalWidth { 0 };
f32 lineHeight { 0 };
bool dirty { true };
};
public:
inline TextEdit(Window& window) : Widget({ 0, 0, 0, 0 }, window) { create(); }
TextEdit& create(void);
void applyTheme(const ostd::Stylesheet& theme) override;
void onDraw(ogfx::BasicRenderer2D& gfx) override;
void onUpdate(void) override;
void onFocusGained(const Event& event) override;
void onFocusLost(const Event& event) override;
void onTextEntered(const Event& event) override;
void onKeyPressed(const Event& event) override;
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); }
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;
private:
ostd::TextBuffer m_buffer { "", true };
ostd::BasicCounter m_cursorBlinkTimer { 30 };
ostd::BasicCounter m_keyRepeatTimer { 1 };
ostd::BasicCounter m_doubleClickTimer { 24 }; // ~400ms at 60fps
f32 m_lastClickLocalX { 0.0f };
bool m_lastClickValid { false };
bool m_mouseSelecting { false };
f32 m_lastMouseLocalX { 0.0f };
CharacterFilter* m_charFilter { nullptr };
String m_lastChar { "" };
i32 m_lastKeyCode { 0 };
bool m_cursorBlink { true };
bool m_cursorState { false };
f32 m_cursorWidth { 2 };
Color m_cursorColor { Colors::Red };
GlyphLayout m_layout;
f32 m_scrollX { 0.0f };
TextPadding m_textPadding;
Color m_selectionColor { 60, 110, 200, 200 }; // bluish, semi-transparent
};
}
}

View file

@ -325,6 +325,13 @@ namespace ostd
move_cursor_to(cast<u32>(m_buffer.len()), extend);
}
void TextBuffer::setCursorByteOffset(u32 byte_offset, bool extend)
{
const u32 clamped = clamp_to_codepoint_boundary(byte_offset);
move_cursor_to(clamped, extend);
m_desiredColumn = -1;
}
// ============================================================
// Mutation
@ -602,6 +609,50 @@ namespace ostd
}
}
void TextBuffer::selectWordAt(u32 byte_offset)
{
m_desiredColumn = -1;
if (m_buffer.empty()) return;
// Clamp to a valid codepoint boundary first. If we're past end, back up
// to the last codepoint so we have something to classify.
u32 pos = clamp_to_codepoint_boundary(byte_offset);
if (pos >= m_buffer.len()) {
// At end-of-buffer — step back one codepoint to get something to
// classify, then expand from there.
if (m_buffer.len() == 0) return;
pos = String::utf8::prev_codepoint_start(m_buffer.cpp_str(), m_buffer.len());
}
// Expand left and right from `pos`. The trick: prev_word_boundary and
// next_word_boundary are *almost* what we want, but they're designed
// for "skip the run AND following whitespace" semantics. For word
// *selection*, we want just the run.
//
// Easiest: classify the byte at pos, then walk outward in both
// directions while the class matches.
const cpp_string& s = m_buffer.cpp_str();
const WordClass cls = classify_byte(cast<u8>(s[pos]));
// Walk left: advance start backward while previous codepoint is same class.
u32 start = pos;
while (start > 0) {
const u32 prev = String::utf8::prev_codepoint_start(s, start);
if (classify_byte(cast<u8>(s[prev])) != cls) break;
start = prev;
}
// Walk right: advance end forward while current codepoint is same class.
u32 end = pos;
while (end < s.size()) {
if (classify_byte(cast<u8>(s[end])) != cls) break;
end = String::utf8::next_codepoint_start(s, end);
}
selectRange(start, end);
}
u32 TextBuffer::prev_word_boundary(u32 byte_offset) const
{
const cpp_string& s = m_buffer.cpp_str();

View file

@ -94,6 +94,7 @@ namespace ostd
String selectedText(void) const;
void clearSelection(void);
void selectAll(void);
void selectWordAt(u32 byte_offset);
void selectRange(u32 start_byte, u32 end_byte);
// ===================== Cursor movement ====================
@ -108,6 +109,7 @@ namespace ostd
void moveLineEnd(bool extend = false);
void moveDocumentStart(bool extend = false);
void moveDocumentEnd(bool extend = false);
void setCursorByteOffset(u32 byte_offset, bool extend);
// ======================== Mutation ========================
// All mutators replace the current selection (if any) before

View file

@ -29,7 +29,6 @@ class Window : public ogfx::GraphicsWindow
inline Window(void) { }
inline void onInitialize(void) override
{
connectSignal(ogfx::gui::RawTextInput::actionEventSignalID);
m_gfx.init(*this);
}

View file

@ -229,6 +229,7 @@ class TestWindow : public Window
t1.addWidget(m_list, { 30, 300 });
t1.addWidget(m_panel2, { 500, 100 });
t1.addWidget(m_combo, { 400, 600 });
t1.addWidget(m_text, { 400, 700 });
t2.setLayout<BoxLayout>(BoxLayout::Orientation::Vertical);
t2.getLayout()->setSpacing(16);
@ -390,6 +391,7 @@ class TestWindow : public Window
Label m_cacheHitsLbl { *this };
Label m_cacheMissesLbl { *this };
ComboBox m_combo { *this };
TextEdit m_text { *this };
enum MenuId : i32 { New = 1, Open, Save, SaveAs, Exit, CopyRaw, CopyFormatted };