From ee6a4bc8ff9cccfec09cb6a6f31a262ee8d3015d Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 30 Mar 2026 17:16:21 +0900 Subject: [PATCH 1/4] Fix Korean IME composition overlay hiding characters to the right of cursor When composing a new character between two existing characters, the in-progress composition text was drawn over the character immediately to the right of the cursor, visually hiding it during composition. This happened because the tsfPreview overlay used an overwrite-style render, writing the composition text at the cursor column without preserving the displaced content. The fix copies the original characters from that position back into the visual row at the position just after the composition text ends, changing the appearance from overwrite to insert. The buffer itself is not modified; the change is local to the temporary row modification that is restored after each paint frame. This regression was introduced by commit a3d508a48 (Remove TF_TMAE_UIELEMENTENABLEDONLY, #19738), which caused the Korean IME to switch from IMM32 (which renders its own floating composition window) to TSF (which uses the terminal's inline tsfPreview rendering), exposing this rendering flaw. --- src/renderer/base/renderer.cpp | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/renderer/base/renderer.cpp b/src/renderer/base/renderer.cpp index 0f3070cd9fe..f1ba76d45a1 100644 --- a/src/renderer/base/renderer.cpp +++ b/src/renderer/base/renderer.cpp @@ -1090,6 +1090,37 @@ void Renderer::_PaintBufferOutput(_In_ IRenderEngine* const pEngine) const_cast(r).ReplaceAttributes(state.columnBegin, state.columnEnd, attr); off += len; } + + // After writing the composition text, copy the original characters + // that were at and after the cursor position into the space to the + // right of the composition text. This renders the in-progress + // composition as an insertion rather than an overwrite, so that + // existing characters to the right of the cursor are not hidden. + const auto compositionEnd = state.columnEnd; + const auto origCursorCol = _compositionCache->absoluteOrigin.x; + const auto colLimit = r.GetReadableColumnCount(); + + if (compositionEnd > origCursorCol && compositionEnd < colLimit) + { + RowCopyTextFromState copyState{ + .source = scratch, + .columnBegin = compositionEnd, + .columnLimit = colLimit, + .sourceColumnBegin = origCursorCol, + .sourceColumnLimit = colLimit, + }; + const_cast(r).CopyTextFrom(copyState); + + const auto srcBeg = gsl::narrow_cast(origCursorCol); + const auto srcEnd = gsl::narrow_cast(scratch.GetReadableColumnCount()); + const auto dstBeg = gsl::narrow_cast(compositionEnd); + const auto dstEnd = gsl::narrow_cast(colLimit); + if (srcBeg < srcEnd && dstBeg < dstEnd) + { + const_cast(r).Attributes().replace( + dstBeg, dstEnd, scratch.Attributes().slice(srcBeg, srcEnd)); + } + } } const auto restore = wil::scope_exit([&] { if (rowBackup) From a858580a29ca399029ebada528b4aa1cb7b5d6f0 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 31 Mar 2026 11:22:58 +0900 Subject: [PATCH 2/4] Revert Fix 2: composition overlay shift breaks TUI box borders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the change that shifted existing characters to the right of the cursor when rendering an in-progress TSF composition (tsfPreview). The approach was an unconditional rightward shift of all content from the cursor position, which caused box-drawing characters in TUI applications to be displaced and rendered at wrong positions or cut off entirely. Example of the breakage in a TUI text box: Before (correct): |Hello [ㄷ] | After (broken): |Hello [ㄷ]Worl|d (border shifted off screen) The correct fix requires consuming an equal amount of whitespace to the right of the composition before shifting, so that box borders remain at their original column positions when trailing whitespace is available. This improved approach will be implemented separately as Fix 2-1. Reviewer feedback: When you're in a TUI application, there may be a box around the text field. Shifting text from the cursor to the right means that the right border (and anything else in the TUI app) also shifts to the right. One solution for this is that we try to remove an equal amount of whitespace to the right of the composition. --- src/renderer/base/renderer.cpp | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/src/renderer/base/renderer.cpp b/src/renderer/base/renderer.cpp index f1ba76d45a1..0f3070cd9fe 100644 --- a/src/renderer/base/renderer.cpp +++ b/src/renderer/base/renderer.cpp @@ -1090,37 +1090,6 @@ void Renderer::_PaintBufferOutput(_In_ IRenderEngine* const pEngine) const_cast(r).ReplaceAttributes(state.columnBegin, state.columnEnd, attr); off += len; } - - // After writing the composition text, copy the original characters - // that were at and after the cursor position into the space to the - // right of the composition text. This renders the in-progress - // composition as an insertion rather than an overwrite, so that - // existing characters to the right of the cursor are not hidden. - const auto compositionEnd = state.columnEnd; - const auto origCursorCol = _compositionCache->absoluteOrigin.x; - const auto colLimit = r.GetReadableColumnCount(); - - if (compositionEnd > origCursorCol && compositionEnd < colLimit) - { - RowCopyTextFromState copyState{ - .source = scratch, - .columnBegin = compositionEnd, - .columnLimit = colLimit, - .sourceColumnBegin = origCursorCol, - .sourceColumnLimit = colLimit, - }; - const_cast(r).CopyTextFrom(copyState); - - const auto srcBeg = gsl::narrow_cast(origCursorCol); - const auto srcEnd = gsl::narrow_cast(scratch.GetReadableColumnCount()); - const auto dstBeg = gsl::narrow_cast(compositionEnd); - const auto dstEnd = gsl::narrow_cast(colLimit); - if (srcBeg < srcEnd && dstBeg < dstEnd) - { - const_cast(r).Attributes().replace( - dstBeg, dstEnd, scratch.Attributes().slice(srcBeg, srcEnd)); - } - } } const auto restore = wil::scope_exit([&] { if (rowBackup) From 7315766aa2079692a457031321f5aae755b2e45b Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 31 Mar 2026 11:25:39 +0900 Subject: [PATCH 3/4] Fix Korean IME composition overlay hiding characters to the right of cursor When composing a new Korean character between existing text, the in-progress composition overlay was drawn using an overwrite-style render that hid any character immediately to the right of the cursor. This is visually incorrect for Korean IME, which inserts rather than replaces. The fix copies original characters from the cursor position back to the right of the composition text, using a whitespace-absorption algorithm so that TUI box borders stay at their original column positions when trailing whitespace is available. Up to W whitespace columns (where W is the composition width) are consumed silently; remaining non-whitespace content is shifted rightward. If no whitespace is available, the shift is unavoidable (border may move), but this is the best achievable behavior for fixed-width lines. The rendering change is purely visual: the buffer row is restored in full after each paint frame by the existing wil::scope_exit restore mechanism. This approach was suggested by a reviewer as a refinement to a prior attempt that shifted all content unconditionally and broke TUI box borders. Closes #20040 --- src/renderer/base/renderer.cpp | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/renderer/base/renderer.cpp b/src/renderer/base/renderer.cpp index 0f3070cd9fe..882f1a8368a 100644 --- a/src/renderer/base/renderer.cpp +++ b/src/renderer/base/renderer.cpp @@ -1090,6 +1090,77 @@ void Renderer::_PaintBufferOutput(_In_ IRenderEngine* const pEngine) const_cast(r).ReplaceAttributes(state.columnBegin, state.columnEnd, attr); off += len; } + + // Render the composition as an insertion by copying original + // characters from the cursor position to the right of the + // composition text, absorbing up to W whitespace columns in the + // process. This keeps TUI box borders at their original column + // when there is sufficient whitespace padding to the right. + // + // Algorithm: walk the original row (scratch) from origCursorCol, + // skipping up to W whitespace columns ("absorbing" them), and + // copying non-whitespace glyphs to r starting at compositionEnd. + // When the whitespace budget reaches 0, srcCol == dstCol and the + // remaining content in r is already correct, so we stop early. + const auto compositionWidth = state.columnEnd - _compositionCache->absoluteOrigin.x; + const auto origCursorCol = _compositionCache->absoluteOrigin.x; + const auto colLimit = r.GetReadableColumnCount(); + + if (compositionWidth > 0 && state.columnEnd < colLimit) + { + auto srcCol = origCursorCol; + auto dstCol = state.columnEnd; + auto whitespaceToAbsorb = compositionWidth; + + while (srcCol < colLimit) + { + // All whitespace absorbed: srcCol == dstCol, r already correct. + if (whitespaceToAbsorb == 0) + { + break; + } + + const auto glyphEnd = scratch.NavigateToNext(srcCol); + const auto glyphWidth = glyphEnd - srcCol; + const auto glyphText = scratch.GetText(srcCol, glyphEnd); + + // Absorb half-width (U+0020) or full-width (U+3000) spaces + // only when the budget covers the glyph's full width. + const bool absorb = + whitespaceToAbsorb >= glyphWidth && + !glyphText.empty() && + (glyphText[0] == L' ' || glyphText[0] == L'\u3000'); + + if (absorb) + { + whitespaceToAbsorb -= glyphWidth; + srcCol = glyphEnd; + } + else + { + if (dstCol < colLimit) + { + RowCopyTextFromState cs{ + .source = scratch, + .columnBegin = dstCol, + .columnLimit = dstCol + glyphWidth, + .sourceColumnBegin = srcCol, + .sourceColumnLimit = glyphEnd, + }; + const_cast(r).CopyTextFrom(cs); + + const_cast(r).Attributes().replace( + gsl::narrow_cast(dstCol), + gsl::narrow_cast(dstCol + glyphWidth), + scratch.Attributes().slice( + gsl::narrow_cast(srcCol), + gsl::narrow_cast(glyphEnd))); + } + srcCol = glyphEnd; + dstCol += glyphWidth; + } + } + } } const auto restore = wil::scope_exit([&] { if (rowBackup) From d5eda6d7f334117daaa12acfad1837236d4c0e9f Mon Sep 17 00:00:00 2001 From: Leonard Hecker Date: Fri, 17 Apr 2026 00:25:10 +0200 Subject: [PATCH 4/4] Use batch copies from the scratch row --- src/buffer/out/Row.cpp | 7 ++ src/buffer/out/Row.hpp | 1 + src/renderer/base/renderer.cpp | 205 ++++++++++++++++----------------- src/renderer/base/renderer.hpp | 1 + 4 files changed, 111 insertions(+), 103 deletions(-) diff --git a/src/buffer/out/Row.cpp b/src/buffer/out/Row.cpp index 77451dd1cca..62e4269bf5a 100644 --- a/src/buffer/out/Row.cpp +++ b/src/buffer/out/Row.cpp @@ -1143,6 +1143,13 @@ til::CoordType ROW::GetTrailingColumnAtCharOffset(const ptrdiff_t offset) const return _createCharToColumnMapper(offset).GetTrailingColumnAt(offset); } +uint16_t ROW::GetCharOffset(til::CoordType col) const noexcept +{ + const auto columns = GetReadableColumnCount(); + const auto colBeg = clamp(col, 0, columns); + return _uncheckedCharOffset(gsl::narrow_cast(colBeg)); +} + DelimiterClass ROW::DelimiterClassAt(til::CoordType column, const std::wstring_view& wordDelimiters) const noexcept { const auto col = _clampedColumn(column); diff --git a/src/buffer/out/Row.hpp b/src/buffer/out/Row.hpp index a1efe36707b..d1157e06e3f 100644 --- a/src/buffer/out/Row.hpp +++ b/src/buffer/out/Row.hpp @@ -172,6 +172,7 @@ class ROW final std::wstring_view GetText(til::CoordType columnBegin, til::CoordType columnEnd) const noexcept; til::CoordType GetLeadingColumnAtCharOffset(ptrdiff_t offset) const noexcept; til::CoordType GetTrailingColumnAtCharOffset(ptrdiff_t offset) const noexcept; + uint16_t GetCharOffset(til::CoordType col) const noexcept; DelimiterClass DelimiterClassAt(til::CoordType column, const std::wstring_view& wordDelimiters) const noexcept; auto AttrBegin() const noexcept { return _attr.begin(); } diff --git a/src/renderer/base/renderer.cpp b/src/renderer/base/renderer.cpp index 15af7d9680a..4a98b757a39 100644 --- a/src/renderer/base/renderer.cpp +++ b/src/renderer/base/renderer.cpp @@ -1061,109 +1061,7 @@ void Renderer::_PaintBufferOutput(_In_ IRenderEngine* const pEngine) ROW* rowBackup = nullptr; if (row == compositionRow) { - auto& scratch = buffer.GetScratchpadRow(); - scratch.CopyFrom(r); - rowBackup = &scratch; - - std::wstring_view text{ activeComposition.text }; - RowWriteState state{ - .columnLimit = r.GetReadableColumnCount(), - .columnEnd = _compositionCache->absoluteOrigin.x, - }; - - size_t off = 0; - for (const auto& range : activeComposition.attributes) - { - const auto len = range.len; - auto attr = range.attr; - - // Use the color at the cursor if TSF didn't specify any explicit color. - if (attr.GetBackground().IsDefault()) - { - attr.SetBackground(_compositionCache->baseAttribute.GetBackground()); - } - if (attr.GetForeground().IsDefault()) - { - attr.SetForeground(_compositionCache->baseAttribute.GetForeground()); - } - - state.text = text.substr(off, len); - state.columnBegin = state.columnEnd; - const_cast(r).ReplaceText(state); - const_cast(r).ReplaceAttributes(state.columnBegin, state.columnEnd, attr); - off += len; - } - - // Render the composition as an insertion by copying original - // characters from the cursor position to the right of the - // composition text, absorbing up to W whitespace columns in the - // process. This keeps TUI box borders at their original column - // when there is sufficient whitespace padding to the right. - // - // Algorithm: walk the original row (scratch) from origCursorCol, - // skipping up to W whitespace columns ("absorbing" them), and - // copying non-whitespace glyphs to r starting at compositionEnd. - // When the whitespace budget reaches 0, srcCol == dstCol and the - // remaining content in r is already correct, so we stop early. - const auto compositionWidth = state.columnEnd - _compositionCache->absoluteOrigin.x; - const auto origCursorCol = _compositionCache->absoluteOrigin.x; - const auto colLimit = r.GetReadableColumnCount(); - - if (compositionWidth > 0 && state.columnEnd < colLimit) - { - auto srcCol = origCursorCol; - auto dstCol = state.columnEnd; - auto whitespaceToAbsorb = compositionWidth; - - while (srcCol < colLimit) - { - // All whitespace absorbed: srcCol == dstCol, r already correct. - if (whitespaceToAbsorb == 0) - { - break; - } - - const auto glyphEnd = scratch.NavigateToNext(srcCol); - const auto glyphWidth = glyphEnd - srcCol; - const auto glyphText = scratch.GetText(srcCol, glyphEnd); - - // Absorb half-width (U+0020) or full-width (U+3000) spaces - // only when the budget covers the glyph's full width. - const bool absorb = - whitespaceToAbsorb >= glyphWidth && - !glyphText.empty() && - (glyphText[0] == L' ' || glyphText[0] == L'\u3000'); - - if (absorb) - { - whitespaceToAbsorb -= glyphWidth; - srcCol = glyphEnd; - } - else - { - if (dstCol < colLimit) - { - RowCopyTextFromState cs{ - .source = scratch, - .columnBegin = dstCol, - .columnLimit = dstCol + glyphWidth, - .sourceColumnBegin = srcCol, - .sourceColumnLimit = glyphEnd, - }; - const_cast(r).CopyTextFrom(cs); - - const_cast(r).Attributes().replace( - gsl::narrow_cast(dstCol), - gsl::narrow_cast(dstCol + glyphWidth), - scratch.Attributes().slice( - gsl::narrow_cast(srcCol), - gsl::narrow_cast(glyphEnd))); - } - srcCol = glyphEnd; - dstCol += glyphWidth; - } - } - } + rowBackup = _PaintBufferOutputComposition(buffer, r, activeComposition); } const auto restore = wil::scope_exit([&] { if (rowBackup) @@ -1203,6 +1101,107 @@ void Renderer::_PaintBufferOutput(_In_ IRenderEngine* const pEngine) } } +ROW* Renderer::_PaintBufferOutputComposition(TextBuffer& buffer, const ROW& r, const Composition& activeComposition) +{ + auto& scratch = buffer.GetScratchpadRow(); + scratch.CopyFrom(r); + + // *Overwrite* the original text with the active composition... + til::CoordType compositionEnd = 0; + { + std::wstring_view text{ activeComposition.text }; + RowWriteState state{ + .columnLimit = r.GetReadableColumnCount(), + .columnEnd = _compositionCache->absoluteOrigin.x, + }; + + size_t off = 0; + for (const auto& range : activeComposition.attributes) + { + const auto len = range.len; + auto attr = range.attr; + + // Use the color at the cursor if TSF didn't specify any explicit color. + if (attr.GetBackground().IsDefault()) + { + attr.SetBackground(_compositionCache->baseAttribute.GetBackground()); + } + if (attr.GetForeground().IsDefault()) + { + attr.SetForeground(_compositionCache->baseAttribute.GetForeground()); + } + + state.text = text.substr(off, len); + state.columnBegin = state.columnEnd; + const_cast(r).ReplaceText(state); + const_cast(r).ReplaceAttributes(state.columnBegin, state.columnEnd, attr); + off += len; + } + + compositionEnd = state.columnEnd; + } + + // The text we've overwritten may have been crucial to the user, + // so copy it back by absorbing available whitespace to the right + // and re-inserting the non-whitespace characters instead. + const auto compositionWidth = compositionEnd - _compositionCache->absoluteOrigin.x; + const auto colLimit = r.GetReadableColumnCount(); + if (compositionWidth > 0 && compositionEnd < colLimit) + { + const auto text = scratch.GetText(); + auto srcCol = _compositionCache->absoluteOrigin.x; + auto dstCol = compositionEnd; + auto remaining = compositionWidth; + size_t i = scratch.GetCharOffset(srcCol); + + while (i < text.size() && dstCol < colLimit) + { + // Treat whitespace we encounter as a credit towards our composition width. + // This loop essentially absorbs the whitespace. + while (i < text.size() && til::at(text, i) == L' ' && remaining > 0) + { + remaining--; + srcCol++; + i++; + } + if (remaining <= 0) + { + break; + } + + // Find the end of the non-whitespace span: Our span of text to insert. + auto spanEnd = i; + while (spanEnd < text.size() && til::at(text, spanEnd) != L' ') + { + spanEnd++; + } + + // Copy the non-whitespace segment from the original text (scratch) back in. + RowCopyTextFromState state{ + .source = scratch, + .columnBegin = dstCol, + .columnLimit = colLimit, + .sourceColumnBegin = srcCol, + .sourceColumnLimit = scratch.GetLeadingColumnAtCharOffset(spanEnd), + }; + const_cast(r).CopyTextFrom(state); + + const auto srcBeg = gsl::narrow_cast(srcCol); + const auto srcEnd = gsl::narrow_cast(state.sourceColumnEnd); + const auto attr = scratch.Attributes().slice(srcBeg, srcEnd); + const auto dstBeg = gsl::narrow_cast(dstCol); + const auto dstEnd = gsl::narrow_cast(dstCol + attr.size()); + const_cast(r).Attributes().replace(dstBeg, dstEnd, attr); + + dstCol = state.columnEnd; + srcCol = state.sourceColumnEnd; + i = spanEnd; + } + } + + return &scratch; +} + static bool _IsAllSpaces(const std::wstring_view v) { // first non-space char is not found (is npos) diff --git a/src/renderer/base/renderer.hpp b/src/renderer/base/renderer.hpp index 399171f0e63..31fc7cc24df 100644 --- a/src/renderer/base/renderer.hpp +++ b/src/renderer/base/renderer.hpp @@ -121,6 +121,7 @@ namespace Microsoft::Console::Render void _scheduleRenditionBlink(); [[nodiscard]] HRESULT _PaintBackground(_In_ IRenderEngine* const pEngine); void _PaintBufferOutput(_In_ IRenderEngine* const pEngine); + ROW* _PaintBufferOutputComposition(TextBuffer& buffer, const ROW& r, const Composition& activeComposition); void _PaintBufferOutputHelper(_In_ IRenderEngine* const pEngine, TextBufferCellIterator it, const til::point target); void _PaintBufferOutputGridLineHelper(_In_ IRenderEngine* const pEngine, const TextAttribute textAttribute, const size_t cchLine, const til::point coordTarget); bool _isHoveredHyperlink(const TextAttribute& textAttribute) const noexcept;