From 61b3f8f973658d15ccb9e926038792dcdf2e1952 Mon Sep 17 00:00:00 2001 From: boolean-false Date: Mon, 28 Sep 2026 21:49:20 +0700 Subject: [PATCH 1/2] Add textbox selection and layout API for custom rendering --- doc/en/scripting/ui.md | 56 +++++++++++ doc/ru/scripting/ui.md | 55 +++++++++++ src/graphics/ui/elements/Label.cpp | 26 ++++- src/graphics/ui/elements/Label.hpp | 6 ++ src/graphics/ui/elements/TextBox.cpp | 123 +++++++++++++++++++++++- src/graphics/ui/elements/TextBox.hpp | 27 ++++++ src/logic/scripting/lua/libs/libgui.cpp | 105 ++++++++++++++++++++ 7 files changed, 393 insertions(+), 5 deletions(-) diff --git a/doc/en/scripting/ui.md b/doc/en/scripting/ui.md index 7a11b2f5e..64e736119 100644 --- a/doc/en/scripting/ui.md +++ b/doc/en/scripting/ui.md @@ -99,6 +99,9 @@ Properties: | textColor | vec4 | yes | yes | text color | | syntax | string | yes | yes | syntax highlighting ("lua" - Lua) | | markup | string | yes | yes | text markup language ("md" - Markdown) | +| selection | ivec2 | yes | yes | selection anchor and caret; see below | +| externalRendering | bool | yes | yes | external text rendering (default: false) | +| textLayout | table | yes | no | snapshot of visible text layout; see below | \* - false only @@ -110,6 +113,59 @@ Methods: | lineAt(pos: int) -> int | determines the line number by position in the text | | linePos(line: int) -> int | determines the position of the beginning of the line in the text | +### Selection and external rendering + +- `selection` (read/write): `{anchor, caret}`, preserving direction. Indices + are zero-based native caret character units, not UTF-8 byte offsets. Setters + require finite integers, clamp to the text length and reveal the caret, + including immediately after replacing text. Setting selection does not + edit text or reset undo history. +- `externalRendering` (read/write boolean, default `false`): hides native text, + caret, selection and current-line highlight. Input, hit testing, layout, + scrolling, background, line numbers and scrollbar remain native. Setting it + back to `false` restores native rendering without replacing the textbox. + Markup and external rendering cannot be combined: either setter rejects it. +- `textLayout` (read-only table): a snapshot of the current visible layout. + `ready=false` means the font is unavailable or markup is active. Otherwise: + - `text`: the value of `textbox.text`, including `placeholder` when input is empty; + `font`: font name. + - `lineHeight`: row pitch; `textHeight`: height to use for a label displaying a row. + - `caretRect`: `{x,y,cellWidth,rowHeight}` at the moving selection endpoint; + at end-of-line the cell width is the space advance. This is geometry, + independent of focus, editability and blink phase. + - `selectionRects`: `{x, y, width, height}` rectangles for visible selected rows, + including selected newline cells. + - `lines`: visible rows `{start, text, pos={x,y}, advances={...}}`; + `start` is a native caret index, text excludes the trailing newline, + `advances` contains the native width of each character. Empty input may + expose the hint as display rows; it does not replace `text` in the snapshot. + +Coordinates are relative to the textbox, include scrolling and alignment, +and may extend outside its bounds. External painters must clip to the field. +Snapshots are independent Lua tables; modifying them does not modify the box. +Read them again after input, resizing or scrolling. Syntax colors are not +exported; the external painter supplies its own colors. Indexing and text input +follow the existing textbox behavior. + +```lua +local field = document.input +field.text = "abcdef" +field.selection = {5, 2} +local anchor, caret = unpack(field.selection) + +field.externalRendering = true +local layout = field.textLayout +if layout.ready then + for _, line in ipairs(layout.lines) do + print(line.start, line.text, line.pos[1], line.pos[2]) + end +end +``` + +In this example the caret is at position 2 and the selection anchor is at +position 5. A negative selection index is clamped to zero; unlike `caret`, +it does not address characters from the end of the text. + ## Slider (trackbar) Properties: diff --git a/doc/ru/scripting/ui.md b/doc/ru/scripting/ui.md index 09e296f9b..0eb08af60 100644 --- a/doc/ru/scripting/ui.md +++ b/doc/ru/scripting/ui.md @@ -99,6 +99,9 @@ document["worlds-panel"]:clear() | textColor | vec4 | да | да | цвет текста | | syntax | string | да | да | подсветка синтаксиса ("lua" - Lua) | | markup | string | да | да | язык разметки текста ("md" - Markdown) | +| selection | ivec2 | да | да | закреплённый конец выделения и каретка; см. ниже | +| externalRendering | bool | да | да | внешняя отрисовка текста (по умолчанию false) | +| textLayout | table | да | нет | снимок видимой раскладки текста; см. ниже | \* - только false @@ -110,6 +113,58 @@ document["worlds-panel"]:clear() | lineAt(pos: int) -> int | определяет номер строки по позиции в тексте | | linePos(line: int) -> int | определяет позицию начала строки в тексте | +### Выделение и внешняя отрисовка + +- `selection` (чтение/запись): `{anchor, caret}` - направленное выделение. + Индексы с нуля в единицах нативного `caret`, не байтах UTF-8. Допускаются + только конечные целые числа; значения ограничиваются длиной текста. + Установка прокручивает поле к каретке, в том числе сразу после замены текста, + не меняет текст и не сбрасывает undo. +- `externalRendering` (boolean, чтение/запись, по умолчанию `false`): скрывает + текст, каретку, выделение и подсветку текущей строки. Ввод, hit testing, + раскладка, прокрутка, фон, номера строк и скроллбар остаются нативными. + `false` возвращает нативную отрисовку без пересоздания поля. Совмещение с + `markup` запрещено в обоих направлениях и вызывает ошибку. +- `textLayout` (таблица, только чтение): снимок видимой раскладки. + `ready=false`, если шрифт недоступен либо используется markup. Иначе: + - `text` - значение `textbox.text`, включая `placeholder` при пустом вводе; + `font` - имя шрифта; + - `lineHeight` - шаг строки, `textHeight` - высота label для отрисовки строки; + - `caretRect={x,y,cellWidth,rowHeight}` - геометрия каретки, без учёта фокуса, + editable и мигания; на конце строки ширина ячейки равна ширине пробела; + - `selectionRects` - прямоугольники `{x, y, width, height}` для видимых + выделенных строк, включая ячейки переводов строк; + - `lines` - видимые строки `{start,text,pos={x,y},advances={...}}`: нативный + индекс начала, текст без завершающего перевода строки, позиция и ширины + отдельных символов. Для пустого ввода строки могут содержать hint, + при этом hint не подменяет поле `text` в снимке. + +Координаты локальны textbox, учитывают выравнивание и прокрутку, могут выходить +за его границы. Внешняя отрисовка должна обрезаться областью поля. Снимок - +независимая Lua-таблица; её изменение не меняет textbox. После ввода, изменения +размеров и прокрутки нужно читать новый снимок. Цвета syntax не экспортируются: +внешний рендерер задаёт их самостоятельно. Индексация и ввод текста +следуют существующему поведению textbox. + +```lua +local field = document.input +field.text = "abcdef" +field.selection = {5, 2} +local anchor, caret = unpack(field.selection) + +field.externalRendering = true +local layout = field.textLayout +if layout.ready then + for _, line in ipairs(layout.lines) do + print(line.start, line.text, line.pos[1], line.pos[2]) + end +end +``` + +В примере каретка находится на позиции 2, закреплённый конец выделения - на +позиции 5. Отрицательный индекс выделения ограничивается нулём; в отличие +от `caret`, он не задаёт позицию относительно конца текста. + ## Ползунок (trackbar) Свойства: diff --git a/src/graphics/ui/elements/Label.cpp b/src/graphics/ui/elements/Label.cpp index effb79830..f6ec0e618 100644 --- a/src/graphics/ui/elements/Label.cpp +++ b/src/graphics/ui/elements/Label.cpp @@ -204,11 +204,18 @@ uint Label::getLinesNumber() const { return cache.lines.size(); } -void Label::draw(const DrawContext& pctx, const Assets& assets) { - auto batch = pctx.getBatch2D(); +glm::vec2 Label::getTextOrigin() const { + return textOrigin; +} + +void Label::setRenderText(bool value) { + renderText = value; +} + +bool Label::prepareLayout(const Assets& assets) { auto font = assets.getShared(fontName); if (font == nullptr) { - return; + return false; } cache.prepare( font, @@ -222,7 +229,6 @@ void Label::draw(const DrawContext& pctx, const Assets& assets) { if (cache.resetFlag) { cache.update(text, multiline, textWrap); } - batch->setColor(calcColor()); uint lineHeight = font->getLineHeight(); if (cache.lines.size() > 1) { @@ -248,6 +254,18 @@ void Label::draw(const DrawContext& pctx, const Assets& assets) { textYOffset = pos.y-calcPos().y; totalLineHeight = lineHeight; + textOrigin = pos; + return true; +} + +void Label::draw(const DrawContext& pctx, const Assets& assets) { + if (!prepareLayout(assets) || !renderText) { + return; + } + auto batch = pctx.getBatch2D(); + auto font = assets.getShared(fontName); + batch->setColor(calcColor()); + glm::vec2 pos = textOrigin; const auto& viewport = pctx.getViewport(); glm::vec4 bounds {0, 0, viewport.x, viewport.y}; if (parent) { diff --git a/src/graphics/ui/elements/Label.hpp b/src/graphics/ui/elements/Label.hpp index e92e845b0..57477bc45 100644 --- a/src/graphics/ui/elements/Label.hpp +++ b/src/graphics/ui/elements/Label.hpp @@ -60,6 +60,8 @@ namespace gui { /// @brief Auto resize label to fit text bool autoresize = false; + bool renderText = true; + glm::vec2 textOrigin {0}; /// @brief Text markup language std::string markup; @@ -111,6 +113,10 @@ namespace gui { uint getLinesNumber() const; bool isFakeLine(size_t line) const; + bool prepareLayout(const Assets& assets); + glm::vec2 getTextOrigin() const; + void setRenderText(bool value); + void draw(const DrawContext& pctx, const Assets& assets) override; void textSupplier(wstringsupplier supplier); diff --git a/src/graphics/ui/elements/TextBox.cpp b/src/graphics/ui/elements/TextBox.cpp index d60b09142..75fa9543c 100644 --- a/src/graphics/ui/elements/TextBox.cpp +++ b/src/graphics/ui/elements/TextBox.cpp @@ -231,7 +231,7 @@ TextBox::~TextBox() = default; void TextBox::draw(const DrawContext& pctx, const Assets& assets) { Container::draw(pctx, assets); - if (!isFocused() && !keepLineSelection) { + if (externalRendering || (!isFocused() && !keepLineSelection)) { return; } const auto& labelText = getText(); @@ -1274,6 +1274,9 @@ const std::string& TextBox::getSyntax() const { } void TextBox::setMarkup(std::string_view lang) { + if (externalRendering && !lang.empty()) { + throw std::runtime_error("external textbox rendering does not support markup"); + } markup = lang; } @@ -1284,3 +1287,121 @@ const std::string& TextBox::getMarkup() const { std::shared_ptr