| #ifdef WITH_RIVE_TEXT | |
| #include "rive/text/raw_text_input.hpp" | |
| #include "rive/text_engine.hpp" | |
| #include "rive/factory.hpp" | |
| #include "rive/span.hpp" | |
| using namespace rive; | |
| static const Unichar zeroWidthSpace = 8203; | |
| static const Unichar obscuringBullet = 8226; | |
| RawTextInput::RawTextInput() | |
| : m_cursor(Cursor::atStart()) | |
| , m_textRun({nullptr, 16.0f, -1.0f, 0.0f, 0, 0, 0}) | |
| , m_cursorVisualPosition(CursorVisualPosition::missing()) | |
| { | |
| m_text.push_back(zeroWidthSpace); | |
| // The empty text is where undo stops, so the first edit can be undone. | |
| m_journal.push_back({m_cursor, m_cursor, std::string()}); | |
| } | |
| void RawTextInput::draw( | |
| Factory* factory, | |
| Renderer* renderer, | |
| const Mat2D& worldTransform, | |
| RenderPaint* textPaint, | |
| RenderPaint* selectionPaint, | |
| RenderPaint* cursorPaint) | |
| { | |
| if (m_overflow == TextOverflow::clipped && m_clipRenderPath) | |
| { | |
| renderer->save(); | |
| renderer->clipPath(m_clipRenderPath.get()); | |
| } | |
| if (m_cursor.hasSelection()) | |
| { | |
| auto renderPath = m_selectionPath.renderPath(factory); | |
| renderer->drawPath(renderPath, selectionPaint); | |
| } | |
| auto renderPath = m_textPath.renderPath(factory); | |
| renderer->drawPath(renderPath, textPaint); | |
| auto cursorRenderPath = m_cursorPath.renderPath(factory); | |
| renderer->drawPath(cursorRenderPath, cursorPaint); | |
| if (m_overflow == TextOverflow::clipped && m_clipRenderPath) | |
| { | |
| renderer->restore(); | |
| } | |
| } | |
| void RawTextInput::backspace(int32_t direction) | |
| { | |
| Cursor startingCursor = m_cursor; | |
| if (!m_cursor.isCollapsed()) | |
| { | |
| erase(); | |
| captureJournalEntry(startingCursor); | |
| return; | |
| } | |
| m_idealCursorX = -1.0f; | |
| ensureShape(); | |
| const auto& glyphLookup = m_shape.glyphLookup(); | |
| if (direction > 0) | |
| { | |
| // Delete forward: erase the full glyph cluster at cursor position. | |
| auto index = m_cursor.first().codePointIndex(); | |
| if (index >= m_text.size() - 1) | |
| { | |
| return; | |
| } | |
| uint32_t clusterCount = glyphLookup.count(index); | |
| m_text.erase(m_text.begin() + index, m_text.begin() + index + clusterCount); | |
| auto position = CursorPosition(index); | |
| m_cursor = Cursor::collapsed(position); | |
| } | |
| else | |
| { | |
| // Backspace: erase the full glyph cluster before cursor position. | |
| auto index = m_cursor.first().codePointIndex(); | |
| if (index == 0) | |
| { | |
| return; | |
| } | |
| uint32_t clusterStart = glyphLookup.glyphStart(index - 1); | |
| uint32_t clusterCount = glyphLookup.count(clusterStart); | |
| m_text.erase( | |
| m_text.begin() + clusterStart, | |
| m_text.begin() + clusterStart + clusterCount); | |
| auto position = CursorPosition(clusterStart); | |
| m_cursor = Cursor::collapsed(position); | |
| } | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| captureJournalEntry(startingCursor); | |
| } | |
| void RawTextInput::erase() | |
| { | |
| m_idealCursorX = -1.0f; | |
| if (m_cursor.isCollapsed()) | |
| { | |
| return; | |
| } | |
| assert(m_cursor.first().codePointIndex() < length()); | |
| assert(m_cursor.last().codePointIndex() <= length()); | |
| m_text.erase( | |
| m_text.begin() + m_cursor.first().codePointIndex(), | |
| m_text.begin() + m_cursor.last().codePointIndex()); | |
| auto position = CursorPosition(m_cursor.first().codePointIndex()); | |
| m_cursor = Cursor::collapsed(position); | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| bool RawTextInput::insert(Unichar codePoint) | |
| { | |
| return insertCodePoints(Span<const Unichar>(&codePoint, 1)); | |
| } | |
| bool RawTextInput::insert(const std::string& text) | |
| { | |
| std::vector<Unichar> codePoints; | |
| const uint8_t* ptr = (const uint8_t*)text.data(); | |
| const uint8_t* end = ptr + text.size(); | |
| while (ptr < end && *ptr) | |
| { | |
| codePoints.push_back(UTF::NextUTF8(&ptr, end)); | |
| } | |
| return insertCodePoints(codePoints); | |
| } | |
| bool RawTextInput::insertCodePoints(Span<const Unichar> codePoints) | |
| { | |
| size_t count = fittingCount(codePoints); | |
| if (count == 0 && !codePoints.empty()) | |
| { | |
| // Nothing fits, so the text, selection and undo history stay as they | |
| // are. | |
| return false; | |
| } | |
| Cursor startingCursor = m_cursor; | |
| erase(); | |
| assert(m_cursor.isCollapsed()); | |
| uint32_t codePointIndex = m_cursor.start().codePointIndex(); | |
| m_text.insert( | |
| m_text.begin() + codePointIndex, | |
| codePoints.begin(), | |
| codePoints.begin() + count); | |
| auto position = CursorPosition(codePointIndex + (uint32_t)count); | |
| m_cursor = Cursor::collapsed(position); | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| captureJournalEntry(startingCursor); | |
| return true; | |
| } | |
| // How many of codePoints, from the front, can replace the selection without | |
| // going over maxCharacters or maxLines. | |
| size_t RawTextInput::fittingCount(Span<const Unichar> codePoints) | |
| { | |
| size_t count = codePoints.size(); | |
| if (count != 0 && m_maxCharacters != 0) | |
| { | |
| size_t first = std::min( | |
| (size_t)m_cursor.first().codePointIndex(), | |
| length()); | |
| size_t last = std::min((size_t)m_cursor.last().codePointIndex(), length()); | |
| size_t kept = length() - (last - first); | |
| size_t room = m_maxCharacters > kept ? m_maxCharacters - kept : 0; | |
| count = std::min(count, room); | |
| } | |
| if (count != 0 && m_maxLines != 0 && m_textRun.font != nullptr && | |
| lineCountWith(codePoints, count, true) > m_maxLines) | |
| { | |
| // Already over at the insertion point: nothing goes in, as with | |
| // maxCharacters. | |
| if (lineCountWith(codePoints, 0, true) > m_maxLines) | |
| { | |
| return 0; | |
| } | |
| // Wrapping isn't monotonic in the prefix: one ending mid-word joins the | |
| // word after the selection and can wrap onto more lines than a longer one | |
| // ending in a space. Without the rest of the selection's paragraph it is, | |
| // as appending only ever adds lines, and putting that rest back can't | |
| // take any away. The paragraphs after it don't join, so they stay in and | |
| // keep the bound tight. So the longest prefix that fits without the rest | |
| // bounds the answer; count down from there to the first that fits with | |
| // it. | |
| size_t low = 0; | |
| size_t high = count - 1; | |
| while (low < high) | |
| { | |
| size_t middle = (low + high + 1) / 2; | |
| if (lineCountWith(codePoints, middle, false) <= m_maxLines) | |
| { | |
| low = middle; | |
| } | |
| else | |
| { | |
| high = middle - 1; | |
| } | |
| } | |
| // The count down is about as long as the rest of the paragraph is wide, | |
| // and every step shapes the whole text. Past a budget, settle for a | |
| // binary search on the real count: what it finds fits, it just isn't | |
| // always the longest prefix that does. | |
| const size_t countdownBudget = 64; | |
| size_t steps = 0; | |
| while (low > 0 && lineCountWith(codePoints, low, true) > m_maxLines) | |
| { | |
| low--; | |
| if (++steps == countdownBudget) | |
| { | |
| size_t fits = 0; | |
| while (fits < low) | |
| { | |
| size_t middle = (fits + low + 1) / 2; | |
| if (lineCountWith(codePoints, middle, true) <= m_maxLines) | |
| { | |
| fits = middle; | |
| } | |
| else | |
| { | |
| low = middle - 1; | |
| } | |
| } | |
| low = fits; | |
| break; | |
| } | |
| } | |
| count = low; | |
| } | |
| return count; | |
| } | |
| // The number of displayed lines the text would wrap to with the selection | |
| // replaced by the first count code points, and with the rest of the | |
| // selection's paragraph (the text after it, up to its line break) dropped | |
| // unless withRestOfParagraph. | |
| uint32_t RawTextInput::lineCountWith( | |
| Span<const Unichar> codePoints, | |
| size_t count, | |
| bool withRestOfParagraph) | |
| { | |
| size_t first = std::min((size_t)m_cursor.first().codePointIndex(), length()); | |
| size_t last = std::min((size_t)m_cursor.last().codePointIndex(), length()); | |
| std::vector<Unichar> candidate; | |
| candidate.reserve(m_text.size() - (last - first) + count); | |
| candidate.insert(candidate.end(), m_text.begin(), m_text.begin() + first); | |
| candidate.insert( | |
| candidate.end(), | |
| codePoints.begin(), | |
| codePoints.begin() + count); | |
| size_t after = last; | |
| if (!withRestOfParagraph) | |
| { | |
| while (after < length() && m_text[after] != '\n' && m_text[after] != '\r' && | |
| m_text[after] != 0x2029) | |
| { | |
| after++; | |
| } | |
| } | |
| // Ends with the sentinel either way. | |
| candidate.insert(candidate.end(), m_text.begin() + after, m_text.end()); | |
| if (flagged(Flags::obscured)) | |
| { | |
| // Count what would be displayed, keeping the trailing sentinel. | |
| std::fill(candidate.begin(), candidate.end() - 1, obscuringBullet); | |
| } | |
| // Borrow the measuring shape rather than keep another one around, and | |
| // have the next measure() shape again. | |
| if (!m_measuringShape) | |
| { | |
| m_measuringShape = std::make_unique<FullyShapedText>(); | |
| } | |
| flag(Flags::measureDirty); | |
| #ifdef TESTING | |
| limitShapeCount++; | |
| #endif | |
| // Wrap where the text will display. A hugging layout narrows maxWidth to | |
| // the text's current width, so wrapping there would push any line that | |
| // grows wider onto a new one: there the width measure() was last offered | |
| // is the room the text can grow into. | |
| float wrapWidth = flagged(Flags::hugsWidth) && flagged(Flags::measured) | |
| ? m_lastMeasureMaxWidth | |
| : m_maxWidth; | |
| TextRun run = m_textRun; | |
| run.unicharCount = (uint32_t)candidate.size(); | |
| m_measuringShape->shape( | |
| candidate, | |
| Span<TextRun>(&run, 1), | |
| m_sizing, | |
| wrapWidth, | |
| m_maxHeight, | |
| m_align, | |
| m_wrap, | |
| m_origin, | |
| m_overflow, | |
| m_paragraphSpacing, | |
| m_alignWidth, | |
| m_verticalAlign, | |
| m_alignHeight); | |
| // Every line the breaker produced, which lineCount() can fall short of | |
| // when overflow stops it early. | |
| uint32_t lines = 0; | |
| for (const auto& paragraphLines : m_measuringShape->paragraphLines()) | |
| { | |
| lines += (uint32_t)paragraphLines.size(); | |
| } | |
| return lines; | |
| } | |
| void RawTextInput::selectionCornerRadius(float value) | |
| { | |
| if (m_selectionCornerRadius == value) | |
| { | |
| return; | |
| } | |
| m_selectionCornerRadius = value; | |
| flag(Flags::selectionDirty); | |
| } | |
| void RawTextInput::fontSize(float value) | |
| { | |
| if (m_textRun.size == value) | |
| { | |
| return; | |
| } | |
| m_textRun.size = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::maxWidth(float value) | |
| { | |
| if (m_maxWidth == value) | |
| { | |
| return; | |
| } | |
| m_maxWidth = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::maxHeight(float value) | |
| { | |
| if (m_maxHeight == value) | |
| { | |
| return; | |
| } | |
| m_maxHeight = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::sizing(TextSizing value) | |
| { | |
| if (m_sizing == value) | |
| { | |
| return; | |
| } | |
| m_sizing = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::overflow(TextOverflow value) | |
| { | |
| if (m_overflow == value) | |
| { | |
| return; | |
| } | |
| m_overflow = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::align(TextAlign value) | |
| { | |
| if (m_align == value) | |
| { | |
| return; | |
| } | |
| m_align = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::alignWidth(float value) | |
| { | |
| if (m_alignWidth == value) | |
| { | |
| return; | |
| } | |
| m_alignWidth = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::verticalAlign(VerticalTextAlign value) | |
| { | |
| if (m_verticalAlign == value) | |
| { | |
| return; | |
| } | |
| m_verticalAlign = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::alignHeight(float value) | |
| { | |
| if (m_alignHeight == value) | |
| { | |
| return; | |
| } | |
| m_alignHeight = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::font(rcp<Font> value) | |
| { | |
| if (m_textRun.font == value) | |
| { | |
| return; | |
| } | |
| m_textRun.font = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::computeVisualPositionFromCursor() | |
| { | |
| m_cursorVisualPosition = cursorVisualPosition(m_cursor.end()); | |
| } | |
| // The text handed to the shaper: the real text, or one bullet per code point | |
| // when obscured. The trailing zero width space sentinel stays so cursor | |
| // positions keep a one to one index mapping with m_text. | |
| std::vector<Unichar>& RawTextInput::shapeableText() | |
| { | |
| if (!flagged(Flags::obscured)) | |
| { | |
| return m_text; | |
| } | |
| m_obscuredText.assign(m_text.size(), obscuringBullet); | |
| m_obscuredText.back() = zeroWidthSpace; | |
| return m_obscuredText; | |
| } | |
| void RawTextInput::ensureShape() | |
| { | |
| if (unflag(Flags::shapeDirty)) | |
| { | |
| m_textRun.unicharCount = (uint32_t)m_text.size(); | |
| m_shape.shape( | |
| shapeableText(), | |
| Span<TextRun>(&m_textRun, 1), | |
| m_sizing, | |
| m_maxWidth, | |
| m_maxHeight, | |
| m_align, | |
| m_wrap, | |
| m_origin, | |
| m_overflow, | |
| m_paragraphSpacing, | |
| m_alignWidth, | |
| m_verticalAlign, | |
| m_alignHeight); | |
| flag(Flags::shaped); | |
| } | |
| } | |
| RawTextInput::Flags RawTextInput::update(Factory* factory) | |
| { | |
| Flags updated = Flags::none; | |
| if (m_textRun.font == nullptr) | |
| { | |
| return updated; | |
| } | |
| bool updateTextPath = false; | |
| ensureShape(); | |
| // Whether shaped just now or by an edit since the last update -- a select | |
| // or a caret move shapes the text it needs at once. | |
| if (unflag(Flags::shaped)) | |
| { | |
| updated |= Flags::shapeDirty; | |
| updateTextPath = true; | |
| } | |
| if (unflag(Flags::selectionDirty)) | |
| { | |
| updated |= Flags::selectionDirty; | |
| if (flagged(Flags::separateSelectionText)) | |
| { | |
| updateTextPath = true; | |
| } | |
| m_cursor.resolveLinePositions(m_shape); | |
| computeVisualPositionFromCursor(); | |
| // Update the selection contours. | |
| m_selectionRects.clear(); | |
| m_cursor.selectionRects(m_selectionRects, m_shape); | |
| m_selectionPath.update(m_selectionRects, m_selectionCornerRadius); | |
| m_cursorPath.rewind(); | |
| float caretWidth = 1.0f; | |
| RawPath rect; | |
| rect.moveTo(m_cursorVisualPosition.x(), m_cursorVisualPosition.top()); | |
| rect.lineTo( | |
| m_cursorVisualPosition.x() + caretWidth, | |
| m_cursorVisualPosition.top()); | |
| rect.lineTo( | |
| m_cursorVisualPosition.x() + caretWidth, | |
| m_cursorVisualPosition.bottom()); | |
| rect.lineTo(m_cursorVisualPosition.x(), m_cursorVisualPosition.bottom()); | |
| rect.close(); | |
| m_cursorPath.addPathClockwise(rect); | |
| } | |
| if (updateTextPath) | |
| { | |
| buildTextPaths(factory); | |
| } | |
| return updated; | |
| } | |
| void RawTextInput::buildTextPaths(Factory* factory) | |
| { | |
| bool wantSeparate = flagged(Flags::separateSelectionText); | |
| m_textPath.rewind(); | |
| m_selectedTextPath.rewind(); | |
| if (!m_shape.hasValidBounds()) | |
| { | |
| m_clipRenderPath = nullptr; | |
| return; | |
| } | |
| // Build the clip path if we want it. | |
| if (m_overflow == TextOverflow::clipped) | |
| { | |
| if (m_clipRenderPath == nullptr) | |
| { | |
| m_clipRenderPath = factory->makeEmptyRenderPath(); | |
| } | |
| else | |
| { | |
| m_clipRenderPath->rewind(); | |
| } | |
| const AABB& bounds = m_shape.bounds(); | |
| m_clipRenderPath | |
| ->addRect(bounds.minX, bounds.minY, bounds.width(), bounds.height()); | |
| } | |
| else | |
| { | |
| m_clipRenderPath = nullptr; | |
| } | |
| // Walks the lines itself rather than reading the ordered lines' y, so it | |
| // has to pick up the vertical alignment offset they already carry. | |
| float y = m_shape.verticalOffset(); | |
| const SimpleArray<SimpleArray<GlyphLine>>& | |
| paragraphLines = m_shape.paragraphLines(); | |
| const std::vector<OrderedLine>& orderedLines = m_shape.orderedLines(); | |
| if (m_origin == TextOrigin::baseline && !paragraphLines.empty() && | |
| !paragraphLines[0].empty()) | |
| { | |
| y -= paragraphLines[0][0].baseline; | |
| } | |
| int32_t lineIndex = 0; | |
| // Only reason we don't iterate lines here is so we don't need to recompute | |
| // shape if we just change paragraph spacing, otherwise we could store the | |
| // computed y in each OrderedLine and just iterate those. | |
| for (const SimpleArray<GlyphLine>& lines : paragraphLines) | |
| { | |
| for (const GlyphLine& line : lines) | |
| { | |
| if (lineIndex >= orderedLines.size()) | |
| { | |
| // We previously decided to clip at this number of lines (see | |
| // fully_shaped_text.cpp). | |
| break; | |
| } | |
| const OrderedLine& orderedLine = orderedLines[lineIndex]; | |
| float x = orderedLine.startX(); | |
| float renderY = y + line.baseline; | |
| for (auto glyphItr : orderedLine) | |
| { | |
| const GlyphRun* run = std::get<0>(glyphItr); | |
| size_t glyphIndex = std::get<1>(glyphItr); | |
| const Font* font = run->font.get(); | |
| const Vec2D& offset = run->offsets[glyphIndex]; | |
| GlyphID glyphId = run->glyphs[glyphIndex]; | |
| float advance = run->advances[glyphIndex]; | |
| RawPath rawPath = font->getPath(glyphId); | |
| rawPath.transformInPlace(Mat2D( | |
| run->size, | |
| 0.0f, | |
| 0.0f, | |
| run->size, | |
| x + offset.x, | |
| renderY + offset.y)); | |
| x += advance; | |
| // m_path contains everything, so inner feather bounds can work. | |
| if (wantSeparate && m_cursor.contains(run->textIndices[glyphIndex])) | |
| { | |
| m_selectedTextPath.addPathClockwise(rawPath); | |
| } | |
| else | |
| { | |
| m_textPath.addPathClockwise(rawPath); | |
| } | |
| // assert(run->styleId < m_styles.size()); | |
| // RenderStyle* style = &m_styles[run->styleId]; | |
| // assert(style != nullptr); | |
| // path.addTo(style->path.get()); | |
| // if (style->isEmpty) | |
| // { | |
| // // This was the first path added to the style, so let's | |
| // mark | |
| // // it in our draw list. | |
| // style->isEmpty = false; | |
| // m_renderStyles.push_back(style); | |
| // } | |
| } | |
| lineIndex++; | |
| } | |
| if (!lines.empty()) | |
| { | |
| y += lines.back().bottom; | |
| } | |
| y += m_paragraphSpacing; | |
| } | |
| } | |
| AABB RawTextInput::bounds() const { return m_shape.bounds(); } | |
| void RawTextInput::paragraphSpacing(float value) | |
| { | |
| if (m_paragraphSpacing == value) | |
| { | |
| return; | |
| } | |
| m_paragraphSpacing = value; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::cursor(Cursor value) | |
| { | |
| if (m_cursor == value) | |
| { | |
| return; | |
| } | |
| m_cursor = value; | |
| flag(Flags::selectionDirty); | |
| } | |
| void RawTextInput::selectWord() | |
| { | |
| CursorPosition searchPosition = m_cursor.start(); | |
| Delineator classification = classify(searchPosition); | |
| // If it's not a word, make sure point before it is not a word too. This | |
| // accomodates for when trying to select a word by clicking on the | |
| // right-most edge of the word. | |
| if ((classification & Delineator::word) == Delineator::unknown) | |
| { | |
| CursorPosition previousPosition = searchPosition - 1; | |
| Delineator previousClassification = classify(previousPosition); | |
| if ((previousClassification & Delineator::word) != Delineator::unknown) | |
| { | |
| searchPosition = previousPosition; | |
| classification = previousClassification; | |
| } | |
| } | |
| // Be more flexible on classification when search for a word (we don't want | |
| // an exact match on upper/lower/underscore, we want any of the three). | |
| if ((classification & Delineator::word) != Delineator::unknown) | |
| { | |
| classification = Delineator::word; | |
| } | |
| CursorPosition start = findPosition( | |
| ~(uint8_t)classification, | |
| searchPosition, | |
| -1); | |
| CursorPosition end = findPosition( | |
| ~(uint8_t)classification, | |
| searchPosition, | |
| 1); | |
| end += 1; | |
| m_cursor = Cursor(start, end); | |
| flag(Flags::selectionDirty); | |
| } | |
| CursorPosition RawTextInput::positionAtIndex(uint32_t codePointIndex) | |
| { | |
| // There is no shaping without a font. | |
| if (m_textRun.font == nullptr) | |
| { | |
| return CursorPosition(codePointIndex); | |
| } | |
| ensureShape(); | |
| return CursorPosition::atIndex(codePointIndex, m_shape); | |
| } | |
| void RawTextInput::selectAll() | |
| { | |
| m_idealCursorX = -1.0f; | |
| m_cursor = Cursor(positionAtIndex(0), positionAtIndex((uint32_t)length())); | |
| flag(Flags::selectionDirty); | |
| } | |
| void RawTextInput::moveCursorToEnd() | |
| { | |
| m_idealCursorX = -1.0f; | |
| m_cursor = Cursor::collapsed(positionAtIndex((uint32_t)length())); | |
| flag(Flags::selectionDirty); | |
| } | |
| void RawTextInput::selectLine() | |
| { | |
| ensureShape(); | |
| m_idealCursorX = -1.0f; | |
| CursorPosition cursor = m_cursor.start(); | |
| cursor.resolveLine(m_shape); | |
| const OrderedLine* line = orderedLine(cursor); | |
| if (line == nullptr) | |
| { | |
| return; | |
| } | |
| const auto& glyphLookup = m_shape.glyphLookup(); | |
| CursorPosition start( | |
| cursor.lineIndex(), | |
| line->firstCodePointIndex(glyphLookup)); | |
| CursorPosition end(cursor.lineIndex(), line->lastCodePointIndex(glyphLookup)); | |
| m_cursor = Cursor(start, end); | |
| flag(Flags::selectionDirty); | |
| } | |
| void RawTextInput::clearSelection() | |
| { | |
| cursor(Cursor::collapsed(m_cursor.end())); | |
| } | |
| const OrderedLine* RawTextInput::orderedLine(CursorPosition position) const | |
| { | |
| const std::vector<OrderedLine>& orderedLines = m_shape.orderedLines(); | |
| if (position.lineIndex() >= orderedLines.size()) | |
| { | |
| return nullptr; | |
| } | |
| return &orderedLines[position.lineIndex()]; | |
| } | |
| void RawTextInput::cursorHorizontal( | |
| int32_t offset, | |
| CursorBoundary boundary, | |
| bool select) | |
| { | |
| ensureShape(); | |
| m_idealCursorX = -1.0f; | |
| // Collapsing a selection with a leftward move works off its leading edge, | |
| // so the cursor ends up before the selection's first character instead of | |
| // before its last. Clamping keeps a selection that already starts at the | |
| // beginning there. | |
| CursorPosition basePosition = !select && offset < 0 && m_cursor.hasSelection() | |
| ? m_cursor.first() | |
| : m_cursor.end(); | |
| CursorPosition position = basePosition; | |
| switch (boundary) | |
| { | |
| case CursorBoundary::character: | |
| { | |
| const auto& glyphLookup = m_shape.glyphLookup(); | |
| uint32_t nextIndex = basePosition.codePointIndex(offset); | |
| // Skip over interior codepoints of multi-codepoint glyphs. | |
| if (offset > 0) | |
| { | |
| while (nextIndex < (uint32_t)(m_text.size() - 1) && | |
| !glyphLookup.isGlyphBoundary(nextIndex)) | |
| { | |
| nextIndex++; | |
| } | |
| } | |
| else | |
| { | |
| while (nextIndex > 0 && !glyphLookup.isGlyphBoundary(nextIndex)) | |
| { | |
| nextIndex--; | |
| } | |
| } | |
| position = CursorPosition::atIndex(nextIndex, m_shape); | |
| break; | |
| } | |
| case CursorBoundary::line: | |
| { | |
| auto line = orderedLine(basePosition); | |
| if (line != nullptr) | |
| { | |
| uint32_t codePointIndex = offset < 0 ? line->firstCodePointIndex( | |
| m_shape.glyphLookup()) | |
| : line->lastCodePointIndex( | |
| m_shape.glyphLookup()); | |
| position = CursorPosition(basePosition.lineIndex(), codePointIndex); | |
| } | |
| break; | |
| } | |
| case CursorBoundary::word: | |
| case CursorBoundary::subWord: | |
| { | |
| Delineator classification = classify(position - (offset < 0 ? 1 : 0)); | |
| switch (classification) | |
| { | |
| case Delineator::whitespace: | |
| case Delineator::underscore: | |
| classification = find(~classification, position, offset); | |
| break; | |
| default: | |
| break; | |
| } | |
| switch (classification) | |
| { | |
| case Delineator::symbol: | |
| classification = find(~classification, position, offset); | |
| break; | |
| case Delineator::lowercase: | |
| if (boundary == CursorBoundary::subWord) | |
| { | |
| Delineator nonLowerCase = find( | |
| ~Delineator::lowercase, | |
| position, | |
| offset); | |
| if (offset == -1 && nonLowerCase == Delineator::uppercase) | |
| { | |
| position = position - 1; | |
| } | |
| } | |
| else | |
| { | |
| find( | |
| ~(Delineator::lowercase | Delineator::uppercase | | |
| Delineator::underscore), | |
| position, | |
| offset); | |
| } | |
| break; | |
| case Delineator::uppercase: | |
| if (boundary == CursorBoundary::subWord) | |
| { | |
| CursorPosition startPosition = position; | |
| Delineator nonUpper = find( | |
| ~Delineator::uppercase, | |
| position, | |
| offset); | |
| if (offset == 1 && nonUpper == Delineator::lowercase) | |
| { | |
| position = position - 1; | |
| if (position.codePointIndex() == startPosition.codePointIndex()) | |
| { | |
| find(~Delineator::lowercase, position, offset); | |
| } | |
| } | |
| } | |
| else | |
| { | |
| find( | |
| ~(Delineator::lowercase | Delineator::uppercase | | |
| Delineator::underscore), | |
| position, | |
| offset); | |
| } | |
| break; | |
| default: | |
| find(~classification, position, offset); | |
| break; | |
| } | |
| break; | |
| } | |
| default: | |
| break; | |
| } | |
| if (select) | |
| { | |
| m_cursor = Cursor(m_cursor.start(), position); | |
| } | |
| else | |
| { | |
| m_cursor = Cursor::collapsed(position); | |
| } | |
| flag(Flags::selectionDirty); | |
| } | |
| RawTextInput::Delineator RawTextInput::classify(Unichar codepoint) | |
| { | |
| if (isWhiteSpace(codepoint)) | |
| { | |
| return Delineator::whitespace; | |
| } | |
| if (codepoint == 95) | |
| { | |
| return Delineator::underscore; | |
| } | |
| if (codepoint < 48 || (codepoint >= 58 && codepoint <= 64) || | |
| (codepoint >= 91 && codepoint <= 96) || | |
| (codepoint >= 123 && codepoint <= 127)) | |
| { | |
| return Delineator::symbol; | |
| } | |
| if (codepoint >= 65 && codepoint <= 90) | |
| { | |
| return Delineator::uppercase; | |
| } | |
| return Delineator::lowercase; | |
| } | |
| RawTextInput::Delineator RawTextInput::classify(CursorPosition position) const | |
| { | |
| if (empty() || position.codePointIndex() >= m_text.size() - 1) | |
| { | |
| return Delineator::whitespace; | |
| } | |
| // Word boundaries follow what is displayed, so obscured text moves as one | |
| // run of bullets and reveals nothing. | |
| return classify( | |
| flagged(Flags::obscured) ? obscuringBullet | |
| : m_text[position.codePointIndex()]); | |
| } | |
| RawTextInput::Delineator RawTextInput::find( | |
| uint8_t delineatorMask, | |
| CursorPosition& position, | |
| int32_t direction) const | |
| { | |
| Delineator lastClassification = Delineator::unknown; | |
| while (true) | |
| { | |
| CursorPosition nextPosition = position + direction; | |
| if (nextPosition.codePointIndex() == position.codePointIndex()) | |
| { | |
| break; | |
| } | |
| position = nextPosition; | |
| if (((lastClassification = classify( | |
| nextPosition - (direction < 0 ? 1 : 0))) & | |
| delineatorMask) != 0) | |
| { | |
| break; | |
| } | |
| } | |
| return lastClassification; | |
| } | |
| CursorPosition RawTextInput::findPosition( | |
| uint8_t delineatorMask, | |
| const CursorPosition& position, | |
| int32_t direction) const | |
| { | |
| CursorPosition result = position; | |
| while (true) | |
| { | |
| CursorPosition nextPosition = result + direction; | |
| if (nextPosition.codePointIndex() == result.codePointIndex() || | |
| (size_t)nextPosition.codePointIndex() >= length()) | |
| { | |
| break; | |
| } | |
| if ((classify(nextPosition) & delineatorMask) != 0) | |
| { | |
| break; | |
| } | |
| result = nextPosition; | |
| } | |
| return result; | |
| } | |
| void RawTextInput::cursorLeft(CursorBoundary boundary, bool select) | |
| { | |
| cursorHorizontal(-1, boundary, select); | |
| } | |
| void RawTextInput::cursorRight(CursorBoundary boundary, bool select) | |
| { | |
| cursorHorizontal(1, boundary, select); | |
| } | |
| void RawTextInput::cursorUp(bool select) | |
| { | |
| ensureShape(); | |
| if (m_idealCursorX == -1.0f) | |
| { | |
| m_idealCursorX = m_cursorVisualPosition.x(); | |
| } | |
| uint32_t lineIndex = m_cursor.end().lineIndex(); | |
| auto position = lineIndex == 0 ? CursorPosition::zero() | |
| : CursorPosition::fromLineX( | |
| m_cursor.end().lineIndex(-1), | |
| m_idealCursorX, | |
| m_shape); | |
| if (select) | |
| { | |
| m_cursor = Cursor(m_cursor.start(), position); | |
| } | |
| else | |
| { | |
| m_cursor = Cursor::collapsed(position); | |
| } | |
| flag(Flags::selectionDirty); | |
| } | |
| void RawTextInput::cursorDown(bool select) | |
| { | |
| ensureShape(); | |
| if (m_idealCursorX == -1.0f) | |
| { | |
| m_idealCursorX = m_cursorVisualPosition.x(); | |
| } | |
| uint32_t nextLineIndex = m_cursor.end().lineIndex(1); | |
| auto position = m_shape.lineCount() != 0 && m_text.size() > 1 && | |
| nextLineIndex >= m_shape.lineCount() | |
| ? CursorPosition( | |
| (uint32_t)(m_shape.lineCount() - 1), | |
| (uint32_t)(m_text.size() - 1)) | |
| : CursorPosition::fromLineX( | |
| nextLineIndex, | |
| m_idealCursorX, | |
| m_shape); | |
| if (select) | |
| { | |
| m_cursor = Cursor(m_cursor.start(), position); | |
| } | |
| else | |
| { | |
| m_cursor = Cursor::collapsed(position); | |
| } | |
| flag(Flags::selectionDirty); | |
| } | |
| void RawTextInput::moveCursorTo(Vec2D translation, bool select) | |
| { | |
| ensureShape(); | |
| m_idealCursorX = -1.0f; | |
| auto position = CursorPosition::fromTranslation(translation, m_shape); | |
| if (select) | |
| { | |
| m_cursor = Cursor(m_cursor.start(), position); | |
| } | |
| else | |
| { | |
| m_cursor = Cursor::collapsed(position); | |
| } | |
| flag(Flags::selectionDirty); | |
| } | |
| static std::string encodeCodePoints(Span<const Unichar> codePoints) | |
| { | |
| std::vector<uint8_t> buffer(UTF::CountCodePointLength(codePoints)); | |
| uint8_t* encoded = buffer.data(); | |
| for (auto codePoint : codePoints) | |
| { | |
| encoded += UTF::Encode(encoded, codePoint); | |
| } | |
| std::string str; | |
| std::move(buffer.begin(), buffer.end(), std::back_inserter(str)); | |
| return str; | |
| } | |
| std::string RawTextInput::text() const | |
| { | |
| size_t size = m_text.size(); | |
| if (size == 0) | |
| { | |
| return std::string(); | |
| } | |
| return encodeCodePoints(Span<const Unichar>(m_text.data(), size - 1)); | |
| } | |
| std::string RawTextInput::selectedText() const | |
| { | |
| if (m_text.empty()) | |
| { | |
| return std::string(); | |
| } | |
| // Exclude the trailing sentinel code point from the selectable range. | |
| size_t textEnd = m_text.size() - 1; | |
| size_t first = std::min((size_t)m_cursor.first().codePointIndex(), textEnd); | |
| size_t last = std::min((size_t)m_cursor.last().codePointIndex(), textEnd); | |
| if (first >= last) | |
| { | |
| return std::string(); | |
| } | |
| return encodeCodePoints( | |
| Span<const Unichar>(m_text.data() + first, last - first)); | |
| } | |
| void RawTextInput::setTextPrivate(std::string value) | |
| { | |
| const uint8_t* ptr = (const uint8_t*)value.data(); | |
| const uint8_t* end = ptr + value.size(); | |
| m_text.clear(); | |
| while (ptr < end && *ptr) | |
| { | |
| Unichar codePoint = UTF::NextUTF8(&ptr, end); | |
| m_text.push_back(codePoint); | |
| } | |
| m_text.push_back(zeroWidthSpace); | |
| } | |
| void RawTextInput::text(std::string value) | |
| { | |
| setTextPrivate(value); | |
| auto position = CursorPosition::zero(); | |
| m_cursor = Cursor::collapsed(position); | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| // Text set from outside (the file's own, a data bind) starts a new undo | |
| // history with it as the baseline: undo takes back the edits after it, | |
| // never the text itself. | |
| m_journal.clear(); | |
| m_journal.push_back({m_cursor, m_cursor, text()}); | |
| m_journalIndex = 0; | |
| } | |
| void RawTextInput::textPreserveCursor(std::string value) | |
| { | |
| Cursor startingCursor = m_cursor; | |
| m_idealCursorX = -1.0f; | |
| setTextPrivate(value); | |
| uint32_t maxIndex = static_cast<uint32_t>(length()); | |
| CursorPosition start( | |
| std::min(startingCursor.start().codePointIndex(), maxIndex)); | |
| CursorPosition end(std::min(startingCursor.end().codePointIndex(), maxIndex)); | |
| m_cursor = Cursor(start, end); | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| captureJournalEntry(startingCursor); | |
| } | |
| size_t RawTextInput::length() const | |
| { | |
| if (empty()) | |
| { | |
| return 0; | |
| } | |
| return m_text.size() - 1; | |
| } | |
| bool RawTextInput::empty() const | |
| { | |
| // note that we're empty at length 1 as we have our zeroWidthSpace | |
| return m_text.size() <= 1; | |
| } | |
| void RawTextInput::captureJournalEntry(Cursor cursor) | |
| { | |
| if (m_journalIndex + 1 < m_journal.size()) | |
| { | |
| m_journal.erase(m_journal.begin() + m_journalIndex + 1, m_journal.end()); | |
| } | |
| m_journal.push_back({cursor, m_cursor, text()}); | |
| m_journalIndex = (uint32_t)m_journal.size() - 1; | |
| } | |
| void RawTextInput::undo() | |
| { | |
| if (m_journalIndex == 0) | |
| { | |
| return; | |
| } | |
| const JournalEntry& entryFrom = m_journal[m_journalIndex]; | |
| const JournalEntry& entryTo = m_journal[m_journalIndex - 1]; | |
| setTextPrivate(entryTo.text); | |
| m_cursor = entryFrom.cursorFrom; | |
| m_journalIndex -= 1; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| void RawTextInput::redo() | |
| { | |
| if (m_journal.empty() || m_journalIndex + 1 >= m_journal.size()) | |
| { | |
| return; | |
| } | |
| const JournalEntry& entryTo = m_journal[m_journalIndex + 1]; | |
| setTextPrivate(entryTo.text); | |
| m_cursor = entryTo.cursorTo; | |
| m_journalIndex += 1; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| bool RawTextInput::flagged(RawTextInput::Flags mask) const | |
| { | |
| return enums::any_flag_set(m_flags, mask); | |
| } | |
| bool RawTextInput::unflag(RawTextInput::Flags mask) | |
| { | |
| if (enums::any_flag_set(m_flags, mask)) | |
| { | |
| m_flags &= ~mask; | |
| return true; | |
| } | |
| return false; | |
| } | |
| void RawTextInput::flag(RawTextInput::Flags mask) { m_flags |= mask; } | |
| bool RawTextInput::separateSelectionText() const | |
| { | |
| return flagged(Flags::separateSelectionText); | |
| } | |
| void RawTextInput::separateSelectionText(bool value) | |
| { | |
| if (value) | |
| { | |
| flag(Flags::separateSelectionText); | |
| } | |
| else | |
| { | |
| unflag(Flags::separateSelectionText); | |
| } | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| bool RawTextInput::obscured() const { return flagged(Flags::obscured); } | |
| bool RawTextInput::hugsWidth() const { return flagged(Flags::hugsWidth); } | |
| void RawTextInput::hugsWidth(bool value) | |
| { | |
| if (value) | |
| { | |
| flag(Flags::hugsWidth); | |
| } | |
| else | |
| { | |
| unflag(Flags::hugsWidth); | |
| } | |
| } | |
| static Cursor withoutLineIndices(const Cursor& cursor) | |
| { | |
| return Cursor( | |
| CursorPosition(cursor.start().codePointIndex()), | |
| CursorPosition(cursor.end().codePointIndex())); | |
| } | |
| void RawTextInput::obscured(bool value) | |
| { | |
| if (obscured() == value) | |
| { | |
| return; | |
| } | |
| if (value) | |
| { | |
| flag(Flags::obscured); | |
| } | |
| else | |
| { | |
| unflag(Flags::obscured); | |
| } | |
| // Masking can reflow lines, so resolved line indices are stale; keep only | |
| // the code point indices. The journal holds cursor snapshots too, or undo | |
| // would restore one resolved against the old layout. | |
| m_cursor = withoutLineIndices(m_cursor); | |
| for (JournalEntry& entry : m_journal) | |
| { | |
| entry.cursorFrom = withoutLineIndices(entry.cursorFrom); | |
| entry.cursorTo = withoutLineIndices(entry.cursorTo); | |
| } | |
| m_idealCursorX = -1.0f; | |
| flag(Flags::shapeDirty | Flags::measureDirty | Flags::selectionDirty); | |
| } | |
| AABB RawTextInput::measure(float maxWidth, float maxHeight) | |
| { | |
| if (m_textRun.font == nullptr) | |
| { | |
| return AABB(); | |
| } | |
| bool force = m_lastMeasureMaxWidth != maxWidth || | |
| m_lastMeasureMaxHeight != maxHeight; | |
| if (!m_measuringShape) | |
| { | |
| force = true; | |
| m_measuringShape = std::make_unique<FullyShapedText>(); | |
| } | |
| if (unflag(Flags::measureDirty) || force) | |
| { | |
| m_textRun.unicharCount = (uint32_t)m_text.size(); | |
| m_measuringShape->shape( | |
| shapeableText(), | |
| Span<TextRun>(&m_textRun, 1), | |
| m_sizing, | |
| maxWidth, | |
| maxHeight, | |
| m_align, | |
| m_wrap, | |
| m_origin, | |
| m_overflow, | |
| m_paragraphSpacing, | |
| m_alignWidth, | |
| m_verticalAlign, | |
| m_alignHeight); | |
| m_lastMeasureMaxWidth = maxWidth; | |
| m_lastMeasureMaxHeight = maxHeight; | |
| flag(Flags::measured); | |
| #ifdef TESTING | |
| measureCount++; | |
| #endif | |
| } | |
| return m_measuringShape->bounds(); | |
| } | |
| #endif |