diff --git a/src/gui/shellwidget/helpers.h b/src/gui/shellwidget/helpers.h index 7c2758d7e..52482a2fd 100644 --- a/src/gui/shellwidget/helpers.h +++ b/src/gui/shellwidget/helpers.h @@ -11,3 +11,10 @@ int GetHorizontalAdvance(const QFontMetrics& fm, QChar character) noexcept; int GetHorizontalAdvance(const QFontMetrics& fm, const QString& text) noexcept; bool saveShellContents(const ShellContents& s, const QString& filename); + +/// Check if `index` is valid and within the bounds of `container`. +template +inline bool IsValidIndex(const T& container, int index) noexcept +{ + return index >= 0 && index < container.size(); +} diff --git a/src/gui/shellwidget/shellwidget.cpp b/src/gui/shellwidget/shellwidget.cpp index 14c363526..3746615c1 100644 --- a/src/gui/shellwidget/shellwidget.cpp +++ b/src/gui/shellwidget/shellwidget.cpp @@ -414,7 +414,7 @@ void ShellWidget::paintForegroundCellText( } } -static bool AreGlyphPositionsUniform( +/*static*/ bool ShellWidget::AreGlyphPositionsUniform( const QVector& glyphPositionList, int cellWidth) noexcept { @@ -422,69 +422,295 @@ static bool AreGlyphPositionsUniform( return true; } - qreal lastPos{ glyphPositionList[0].x() }; - for (int i=1; i(glyphPositionList[0].x()) }; + for (int i=1;i(glyphPositionList[i].x()) }; + int delta{ xPos - lastPos }; + lastPos = xPos; + + if (delta == cellWidth) { + continue; } + + if (delta % cellWidth == 0) { + continue; + } + + return false; } return true; } -static QVector DistributeGlyphPositions( - QVector&& glyphPositionList, +static int RoundToNearestCell(qreal widthPixels, int cellWidth) noexcept +{ + int width{ static_cast(widthPixels) }; + + // All characters must be at least 1 cell wide + if (width < cellWidth) { + return 1; + } + + // Less than 1/2, round down + if (width % cellWidth < cellWidth / 2) { + return width / cellWidth; + } + + // Else, round up + return (width / cellWidth) + 1; +} + +/*static*/ void ShellWidget::DistributeGlyphPositions( + QGlyphRun& glyphRunOut, + const QString& text, int cellWidth) noexcept { - if (glyphPositionList.size() > 1) { - qreal adjustPositionX{ glyphPositionList[0].x() }; - for (auto& glyphPos : glyphPositionList) { - glyphPos.setX(adjustPositionX); - adjustPositionX += cellWidth; + auto glyphPositionList{ glyphRunOut.positions() }; + + if (glyphPositionList.size() <= 1 + || AreGlyphPositionsUniform(glyphPositionList, cellWidth)) + { + return; + } + + qreal adjXPos{ glyphPositionList.at(0).x() }; + + // Encoding Scheme: 1 glyph per character + if (text.size() == glyphPositionList.size()) { + for (auto& pos : glyphPositionList) + { + pos.setX(adjXPos); + adjXPos += cellWidth; + } + } + // Encoding Scheme: 1 glyph per ligature + else { + const auto glyphPositionListOriginal{ glyphRunOut.positions() }; + + for (int i=1;i RemoveLigaturesUnderCursor( - const QGlyphRun& glyphRun, +static void RemoveLigaturesUnderCursorGlyphPerCharacter( + QGlyphRun& glyphRunOut, + int cursorTextPos, + const QVector& glyphIndexListNoLigatures) noexcept +{ + auto glyphIndexList{ glyphRunOut.glyphIndexes() }; + + if (cursorTextPos < 0 + || cursorTextPos >= glyphIndexList.size() + || cursorTextPos >= glyphIndexListNoLigatures.size()) { + qDebug() << "ERROR: Invalid cursorTextPos!"; + return; + } + + // Check if the cursor is within a glyph, if not exit early + if (glyphIndexList[cursorTextPos] == glyphIndexListNoLigatures[cursorTextPos]) { + return; + } + + // Replace ligature glyphs with individual character glyphs: [left bound, cursor] + for(int i=cursorTextPos; i>=0; i--) { + if (glyphIndexList.at(i) == glyphIndexListNoLigatures.at(i)) { + break; + } + + glyphIndexList.data()[i] = glyphIndexListNoLigatures[i]; + } + + // Replace ligature glyphs with individual character glyphs: (cursor, right bound] + for(int i=cursorTextPos+1; i& glyphPositionList) noexcept +{ + int cursorGlyphPos{ -1 }; + const double cursorPixelPos{ glyphPositionList[0].x() + cellWidth * cursorTextPos }; + for (auto pos : glyphPositionList) { + + if (pos.x() > cursorPixelPos) { + break; + } + + cursorGlyphPos++; + } + return cursorGlyphPos; +} + +static int GetGlyphSizeGlyphPerLigature( + int cursorGlyphPos, + int cellWidth, const QString& textGlyphRun, - int cursorGlyphRunPos) noexcept + const QVector& glyphPositionList) noexcept { - auto glyphIndexList{ glyphRun.glyphIndexes() }; - auto glyphIndexListNoLigatures{ glyphRun.rawFont().glyphIndexesForString(textGlyphRun) }; + if (cursorGlyphPos + 1 < glyphPositionList.size()) { + return (glyphPositionList[cursorGlyphPos + 1].x() - glyphPositionList[cursorGlyphPos].x()) / cellWidth; + } - if (cursorGlyphRunPos < 0 - || cursorGlyphRunPos >= glyphIndexList.size() - || cursorGlyphRunPos >= glyphIndexListNoLigatures.size()) { - qDebug() << "ERROR: Invalid cursorGlyphRunPos!"; + return textGlyphRun.size() - glyphPositionList[cursorGlyphPos].x() / cellWidth; +} + +static void RemoveLigaturesUnderCursorGlyphPerLigature( + QGlyphRun& glyphRunOut, + const QString& textGlyphRun, + int cursorTextPos, + int cellWidth, + const QVector& glyphIndexListNoLigatures) noexcept +{ + auto glyphIndexList{ glyphRunOut.glyphIndexes() }; + auto glyphPositionList{ glyphRunOut.positions() }; + + // The cursor position within the text and QGlyphRun differ. + // Find the cursor index with respect to the QGlyphRun using text position index. + const int cursorGlyphPos{ GetCursorPositionGlyphPerLigature(cellWidth, cursorTextPos, glyphPositionList) }; + + if (!IsValidIndex(glyphIndexList, cursorGlyphPos) || !IsValidIndex(glyphIndexListNoLigatures, cursorTextPos)) { + return; + } + + // Check if the cursor is within a glyph, if not exit early + if (glyphIndexListNoLigatures[cursorTextPos] == glyphIndexList[cursorGlyphPos]) { + return; + } + + const int glyphStartPos{ static_cast(glyphPositionList[cursorGlyphPos].x() / cellWidth) }; + const int glyphSize{ GetGlyphSizeGlyphPerLigature( + cursorGlyphPos, cellWidth, textGlyphRun, glyphPositionList) }; + + if (!IsValidIndex(glyphIndexListNoLigatures, glyphStartPos)) { + return; + } + + // Cursor is within a glyph. This single glyph/ligature needs to be decomposed into individual character glyphs. + glyphIndexList[cursorGlyphPos] = glyphIndexListNoLigatures[glyphStartPos]; + for (int i=1;i* glyphIndexListNoLigaturesOverride) noexcept +{ + auto glyphIndexList{ glyphRunOut.glyphIndexes() }; + auto glyphPositionList{ glyphRunOut.positions() }; + + const auto& glyphIndexListNoLigatures{ (!glyphIndexListNoLigaturesOverride) ? + glyphRunOut.rawFont().glyphIndexesForString(textGlyphRun) : + *glyphIndexListNoLigaturesOverride }; + + // Encoding Scheme: 1 glyph per character + if (textGlyphRun.size() == glyphIndexList.size()) { + RemoveLigaturesUnderCursorGlyphPerCharacter(glyphRunOut, cursorTextPos, glyphIndexListNoLigatures); + return; + } + // Encoding Scheme: 1 glyph per ligature + else { + RemoveLigaturesUnderCursorGlyphPerLigature( + glyphRunOut, textGlyphRun, cursorTextPos, cellWidth, glyphIndexListNoLigatures); + return; + } +} + +QList ShellWidget::GetGlyphRunListForTextBlock( + QPainter& p, + QRect blockRect, + const QString& text, + const QFont& blockFont) noexcept +{ + QTextLayout textLayout{ text, blockFont, p.device() }; + textLayout.setCacheEnabled(true); + textLayout.beginLayout(); + + QTextLine line{ textLayout.createLine() }; + if (!line.isValid()) { return {}; } - if (glyphIndexList.at(cursorGlyphRunPos) != glyphIndexListNoLigatures.at(cursorGlyphRunPos)) { - for(int i=cursorGlyphRunPos; i>=0; i--) { - if (glyphIndexList.at(i) == glyphIndexListNoLigatures.at(i)) { - break; - } + line.setNumColumns(text.length()); - glyphIndexList.data()[i] = glyphIndexListNoLigatures[i]; - } + textLayout.endLayout(); - for(int i=cursorGlyphRunPos+1; i SplitTextByGlyphRun( + const QString& text, + QSize cellSize, + const QList& glyphRunList) noexcept +{ + if (glyphRunList.size() == 1) { + return { text }; + } - glyphIndexList.data()[i] = glyphIndexListNoLigatures[i]; + QVector result; + result.reserve(glyphRunList.size()); + + int totalGlyphCount{ 0 }; + for (const auto& glyphRun : glyphRunList) { + totalGlyphCount += glyphRun.glyphIndexes().size(); + } + + // Case: Direct mapping between cursor and ligature. + // This block may or may not be valid, can we guarantee `glyphRunList` ordering? + if (totalGlyphCount == text.size()) { + // Split `text` into `glyphRun` sized parts + int glyphsAddedAlready{ 0 }; + for (const auto& glyphRun : glyphRunList) { + const int sizeGlyphRun{ glyphRun.glyphIndexes().size() }; + result.append(QStringRef{ &text, glyphsAddedAlready, sizeGlyphRun }.toString()); + glyphsAddedAlready += sizeGlyphRun; } - return glyphIndexList; + return result; } - // No glyph changes required - return {}; + // Unrecognized QGlyphRun format, this case may be unexpected but correctable. + // Users seeing this error may want to file a bug to have the scenario corrected. + qDebug() << "Unable to split QGlyphRun into text segments!"; + qDebug() << " text:" << text; + + result.resize(glyphRunList.size()); + return result; } void ShellWidget::paintForegroundTextBlock( @@ -499,70 +725,68 @@ void ShellWidget::paintForegroundTextBlock( fgColor = (cell.IsReverse()) ? background() : foreground(); } + // Option `guifontwide` can cause cells to render with different fonts const QFont blockFont{ GetCellFont(cell) }; - p.setPen(fgColor); p.setFont(blockFont); const int cellTextOffset{ m_lineSpace / 2 }; const QPoint pos{ blockRect.left(), blockRect.top() + cellTextOffset }; - QTextLayout textLayout{ text, blockFont, p.device() }; - textLayout.setCacheEnabled(true); - textLayout.beginLayout(); - QTextLine line = textLayout.createLine(); - if (!line.isValid()) { + auto glyphRunList{ GetGlyphRunListForTextBlock(p, blockRect, text, blockFont) }; + auto glyphRunTextList{ SplitTextByGlyphRun(text, m_cellSize, glyphRunList) }; + + if (glyphRunList.size() != glyphRunTextList.size()) { + qDebug() << "Each QGlyphRun must contain matching text!"; return; } - line.setNumColumns(text.length()); - textLayout.endLayout(); + int textIndex{ 0 }; int glyphsRendered{ 0 }; - for (auto& glyphRun : textLayout.glyphRuns()) { + for (auto& glyphRun : glyphRunList) { auto glyphPositionList{ glyphRun.positions() }; - auto sizeGlyphRun{ glyphPositionList.size() }; + auto glyphRunText{ glyphRunTextList[textIndex++] }; + glyphsRendered += glyphRunText.size(); const int cellWidth{ (cell.IsDoubleWidth()) ? m_cellSize.width() * 2 : m_cellSize.width() }; - // When characters are rendered as a string, they may not be uniformly - // distributed. Check for even spacing and redistribute as necessary. - if (!AreGlyphPositionsUniform(glyphPositionList, cellWidth)) { - glyphRun.setPositions( - DistributeGlyphPositions(std::move(glyphPositionList), cellWidth)); - } + // Characters may not be rendered uniformly, redistribute as necessary. + DistributeGlyphPositions(glyphRun, glyphRunText, cellWidth); const bool isCursorVisibleInGlyphRun{ m_cursor.IsVisible() && cursorPos >= 0 - && cursorPos < sizeGlyphRun + glyphsRendered - && cursorPos >= glyphsRendered }; + && cursorPos < glyphsRendered + && cursorPos >= glyphsRendered - glyphRunText.size() }; - // When the cursor is NOT within the glyph run, render as-is. - if (!isCursorVisibleInGlyphRun) { + // The QGlyphRun is rendered as-is when no cursor is present. + if (glyphRunText.isEmpty() || !isCursorVisibleInGlyphRun) { p.drawGlyphRun(pos, glyphRun); - glyphsRendered += sizeGlyphRun; continue; } - // When the cursor IS within the glyph run, decompose individual characters under the cursor. - const int cursorGlyphRunPos { cursorPos - glyphsRendered }; - const QString textGlyphRun{ QStringRef{ &text, glyphsRendered, sizeGlyphRun }.toString() }; - - // Compares a glyph run with and without ligatures. Ligature glyphs are detected as differences - // in these two lists. A non-empty newCursorGlyphList indicates glyph substitution is required. - const auto newCursorGlyphList { RemoveLigaturesUnderCursor(glyphRun, textGlyphRun, cursorGlyphRunPos) }; - if (!newCursorGlyphList.empty()) { - glyphRun.setGlyphIndexes(newCursorGlyphList); - } + // The cursor is within the QGlyphRun: decompose individual characters under the cursor. + const int cursorPosInGlyphRun{ cursorPos - (glyphsRendered - glyphRunText.size())}; + RemoveLigaturesUnderCursor(glyphRun, glyphRunText, cursorPosInGlyphRun, cellWidth); p.drawGlyphRun(pos, glyphRun); - glyphsRendered += sizeGlyphRun; + } + + // Draw the Neovim cursor, if present in text block. + if (m_cursor.IsVisible() && IsValidIndex(text, cursorPos)) { + const QPoint cursorDrawPos{ + blockRect.left() + (m_cellSize.width() * cursorPos), + blockRect.top() + m_ascent + (m_lineSpace / 2) }; + + // Issue 735: Some characters (emoji) contain multiple bytes. + // FIXME Is QTextBoundary a better approach? Will this work for 3+ byte chars? + QString cursorText{ text.at(cursorPos) }; + if (cell.IsDoubleWidth() && IsValidIndex(text, cursorPos + 1)) { + cursorText += text.at(cursorPos + 1); + } - const QRect cursorCellRect{ neovimCursorRect() }; - paintNeovimCursorBackground(p, cursorCellRect); - paintNeovimCursorForeground( - p, cursorCellRect, glyphRun.positions().at(cursorGlyphRunPos).toPoint() + pos, - textGlyphRun.at(cursorGlyphRunPos)); + paintNeovimCursorBackground(p, neovimCursorRect()); + paintNeovimCursorForeground(p, neovimCursorRect(), cursorDrawPos, cursorText); } } diff --git a/src/gui/shellwidget/shellwidget.h b/src/gui/shellwidget/shellwidget.h index da72c7dda..82f8b5140 100644 --- a/src/gui/shellwidget/shellwidget.h +++ b/src/gui/shellwidget/shellwidget.h @@ -2,6 +2,7 @@ #define QSHELLWIDGET2_SHELLWIDGET #include +#include #include "shellcontents.h" #include "cursor.h" @@ -81,6 +82,32 @@ class ShellWidget: public QWidget return m_isLigatureModeEnabled; } + /// FIXME Comment + static bool AreGlyphPositionsUniform( + const QVector& glyphPositionList, + int cellWidth) noexcept; + + /// FIXME Comment + static void DistributeGlyphPositions( + QGlyphRun& glyphRunOut, + const QString& text, + int cellWidth) noexcept; + + // FIXME Comment + static void RemoveLigaturesUnderCursor( + QGlyphRun& glyphRunOut, + const QString& textGlyphRun, + int cursorTextPos, + int cellWidth, + const QVector* glyphIndexListNoLigaturesOverride = nullptr) noexcept; + + /// FIXME Comment + QList GetGlyphRunListForTextBlock( + QPainter& p, + QRect blockRect, + const QString& text, + const QFont& blockFont) noexcept; + signals: void shellFontChanged(); void fontError(const QString& msg); diff --git a/src/gui/shellwidget/test/CMakeLists.txt b/src/gui/shellwidget/test/CMakeLists.txt index 6f5a71f55..54449d34e 100644 --- a/src/gui/shellwidget/test/CMakeLists.txt +++ b/src/gui/shellwidget/test/CMakeLists.txt @@ -14,9 +14,10 @@ function(add_xtest SOURCE_NAME) add_test(NAME ${SOURCE_NAME} COMMAND ${SOURCE_NAME}) endfunction() +add_xtest(bench_cell) +add_xtest(bench_paint) +add_xtest(bench_scroll) add_xtest(test_cell) add_xtest(test_shellcontents) add_xtest(test_shellwidget) -add_xtest(bench_scroll) -add_xtest(bench_cell) -add_xtest(bench_paint) +add_xtest(test_ligatures) diff --git a/src/gui/shellwidget/test/test_ligatures.cpp b/src/gui/shellwidget/test/test_ligatures.cpp new file mode 100644 index 000000000..5ecc15c5d --- /dev/null +++ b/src/gui/shellwidget/test/test_ligatures.cpp @@ -0,0 +1,195 @@ +#include + +#include "shellwidget.h" + +#if defined(Q_OS_WIN) && defined(USE_STATIC_QT) +#include +Q_IMPORT_PLUGIN (QWindowsIntegrationPlugin); +#endif + +class TestLigatures : public QObject +{ + Q_OBJECT + +private slots: + void AreGlyphPositionsUniform() noexcept; + void DistributeGlyphPositions() noexcept; + void RemoveLigaturesUnderCursor() noexcept; +}; + + +void TestLigatures::AreGlyphPositionsUniform() noexcept +{ + QVector positionListValid{ + QPointF{ 0, 17 }, + QPointF{ 18, 17 }, + QPointF{ 27, 17 }, + QPointF{ 36, 17 }, + }; + + Q_ASSERT(ShellWidget::AreGlyphPositionsUniform(positionListValid, 9)); + + QVector positionListInvalid{ + QPointF{ 0, 17 }, + QPointF{ 18, 17 }, + QPointF{ 29, 17 }, + QPointF{ 36, 17 }, + }; + Q_ASSERT(!ShellWidget::AreGlyphPositionsUniform(positionListInvalid, 9)); +} + +void TestLigatures::DistributeGlyphPositions() noexcept +{ + // FIXME Add test case: compounding error! + QGlyphRun operatorMono; + + QVector positionList{ + QPointF{ 0, 17 }, + QPointF{ 19, 17 }, + QPointF{ 28, 17 }, + QPointF{ 37, 17 }, + }; + + QVector indexList{ 446, 2, 2, 2 }; + + operatorMono.setPositions(positionList); + operatorMono.setGlyphIndexes(indexList); + + ShellWidget::DistributeGlyphPositions(operatorMono, "-> ", 9); + + auto resultPostions{ operatorMono.positions() }; + QCOMPARE(resultPostions[0].x(), 0); + QCOMPARE(resultPostions[1].x(), 18); + QCOMPARE(resultPostions[2].x(), 27); + QCOMPARE(resultPostions[3].x(), 36); + + QVector positionListOverFlow { + { 0, 17 }, + { 19, 17 }, + { 38, 17 }, + { 57, 17 }, + { 76, 17 }, + { 95, 17 }, + }; + + QVector indexListOverFlow{ 446, 446, 446, 446, 446, 446 }; + + QGlyphRun operatorMonoOverFlow; + operatorMonoOverFlow.setGlyphIndexes(indexListOverFlow); + operatorMonoOverFlow.setPositions(positionListOverFlow); + + ShellWidget::DistributeGlyphPositions(operatorMonoOverFlow, "->->->->->->", 9); + + + auto resultOverflowPostions{ operatorMonoOverFlow.positions() }; + QCOMPARE(resultOverflowPostions[0].x(), 0); + QCOMPARE(resultOverflowPostions[1].x(), 18); + QCOMPARE(resultOverflowPostions[2].x(), 36); + QCOMPARE(resultOverflowPostions[3].x(), 54); + QCOMPARE(resultOverflowPostions[4].x(), 72); + QCOMPARE(resultOverflowPostions[5].x(), 90); +} + +void TestLigatures::RemoveLigaturesUnderCursor() noexcept +{ + { + QGlyphRun oneCharEncoding; + + QVector positionList{ + { 0, 20 }, + { 10, 20 }, + { 20, 20 }, + { 30, 20 }, + }; + + QVector indexList{ 0, 20, 21, 0 }; + + QVector indexListNoLigatures{ 0, 1, 2, 0}; + + oneCharEncoding.setPositions(positionList); + oneCharEncoding.setGlyphIndexes(indexList); + + ShellWidget::RemoveLigaturesUnderCursor(oneCharEncoding, " -> ", 2, 10, &indexListNoLigatures); + + auto resultPostions{ oneCharEncoding.positions() }; + QVERIFY(resultPostions.size() == 4); + QCOMPARE(resultPostions[0].x(), 0); + QCOMPARE(resultPostions[1].x(), 10); + QCOMPARE(resultPostions[2].x(), 20); + QCOMPARE(resultPostions[3].x(), 30); + + auto resultGlyphs{ oneCharEncoding.glyphIndexes() }; + QVERIFY(resultGlyphs.size() == 4); + QCOMPARE(resultGlyphs[0], 0); + QCOMPARE(resultGlyphs[1], 1); + QCOMPARE(resultGlyphs[2], 2); + QCOMPARE(resultGlyphs[3], 0); + } + + { + QGlyphRun oneGlyphEncoding; + + QVector positionList{ + { 0, 20 }, + { 10, 20 }, + { 30, 20 }, + }; + + QVector indexList{ 0, 20, 0 }; + + QVector indexListNoLigatures{ 0, 1, 2, 0}; + + oneGlyphEncoding.setPositions(positionList); + oneGlyphEncoding.setGlyphIndexes(indexList); + + ShellWidget::RemoveLigaturesUnderCursor(oneGlyphEncoding, " -> ", 2, 10, &indexListNoLigatures); + + auto resultPostions{ oneGlyphEncoding.positions() }; + QVERIFY(resultPostions.size() == 4); + QCOMPARE(resultPostions[0].x(), 0); + QCOMPARE(resultPostions[1].x(), 10); + QCOMPARE(resultPostions[2].x(), 20); + QCOMPARE(resultPostions[3].x(), 30); + + auto resultGlyphs{ oneGlyphEncoding.glyphIndexes() }; + QVERIFY(resultGlyphs.size() == 4); + QCOMPARE(resultGlyphs[0], 0); + QCOMPARE(resultGlyphs[1], 1); + QCOMPARE(resultGlyphs[2], 2); + QCOMPARE(resultGlyphs[3], 0); + } + + { + qDebug() << "TEST 2"; + QGlyphRun oneCharEncoding; + + QVector positionList{ + { 0, 20 }, + { 20, 20 }, + }; + + QVector indexList{ 20, 30 }; + + QVector indexListNoLigatures{ 21, 22, 31, 32}; + + oneCharEncoding.setPositions(positionList); + oneCharEncoding.setGlyphIndexes(indexList); + + ShellWidget::RemoveLigaturesUnderCursor(oneCharEncoding, "==->", 3, 10, &indexListNoLigatures); + + auto resultPostions{ oneCharEncoding.positions() }; + QVERIFY(resultPostions.size() == 3); + QCOMPARE(resultPostions[0].x(), 0); + QCOMPARE(resultPostions[1].x(), 20); + QCOMPARE(resultPostions[2].x(), 30); + + auto resultGlyphs{ oneCharEncoding.glyphIndexes() }; + QVERIFY(resultGlyphs.size() == 3); + QCOMPARE(resultGlyphs[0], 20); + QCOMPARE(resultGlyphs[1], 31); + QCOMPARE(resultGlyphs[2], 32); + } +} + +QTEST_MAIN(TestLigatures) +#include "test_ligatures.moc"