diff --git a/doc/COOKED_READ_DATA.md b/doc/COOKED_READ_DATA.md index f4b463f7aa6..eed82ec8f89 100644 --- a/doc/COOKED_READ_DATA.md +++ b/doc/COOKED_READ_DATA.md @@ -4,12 +4,8 @@ All of the following ✅ marks must be fulfilled during manual testing: * ASCII input -* Chinese input (中文維基百科) ❔ - * Resizing the window properly wraps/unwraps wide glyphs ❌ - Broken due to `TextBuffer::Reflow` bugs -* Surrogate pair input (🙂) ❔ - * Resizing the window properly wraps/unwraps surrogate pairs ❌ - Broken due to `TextBuffer::Reflow` bugs +* Chinese input (中文維基百科) ✅ +* Surrogate pair input (🙂) ✅ * In cmd.exe * Create 2 file: "a😊b.txt" and "a😟b.txt" * Press tab: Autocomplete to "a😊b.txt" ✅ @@ -62,21 +58,20 @@ All of the following ✅ marks must be fulfilled during manual testing: * F6 inserts Ctrl+Z ✅ * F7 without modifiers starts "command list" prompt ✅ * Escape dismisses prompt ✅ - * Minimum size of 40x10 characters ✅ - * Width expands to fit the widest history command ✅ + * Entries wider than the window width are truncated ✅ * Height expands up to 20 rows with longer histories ✅ * F9 starts "command number" prompt ✅ - * Left/Right paste replace the buffer with the given command ✅ + * Left/Right replace the buffer with the given command ✅ * And put cursor at the end of the buffer ✅ * Up/Down navigate selection through history ✅ * Stops at start/end with <10 entries ✅ * Stops at start/end with >20 entries ✅ - * Wide text rendering during pagination with >20 entries ✅ + * Scrolls through the entries if there are too many ✅ * Shift+Up/Down moves history items around ✅ * Home navigates to first entry ✅ * End navigates to last entry ✅ - * PageUp navigates by 20 items at a time or to first ✅ - * PageDown navigates by 20 items at a time or to last ✅ + * PageUp navigates by $height items at a time or to first ✅ + * PageDown navigates by $height items at a time or to last ✅ * Alt+F7 clears command history ✅ * F8 cycles through commands that start with the same text as the current buffer up until the current cursor position ✅ diff --git a/src/buffer/out/cursor.cpp b/src/buffer/out/cursor.cpp index 273c05f8ea1..43c0a702939 100644 --- a/src/buffer/out/cursor.cpp +++ b/src/buffer/out/cursor.cpp @@ -20,7 +20,6 @@ Cursor::Cursor(const ULONG ulSize, TextBuffer& parentBuffer) noexcept : _fBlinkingAllowed(true), _fDelay(false), _fIsConversionArea(false), - _fIsPopupShown(false), _fDelayedEolWrap(false), _fDeferCursorRedraw(false), _fHaveDeferredCursorRedraw(false), @@ -66,11 +65,6 @@ bool Cursor::IsConversionArea() const noexcept return _fIsConversionArea; } -bool Cursor::IsPopupShown() const noexcept -{ - return _fIsPopupShown; -} - bool Cursor::GetDelay() const noexcept { return _fDelay; @@ -126,13 +120,6 @@ void Cursor::SetIsConversionArea(const bool fIsConversionArea) noexcept _RedrawCursorAlways(); } -void Cursor::SetIsPopupShown(const bool fIsPopupShown) noexcept -{ - // Functionally the same as "Hide cursor" - _fIsPopupShown = fIsPopupShown; - _RedrawCursorAlways(); -} - void Cursor::SetDelay(const bool fDelay) noexcept { _fDelay = fDelay; diff --git a/src/buffer/out/cursor.h b/src/buffer/out/cursor.h index 3c377e61b2a..d66762c1b84 100644 --- a/src/buffer/out/cursor.h +++ b/src/buffer/out/cursor.h @@ -44,7 +44,6 @@ class Cursor final bool IsBlinkingAllowed() const noexcept; bool IsDouble() const noexcept; bool IsConversionArea() const noexcept; - bool IsPopupShown() const noexcept; bool GetDelay() const noexcept; ULONG GetSize() const noexcept; til::point GetPosition() const noexcept; @@ -61,7 +60,6 @@ class Cursor final void SetBlinkingAllowed(const bool fIsOn) noexcept; void SetIsDouble(const bool fIsDouble) noexcept; void SetIsConversionArea(const bool fIsConversionArea) noexcept; - void SetIsPopupShown(const bool fIsPopupShown) noexcept; void SetDelay(const bool fDelay) noexcept; void SetSize(const ULONG ulSize) noexcept; void SetStyle(const ULONG ulSize, const CursorType type) noexcept; @@ -99,7 +97,6 @@ class Cursor final bool _fBlinkingAllowed; //Whether or not the cursor is allowed to blink at all. only set through VT (^[[?12h/l) bool _fDelay; // don't blink scursor on next timer message bool _fIsConversionArea; // is attached to a conversion area so it doesn't actually need to display the cursor. - bool _fIsPopupShown; // if a popup is being shown, turn off, stop blinking. bool _fDelayedEolWrap; // don't wrap at EOL till the next char comes in. til::point _coordDelayedAt; // coordinate the EOL wrap was delayed at. diff --git a/src/cascadia/TerminalCore/terminalrenderdata.cpp b/src/cascadia/TerminalCore/terminalrenderdata.cpp index 3d989f31f07..951761bb9f8 100644 --- a/src/cascadia/TerminalCore/terminalrenderdata.cpp +++ b/src/cascadia/TerminalCore/terminalrenderdata.cpp @@ -48,7 +48,7 @@ til::point Terminal::GetCursorPosition() const noexcept bool Terminal::IsCursorVisible() const noexcept { const auto& cursor = _activeBuffer().GetCursor(); - return cursor.IsVisible() && !cursor.IsPopupShown(); + return cursor.IsVisible(); } bool Terminal::IsCursorOn() const noexcept diff --git a/src/host/VtIo.cpp b/src/host/VtIo.cpp index cd2cead0f5a..717cf3429db 100644 --- a/src/host/VtIo.cpp +++ b/src/host/VtIo.cpp @@ -518,3 +518,66 @@ bool VtIo::IsResizeQuirkEnabled() const } return S_OK; } + +// Formats the given console attributes to their closest VT equivalent. +// `out` must refer to at least `formatAttributesMaxLen` characters of valid memory. +// Returns a pointer past the end. +static constexpr size_t formatAttributesMaxLen = 16; +static char* formatAttributes(char* out, const TextAttribute& attributes) noexcept +{ + static uint8_t sgr[] = { 30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97 }; + + // Applications expect that SetConsoleTextAttribute() completely replaces whatever attributes are currently set, + // including any potential VT-exclusive attributes. Since we don't know what those are, we must always emit a SGR 0. + // Copying 4 bytes instead of the correct 3 means we need just 1 DWORD mov. Neat. + // + // 3 bytes. + memcpy(out, "\x1b[0", 4); + out += 3; + + // 2 bytes. + if (attributes.IsReverseVideo()) + { + memcpy(out, ";7", 2); + out += 2; + } + + // 3 bytes (";97"). + if (attributes.GetForeground().IsLegacy()) + { + const uint8_t index = sgr[attributes.GetForeground().GetIndex()]; + out = fmt::format_to(out, FMT_COMPILE(";{}"), index); + } + + // 4 bytes (";107"). + if (attributes.GetBackground().IsLegacy()) + { + const uint8_t index = sgr[attributes.GetBackground().GetIndex()] + 10; + out = fmt::format_to(out, FMT_COMPILE(";{}"), index); + } + + // 1 byte. + *out++ = 'm'; + return out; +} + +void VtIo::FormatAttributes(std::string& target, const TextAttribute& attributes) +{ + char buf[formatAttributesMaxLen]; + const size_t len = formatAttributes(&buf[0], attributes) - &buf[0]; + target.append(buf, len); +} + +void VtIo::FormatAttributes(std::wstring& target, const TextAttribute& attributes) +{ + char buf[formatAttributesMaxLen]; + const size_t len = formatAttributes(&buf[0], attributes) - &buf[0]; + + wchar_t bufW[formatAttributesMaxLen]; + for (size_t i = 0; i < len; i++) + { + bufW[i] = buf[i]; + } + + target.append(bufW, len); +} diff --git a/src/host/VtIo.hpp b/src/host/VtIo.hpp index eccdb06aaac..706c982bc67 100644 --- a/src/host/VtIo.hpp +++ b/src/host/VtIo.hpp @@ -20,6 +20,9 @@ namespace Microsoft::Console::VirtualTerminal class VtIo { public: + static void FormatAttributes(std::string& target, const TextAttribute& attributes); + static void FormatAttributes(std::wstring& target, const TextAttribute& attributes); + VtIo(); [[nodiscard]] HRESULT Initialize(const ConsoleArguments* const pArgs); diff --git a/src/host/_stream.cpp b/src/host/_stream.cpp index e030cec03c7..5679557ea84 100644 --- a/src/host/_stream.cpp +++ b/src/host/_stream.cpp @@ -255,6 +255,13 @@ void WriteCharsLegacy(SCREEN_INFORMATION& screenInfo, const std::wstring_view& t } } +// This is the main entrypoint for conhost to write VT to the buffer. +// This wrapper around StateMachine exists so that we can add the necessary ConPTY transformations. +void WriteCharsVT(SCREEN_INFORMATION& screenInfo, const std::wstring_view& str) +{ + screenInfo.GetStateMachine().ProcessString(str); +} + // Routine Description: // - Takes the given text and inserts it into the given screen buffer. // Note: diff --git a/src/host/_stream.h b/src/host/_stream.h index 79eb84585dc..3897abf22fb 100644 --- a/src/host/_stream.h +++ b/src/host/_stream.h @@ -20,6 +20,7 @@ Revision History: #include "writeData.hpp" void WriteCharsLegacy(SCREEN_INFORMATION& screenInfo, const std::wstring_view& str, til::CoordType* psScrollY); +void WriteCharsVT(SCREEN_INFORMATION& screenInfo, const std::wstring_view& str); // NOTE: console lock must be held when calling this routine // String has been translated to unicode at this point. diff --git a/src/host/readDataCooked.cpp b/src/host/readDataCooked.cpp index 6028acf074c..2fc30f76ec9 100644 --- a/src/host/readDataCooked.cpp +++ b/src/host/readDataCooked.cpp @@ -11,100 +11,14 @@ #include "_stream.h" #include "../interactivity/inc/ServiceLocator.hpp" -using Microsoft::Console::Interactivity::ServiceLocator; - -// As per https://graphics.stanford.edu/~seander/bithacks.html#IntegerLog10Obvious -constexpr int integerLog10(uint32_t v) -{ - return (v >= 1000000000) ? 9 : - (v >= 100000000) ? 8 : - (v >= 10000000) ? 7 : - (v >= 1000000) ? 6 : - (v >= 100000) ? 5 : - (v >= 10000) ? 4 : - (v >= 1000) ? 3 : - (v >= 100) ? 2 : - (v >= 10) ? 1 : - 0; -} - -const std::wstring& COOKED_READ_DATA::BufferState::Get() const noexcept -{ - return _buffer; -} - -void COOKED_READ_DATA::BufferState::Replace(size_t offset, size_t remove, const wchar_t* input, size_t count) -{ - const auto size = _buffer.size(); - offset = std::min(offset, size); - remove = std::min(remove, size - offset); - - _buffer.replace(offset, remove, input, count); - _cursor = offset + count; - _dirtyBeg = std::min(_dirtyBeg, offset); -} - -void COOKED_READ_DATA::BufferState::Replace(const std::wstring_view& str) -{ - _buffer.assign(str); - _cursor = _buffer.size(); - _dirtyBeg = 0; -} - -size_t COOKED_READ_DATA::BufferState::GetCursorPosition() const noexcept -{ - return _cursor; -} +#define COOKED_READ_DEBUG 0 -void COOKED_READ_DATA::BufferState::SetCursorPosition(size_t pos) noexcept -{ - const auto size = _buffer.size(); - _cursor = std::min(pos, size); - // This ensures that _dirtyBeg isn't npos, which ensures that IsClean() returns false. - _dirtyBeg = std::min(_dirtyBeg, size); -} - -bool COOKED_READ_DATA::BufferState::IsClean() const noexcept -{ - return _dirtyBeg == npos; -} - -void COOKED_READ_DATA::BufferState::MarkEverythingDirty() noexcept -{ - _dirtyBeg = 0; -} - -void COOKED_READ_DATA::BufferState::MarkAsClean() noexcept -{ - _dirtyBeg = npos; -} - -std::wstring_view COOKED_READ_DATA::BufferState::GetUnmodifiedTextBeforeCursor() const noexcept -{ - return _slice(0, std::min(_dirtyBeg, _cursor)); -} - -std::wstring_view COOKED_READ_DATA::BufferState::GetUnmodifiedTextAfterCursor() const noexcept -{ - return _slice(_cursor, _dirtyBeg); -} - -std::wstring_view COOKED_READ_DATA::BufferState::GetModifiedTextBeforeCursor() const noexcept -{ - return _slice(_dirtyBeg, _cursor); -} - -std::wstring_view COOKED_READ_DATA::BufferState::GetModifiedTextAfterCursor() const noexcept -{ - return _slice(std::max(_dirtyBeg, _cursor), npos); -} +#if COOKED_READ_DEBUG +#include +#endif -std::wstring_view COOKED_READ_DATA::BufferState::_slice(size_t from, size_t to) const noexcept -{ - to = std::min(to, _buffer.size()); - from = std::min(from, to); - return std::wstring_view{ _buffer.data() + from, to - from }; -} +using Microsoft::Console::Interactivity::ServiceLocator; +using Microsoft::Console::VirtualTerminal::VtIo; // Routine Description: // - Constructs cooked read data class to hold context across key presses while a user is modifying their 'input line'. @@ -147,10 +61,11 @@ COOKED_READ_DATA::COOKED_READ_DATA(_In_ InputBuffer* const pInputBuffer, THROW_IF_FAILED(_screenInfo.GetMainBuffer().AllocateIoHandle(ConsoleHandleData::HandleType::Output, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, _tempHandle)); #endif + const auto cursorPos = _getViewportCursorPosition(); + _originInViewport = cursorPos; + if (!initialData.empty()) { - _buffer.Replace(initialData); - // The console API around `nInitialChars` in `CONSOLE_READCONSOLE_CONTROL` is pretty weird. // The way it works is that cmd.exe does a ReadConsole() with a `dwCtrlWakeupMask` that includes \t, // so when you press tab it can autocomplete the prompt based on the available file names. @@ -162,7 +77,7 @@ COOKED_READ_DATA::COOKED_READ_DATA(_In_ InputBuffer* const pInputBuffer, // characters like Ctrl+X as "^X" and WriteConsoleW() doesn't and so the column counts don't match. // Solving these issues is technically possible, but it's also quite difficult to do so correctly. // - // But unfortunately (or fortunately) the initial (from the 1990s up to 2023) looked something like that: + // But unfortunately (or fortunately) the initial implementation (from the 1990s up to 2023) looked something like that: // cursor = cursor.GetPosition(); // cursor.x -= initialData.size(); // while (cursor.x < 0) @@ -173,24 +88,93 @@ COOKED_READ_DATA::COOKED_READ_DATA(_In_ InputBuffer* const pInputBuffer, // // In other words, it assumed that the number of code units in the initial data corresponds 1:1 to // the column count. This meant that the API never supported tabs for instance (nor wide glyphs). - // The new implementation still doesn't support tabs, but it does fix support for wide glyphs. - // That seemed like a good trade-off. - - // NOTE: You can't just "measure" the length of the string in columns either, because previously written - // wide glyphs might have resulted in padding whitespace in the text buffer (see ROW::WasDoubleBytePadded). - // The alternative to the loop below is counting the number of padding glyphs while iterating backwards. Either approach is fine. - til::CoordType distance = 0; - for (size_t i = 0; i < initialData.size(); i = TextBuffer::GraphemeNext(initialData, i)) + // + // + // The new implementation is a lot more complex to be a little more correct. + // It replicates part of the _redisplay() logic to layout the text at various + // starting positions until it finds one that matches the current cursor position. + + const auto size = _screenInfo.GetVtPageArea().Dimensions(); + + // Guess the initial cursor position based on the string length, assuming that 1 char = 1 column. + const auto columnRemainder = gsl::narrow_cast((initialData.size() % size.width)); + const auto bestGuessColumn = (cursorPos.x - columnRemainder + size.width) % size.width; + + std::wstring line; + LayoutResult res; + til::CoordType bestDistance = til::CoordTypeMax; + til::CoordType bestColumnBegin = 0; + til::CoordType bestNewlineCount = 0; + + line.reserve(size.width); + + // We're given an "end position" and a string and we need to find its starting position. + // The problem is that a wide glyph that doesn't fit into the last column of a row gets padded with a whitespace + // and then written on the next line. Because of this, multiple starting positions can result in the same end + // position and this prevents us from simply laying out the text backwards from the end position. + // To solve this, we do a brute force search for the best starting position that ends at the end position. + // The search is centered around `bestGuessColumn` with offsets 0, 1, -1, 2, -2, 3, -3, ... + for (til::CoordType i = 0, attempts = 2 * size.width; i <= attempts; i++) + { + // Hilarious bit-trickery that no one can read. But it works. Check it out in a debugger. + // The idea is to use bits 1:31 as the value (i >> 1) and bit 0 (i & 1) as a trigger to bit-flip the value. + // A bit-flipped positive number is negative, but offset by 1, so we add 1 at the end. Fun! + const auto offset = ((i >> 1) ^ ((i & 1) - 1)) + 1; + const auto columnBegin = bestGuessColumn + offset; + + if (columnBegin < 0 || columnBegin >= size.width) + { + continue; + } + + til::CoordType newlineCount = 0; + res.column = columnBegin; + + for (size_t beg = 0; beg < initialData.size();) + { + line.clear(); + res = _layoutLine(line, initialData, beg, res.column, size.width); + beg = res.offset; + + if (res.column >= size.width) + { + res.column = 0; + newlineCount += 1; + } + } + + const auto distance = abs(res.column - cursorPos.x); + if (distance < bestDistance) + { + bestDistance = distance; + bestColumnBegin = columnBegin; + bestNewlineCount = newlineCount; + } + if (distance == 0) + { + break; + } + } + + auto originInViewport = cursorPos; + originInViewport.x = bestColumnBegin; + originInViewport.y = originInViewport.y - bestNewlineCount; + + if (originInViewport.y < 0) { - --distance; + originInViewport = {}; } - const auto& textBuffer = _screenInfo.GetTextBuffer(); - const auto& cursor = textBuffer.GetCursor(); - const auto end = cursor.GetPosition(); - const auto beg = textBuffer.NavigateCursor(end, distance); - _distanceCursor = (end.y - beg.y) * textBuffer.GetSize().Width() + end.x - beg.x; - _distanceEnd = _distanceCursor; + // We can't mark the buffer as dirty because this messes up the cursor position for cmd + // somehow when the prompt is longer than the viewport height. I haven't investigated + // why that happens, but it works decently well enough that it's not too important. + _buffer.assign(initialData); + _bufferDirtyBeg = _buffer.size(); + _bufferCursor = _buffer.size(); + + _originInViewport = originInViewport; + _pagerPromptEnd = cursorPos; + _pagerHeight = std::min(size.height, bestNewlineCount + 1); } } @@ -288,7 +272,7 @@ bool COOKED_READ_DATA::Read(const bool isUnicode, size_t& numBytes, ULONG& contr // NOTE: Don't call _flushBuffer in a wil::scope_exit/defer. // It may throw and throwing during an ongoing exception is a bad idea. - _flushBuffer(); + _redisplay(); if (_state == State::Accumulating) { @@ -304,23 +288,41 @@ bool COOKED_READ_DATA::Read(const bool isUnicode, size_t& numBytes, ULONG& contr // To fix this, this function is called before a resize and will clear the input line. Afterwards, RedrawAfterResize() will restore it. void COOKED_READ_DATA::EraseBeforeResize() { - _popupsDone(); - - if (_distanceEnd) + if (_redrawPending) { - _offsetCursorPosition(-_distanceCursor); - _erase(_distanceEnd); - _offsetCursorPosition(-_distanceEnd); - _distanceCursor = 0; - _distanceEnd = 0; + return; } + + _redrawPending = true; + + std::wstring output; + _appendCUP(output, _originInViewport); + output.append(L"\x1b[J"); + WriteCharsVT(_screenInfo, output); } // The counter-part to EraseBeforeResize(). void COOKED_READ_DATA::RedrawAfterResize() { - _buffer.MarkEverythingDirty(); - _flushBuffer(); + if (!_redrawPending) + { + return; + } + + _redrawPending = false; + + // Get the new cursor position after the reflow, since it may have changed. + _originInViewport = _getViewportCursorPosition(); + + // Ensure that we don't use any scroll sequences or try to clear previous pager contents. + // They have all been erased with the CSI J above. + _pagerHeight = 0; + + // Ensure that the entire buffer content is rewritten after the above CSI J. + _bufferDirtyBeg = 0; + _dirty = !_buffer.empty(); + + _redisplay(); } void COOKED_READ_DATA::SetInsertMode(bool insertMode) noexcept @@ -330,7 +332,7 @@ void COOKED_READ_DATA::SetInsertMode(bool insertMode) noexcept bool COOKED_READ_DATA::IsEmpty() const noexcept { - return _buffer.Get().empty() && _popups.empty(); + return _buffer.empty() && _popups.empty(); } bool COOKED_READ_DATA::PresentingPopup() const noexcept @@ -340,10 +342,23 @@ bool COOKED_READ_DATA::PresentingPopup() const noexcept til::point_span COOKED_READ_DATA::GetBoundaries() const noexcept { - const auto& textBuffer = _screenInfo.GetTextBuffer(); - const auto& cursor = textBuffer.GetCursor(); - const auto beg = _offsetPosition(cursor.GetPosition(), -_distanceCursor); - const auto end = _offsetPosition(beg, _distanceEnd); + const auto viewport = _screenInfo.GetViewport(); + const auto virtualViewport = _screenInfo.GetVtPageArea(); + + static constexpr til::point min; + const til::point max{ viewport.RightInclusive(), viewport.BottomInclusive() }; + + // Convert from VT-viewport-relative coordinate space back to the console one. + auto beg = _originInViewport; + virtualViewport.ConvertFromOrigin(&beg); + + // Since the pager may be longer than the viewport is tall, we need to clamp the coordinates to still remain within + // the current viewport (the pager doesn't write outside of the viewport, since that's not supported by VT). + auto end = _pagerPromptEnd; + end.y -= _pagerContentTop; + end = std::clamp(end, min, max); + end.y += beg.y; + return { beg, end }; } @@ -449,11 +464,10 @@ void COOKED_READ_DATA::_handleChar(wchar_t wch, const DWORD modifiers) // // It is important that we don't actually print that character out though, as it's only for the calling application to see. // That's why we flush the contents before the insertion and then ensure that the _flushBuffer() call in Read() exits early. - const auto cursor = _buffer.GetCursorPosition(); - _buffer.Replace(cursor, npos, nullptr, 0); - _flushBuffer(); - _buffer.Replace(cursor, 0, &wch, 1); - _buffer.MarkAsClean(); + _replace(_bufferCursor, npos, nullptr, 0); + _redisplay(); + _replace(_bufferCursor, 0, &wch, 1); + _dirty = false; _controlKeyState = modifiers; _transitionState(State::DoneWithWakeupMask); @@ -465,7 +479,7 @@ void COOKED_READ_DATA::_handleChar(wchar_t wch, const DWORD modifiers) case UNICODE_CARRIAGERETURN: { // NOTE: Don't append newlines to the buffer just yet! See _handlePostCharInputLoop for more information. - _buffer.SetCursorPosition(npos); + _setCursorPosition(npos); _transitionState(State::DoneWithCarriageReturn); return; } @@ -473,9 +487,9 @@ void COOKED_READ_DATA::_handleChar(wchar_t wch, const DWORD modifiers) case UNICODE_BACKSPACE: if (WI_IsFlagSet(_pInputBuffer->InputMode, ENABLE_PROCESSED_INPUT)) { - const auto cursor = _buffer.GetCursorPosition(); - const auto pos = wch == EXTKEY_ERASE_PREV_WORD ? _wordPrev(_buffer.Get(), cursor) : TextBuffer::GraphemePrev(_buffer.Get(), cursor); - _buffer.Replace(pos, cursor - pos, nullptr, 0); + const auto cursor = _bufferCursor; + const auto pos = wch == EXTKEY_ERASE_PREV_WORD ? _wordPrev(_buffer, cursor) : TextBuffer::GraphemePrev(_buffer, cursor); + _replace(pos, cursor - pos, nullptr, 0); return; } // If processed mode is disabled, control characters like backspace are treated like any other character. @@ -489,11 +503,11 @@ void COOKED_READ_DATA::_handleChar(wchar_t wch, const DWORD modifiers) { // TODO GH#15875: If the input grapheme is >1 char, then this will replace >1 grapheme // --> We should accumulate input text as much as possible and then call _processInput with wstring_view. - const auto cursor = _buffer.GetCursorPosition(); - remove = TextBuffer::GraphemeNext(_buffer.Get(), cursor) - cursor; + const auto cursor = _bufferCursor; + remove = TextBuffer::GraphemeNext(_buffer, cursor) - cursor; } - _buffer.Replace(_buffer.GetCursorPosition(), remove, &wch, 1); + _replace(_bufferCursor, remove, &wch, 1); } // Handles non-character input for _readCharInputLoop() when no popups exist. @@ -505,62 +519,62 @@ void COOKED_READ_DATA::_handleVkey(uint16_t vkey, DWORD modifiers) switch (vkey) { case VK_ESCAPE: - if (!_buffer.Get().empty()) + if (!_buffer.empty()) { - _buffer.Replace(0, npos, nullptr, 0); + _replace(0, npos, nullptr, 0); } break; case VK_HOME: - if (_buffer.GetCursorPosition() > 0) + if (_bufferCursor > 0) { if (ctrlPressed) { - _buffer.Replace(0, _buffer.GetCursorPosition(), nullptr, 0); + _replace(0, _bufferCursor, nullptr, 0); } - _buffer.SetCursorPosition(0); + _setCursorPosition(0); } break; case VK_END: - if (_buffer.GetCursorPosition() < _buffer.Get().size()) + if (_bufferCursor < _buffer.size()) { if (ctrlPressed) { - _buffer.Replace(_buffer.GetCursorPosition(), npos, nullptr, 0); + _replace(_bufferCursor, npos, nullptr, 0); } - _buffer.SetCursorPosition(npos); + _setCursorPosition(npos); } break; case VK_LEFT: - if (_buffer.GetCursorPosition() != 0) + if (_bufferCursor != 0) { if (ctrlPressed) { - _buffer.SetCursorPosition(_wordPrev(_buffer.Get(), _buffer.GetCursorPosition())); + _setCursorPosition(_wordPrev(_buffer, _bufferCursor)); } else { - _buffer.SetCursorPosition(TextBuffer::GraphemePrev(_buffer.Get(), _buffer.GetCursorPosition())); + _setCursorPosition(TextBuffer::GraphemePrev(_buffer, _bufferCursor)); } } break; case VK_F1: case VK_RIGHT: - if (_buffer.GetCursorPosition() != _buffer.Get().size()) + if (_bufferCursor != _buffer.size()) { if (ctrlPressed && vkey == VK_RIGHT) { - _buffer.SetCursorPosition(_wordNext(_buffer.Get(), _buffer.GetCursorPosition())); + _setCursorPosition(_wordNext(_buffer, _bufferCursor)); } else { - _buffer.SetCursorPosition(TextBuffer::GraphemeNext(_buffer.Get(), _buffer.GetCursorPosition())); + _setCursorPosition(TextBuffer::GraphemeNext(_buffer, _bufferCursor)); } } else if (_history) { // Traditionally pressing right at the end of an input line would paste characters from the previous command. const auto cmd = _history->GetLastCommand(); - const auto bufferSize = _buffer.Get().size(); + const auto bufferSize = _buffer.size(); const auto cmdSize = cmd.size(); size_t bufferBeg = 0; size_t cmdBeg = 0; @@ -573,11 +587,11 @@ void COOKED_READ_DATA::_handleVkey(uint16_t vkey, DWORD modifiers) if (bufferBeg >= bufferSize) { - _buffer.Replace(npos, 0, cmd.data() + cmdBeg, cmdEnd - cmdBeg); + _replace(npos, 0, cmd.data() + cmdBeg, cmdEnd - cmdBeg); break; } - bufferBeg = TextBuffer::GraphemeNext(_buffer.Get(), bufferBeg); + bufferBeg = TextBuffer::GraphemeNext(_buffer, bufferBeg); cmdBeg = cmdEnd; } } @@ -587,36 +601,36 @@ void COOKED_READ_DATA::_handleVkey(uint16_t vkey, DWORD modifiers) _screenInfo.SetCursorDBMode(_insertMode != ServiceLocator::LocateGlobals().getConsoleInformation().GetInsertMode()); break; case VK_DELETE: - if (_buffer.GetCursorPosition() < _buffer.Get().size()) + if (_bufferCursor < _buffer.size()) { - const auto beg = _buffer.GetCursorPosition(); - const auto end = TextBuffer::GraphemeNext(_buffer.Get(), beg); - _buffer.Replace(beg, end - beg, nullptr, 0); + const auto beg = _bufferCursor; + const auto end = TextBuffer::GraphemeNext(_buffer, beg); + _replace(beg, end - beg, nullptr, 0); } break; case VK_UP: case VK_F5: if (_history && !_history->AtFirstCommand()) { - _buffer.Replace(_history->Retrieve(CommandHistory::SearchDirection::Previous)); + _replace(_history->Retrieve(CommandHistory::SearchDirection::Previous)); } break; case VK_DOWN: if (_history && !_history->AtLastCommand()) { - _buffer.Replace(_history->Retrieve(CommandHistory::SearchDirection::Next)); + _replace(_history->Retrieve(CommandHistory::SearchDirection::Next)); } break; case VK_PRIOR: if (_history && !_history->AtFirstCommand()) { - _buffer.Replace(_history->RetrieveNth(0)); + _replace(_history->RetrieveNth(0)); } break; case VK_NEXT: if (_history && !_history->AtLastCommand()) { - _buffer.Replace(_history->RetrieveNth(INT_MAX)); + _replace(_history->RetrieveNth(INT_MAX)); } break; case VK_F2: @@ -629,10 +643,10 @@ void COOKED_READ_DATA::_handleVkey(uint16_t vkey, DWORD modifiers) if (_history) { const auto last = _history->GetLastCommand(); - if (last.size() > _buffer.GetCursorPosition()) + if (last.size() > _bufferCursor) { - const auto count = last.size() - _buffer.GetCursorPosition(); - _buffer.Replace(_buffer.GetCursorPosition(), npos, last.data() + _buffer.GetCursorPosition(), count); + const auto count = last.size() - _bufferCursor; + _replace(_bufferCursor, npos, last.data() + _bufferCursor, count); } } break; @@ -666,12 +680,12 @@ void COOKED_READ_DATA::_handleVkey(uint16_t vkey, DWORD modifiers) if (_history) { CommandHistory::Index index = 0; - const auto cursorPos = _buffer.GetCursorPosition(); - const auto prefix = std::wstring_view{ _buffer.Get() }.substr(0, cursorPos); + const auto cursorPos = _bufferCursor; + const auto prefix = std::wstring_view{ _buffer }.substr(0, cursorPos); if (_history->FindMatchingCommand(prefix, _history->LastDisplayed, index, CommandHistory::MatchOptions::None)) { - _buffer.Replace(_history->RetrieveNth(index)); - _buffer.SetCursorPosition(cursorPos); + _replace(_history->RetrieveNth(index)); + _setCursorPosition(cursorPos); } } break; @@ -699,7 +713,7 @@ void COOKED_READ_DATA::_handleVkey(uint16_t vkey, DWORD modifiers) void COOKED_READ_DATA::_handlePostCharInputLoop(const bool isUnicode, size_t& numBytes, ULONG& controlKeyState) { auto writer = _userBuffer; - auto buffer = _buffer.Extract(); + auto buffer = std::move(_buffer); std::wstring_view input{ buffer }; size_t lineCount = 1; @@ -797,453 +811,484 @@ void COOKED_READ_DATA::_transitionState(State state) noexcept _state = state; } -// Draws the contents of _buffer onto the screen. -// -// By using _buffer._dirtyBeg to avoid redrawing the buffer unless needed, we turn the amortized -// time complexity of _readCharInputLoop() from O(n^2) (n(n+1)/2 redraws) into O(n). -// Pasting text would quickly turn into "accidentally quadratic" meme material otherwise. -// -// NOTE: Don't call _flushBuffer() after appending newlines to the buffer! See _handlePostCharInputLoop for more information. -void COOKED_READ_DATA::_flushBuffer() +til::point COOKED_READ_DATA::_getViewportCursorPosition() const noexcept { - if (_buffer.IsClean() || WI_IsFlagClear(_pInputBuffer->InputMode, ENABLE_ECHO_INPUT)) - { - return; - } - - // `_buffer` is split up by two different indices: - // * `_buffer._cursor`: Text before the `_buffer._cursor` index must be accumulated - // into `distanceBeforeCursor` and the other half into `distanceAfterCursor`. - // This helps us figure out where the cursor is positioned on the screen. - // * `_buffer._dirtyBeg`: Text before `_buffer._dirtyBeg` must be written with SuppressMSAA - // and the other half without. Any text before `_buffer._dirtyBeg` is considered unchanged, - // and this split prevents us from announcing text that hasn't actually changed - // to accessibility tools via MSAA (or UIA, but UIA is robust against this anyways). - // - // This results in 2*2 = 4 writes of which always at least one of the middle two is empty, - // depending on whether _buffer._cursor > _buffer._dirtyBeg or _buffer._cursor < _buffer._dirtyBeg. - // slice() returns an empty string-view when `from` index is greater than the `to` index. - - ptrdiff_t distanceBeforeCursor = 0; - ptrdiff_t distanceAfterCursor = 0; - { - // _distanceCursor might be larger than the entire viewport (= a really long input line). - // _offsetCursorPosition() with such an offset will end up clamping the cursor position to (0,0). - // To make this implementation behave a little bit more consistent in this case without - // writing a more thorough and complex readline implementation, we pass _measureChars() - // the relative "distance" to the current actual cursor position. That way _measureChars() - // can still figure out what the logical cursor position is, when it handles tabs, etc. - auto dirtyBegDistance = -_distanceCursor; - - distanceBeforeCursor = _measureChars(_buffer.GetUnmodifiedTextBeforeCursor(), dirtyBegDistance); - dirtyBegDistance += distanceBeforeCursor; - distanceAfterCursor = _measureChars(_buffer.GetUnmodifiedTextAfterCursor(), dirtyBegDistance); - dirtyBegDistance += distanceAfterCursor; - - _offsetCursorPosition(dirtyBegDistance); - } - - // Now we can finally write the parts of _buffer that have actually changed (or moved). - distanceBeforeCursor += _writeChars(_buffer.GetModifiedTextBeforeCursor()); - distanceAfterCursor += _writeChars(_buffer.GetModifiedTextAfterCursor()); - - const auto distanceEnd = distanceBeforeCursor + distanceAfterCursor; - const auto eraseDistance = std::max(0, _distanceEnd - distanceEnd); - - // If the contents of _buffer became shorter we'll have to erase the previously printed contents. - _erase(eraseDistance); - // Using the *Always() variant ensures that we reset the blinking timer, etc., even if the cursor didn't move. - _offsetCursorPositionAlways(-eraseDistance - distanceAfterCursor); + const auto& textBuffer = _screenInfo.GetTextBuffer(); + const auto& cursor = textBuffer.GetCursor(); + auto cursorPos = cursor.GetPosition(); - _buffer.MarkAsClean(); - _distanceCursor = distanceBeforeCursor; - _distanceEnd = distanceEnd; + _screenInfo.GetVtPageArea().ConvertToOrigin(&cursorPos); + cursorPos.x = std::max(0, cursorPos.x); + cursorPos.y = std::max(0, cursorPos.y); + return cursorPos; } -// This is just a small helper to fill the next N cells starting at the current cursor position with whitespace. -void COOKED_READ_DATA::_erase(ptrdiff_t distance) const +void COOKED_READ_DATA::_replace(size_t offset, size_t remove, const wchar_t* input, size_t count) { - if (distance <= 0) + const auto size = _buffer.size(); + offset = std::min(offset, size); + remove = std::min(remove, size - offset); + + // Nothing to do. Avoid marking it as dirty. + if (remove == 0 && count == 0) { return; } - std::array whitespace; - auto remaining = gsl::narrow_cast(distance); - auto nextWriteSize = std::min(remaining, whitespace.size()); + _buffer.replace(offset, remove, input, count); + _bufferCursor = offset + count; + _bufferDirtyBeg = std::min(_bufferDirtyBeg, offset); + _dirty = true; +} - // If we only need to erase 1 character worth of whitespace, - // we don't need to initialize 256 bytes worth of a whitespace array. - // nextWriteSize can only ever shrink past this point if anything. - std::fill_n(whitespace.begin(), nextWriteSize, L' '); +void COOKED_READ_DATA::_replace(const std::wstring_view& str) +{ + _buffer.assign(str); + _bufferCursor = _buffer.size(); + _bufferDirtyBeg = 0; + _dirty = true; +} - do - { - std::ignore = _writeChars({ whitespace.data(), nextWriteSize }); - remaining -= nextWriteSize; - nextWriteSize = std::min(remaining, whitespace.size()); - } while (remaining != 0); +void COOKED_READ_DATA::_setCursorPosition(size_t position) noexcept +{ + _bufferCursor = std::min(position, _buffer.size()); + _dirty = true; } -// A helper to calculate the number of cells `text` would take up if it were written. -// `cursorOffset` allows the caller to specify a "logical" cursor position relative to the actual cursor position. -// This allows the function to track in which column it currently is, which is needed to implement tabs for instance. -ptrdiff_t COOKED_READ_DATA::_measureChars(const std::wstring_view& text, ptrdiff_t cursorOffset) const +std::wstring_view COOKED_READ_DATA::_slice(size_t from, size_t to) const noexcept { - if (text.empty()) - { - return 0; - } - return _writeCharsImpl(text, true, cursorOffset); + to = std::min(to, _buffer.size()); + from = std::min(from, to); + return std::wstring_view{ _buffer.data() + from, to - from }; } -// A helper to write text and calculate the number of cells we've written. -// _unwindCursorPosition then allows us to go that many cells back. Tracking cells instead of explicit -// buffer positions allows us to pay no further mind to whether the buffer scrolled up or not. -ptrdiff_t COOKED_READ_DATA::_writeChars(const std::wstring_view& text) const +// Draws the contents of _buffer onto the screen. +// +// By using the _dirty flag we avoid redrawing the buffer unless needed. +// This turns the amortized time complexity of _readCharInputLoop() from O(n^2) (n(n+1)/2 redraws) into O(n). +// Without this, pasting text would otherwise quickly turn into "accidentally quadratic" meme material. +// +// NOTE: Don't call _flushBuffer() after appending newlines to the buffer! See _handlePostCharInputLoop for more information. +void COOKED_READ_DATA::_redisplay() { - if (text.empty()) + if (!_dirty || WI_IsFlagClear(_pInputBuffer->InputMode, ENABLE_ECHO_INPUT)) { - return 0; + return; } - return _writeCharsImpl(text, false, 0); -} -ptrdiff_t COOKED_READ_DATA::_writeCharsImpl(const std::wstring_view& text, const bool measureOnly, const ptrdiff_t cursorOffset) const -{ - const auto width = _screenInfo.GetTextBuffer().GetSize().Width(); - auto it = text.begin(); - const auto end = text.end(); - ptrdiff_t distance = 0; + const auto size = _screenInfo.GetVtPageArea().Dimensions(); + auto originInViewportFinal = _originInViewport; + til::point cursorPositionFinal; + til::point pagerPromptEnd; + std::vector lines; + // FYI: This loop does not loop. It exists because goto is considered evil + // and if MSVC says that then that must be true. for (;;) { - const auto nextControlChar = std::find_if(it, end, [](const auto& wch) { return wch < L' '; }); - if (nextControlChar != it) + cursorPositionFinal = { _originInViewport.x, 0 }; + + // Construct the first line manually so that it starts at the correct horizontal position. + LayoutResult res{ .column = cursorPositionFinal.x }; + lines.emplace_back(std::wstring{}, 0, cursorPositionFinal.x, cursorPositionFinal.x); + + // Split the buffer into 3 segments, so that we can find the row/column coordinates of + // the cursor within the buffer, as well as the start of the dirty parts of the buffer. + const size_t offsets[]{ + 0, + std::min(_bufferDirtyBeg, _bufferCursor), + std::max(_bufferDirtyBeg, _bufferCursor), + npos, + }; + + for (int i = 0; i < 3; i++) { - if (measureOnly) + const auto& segment = til::safe_slice_abs(_buffer, offsets[i], offsets[i + 1]); + if (segment.empty()) { - distance += _measureCharsUnprocessed({ it, nextControlChar }, distance + cursorOffset); + continue; } - else + + const auto dirty = offsets[i] >= _bufferDirtyBeg; + + // Layout the _buffer contents into lines. + for (size_t beg = 0; beg < segment.size();) + { + if (res.column >= size.width) + { + lines.emplace_back(); + } + + auto& line = lines.back(); + res = _layoutLine(line.text, segment, beg, line.columns, size.width); + line.columns = res.column; + + if (!dirty) + { + line.dirtyBegOffset = line.text.size(); + line.dirtyBegColumn = res.column; + } + + beg = res.offset; + } + + // If this segment ended at the cursor offset, we got our cursor position in rows/columns. + if (offsets[i + 1] == _bufferCursor) { - distance += _writeCharsUnprocessed({ it, nextControlChar }); + cursorPositionFinal = { res.column, gsl::narrow_cast(lines.size() - 1) }; } - it = nextControlChar; } - if (nextControlChar == end) - { - break; - } - - wchar_t buf[2]; - size_t len = 0; - const auto wch = *it; - if (wch == UNICODE_TAB) + pagerPromptEnd = { res.column, gsl::narrow_cast(lines.size() - 1) }; + if (pagerPromptEnd.x >= size.width) { - buf[0] = L'\t'; - len = 1; + pagerPromptEnd.x = 0; + pagerPromptEnd.y++; } - else + + // If the content got a little shorter than it was before, we need to erase the tail end. + // If the last character on a line got removed, we'll skip this code because `remaining` + // will be negative, and instead we'll erase it later when we append " \r" to the lines. + // If entire lines got removed, then we'll fix this later when comparing against _pagerContentEnd.y. + if (pagerPromptEnd.y <= _pagerPromptEnd.y) { - // In the interactive mode we replace C0 control characters (0x00-0x1f) with ASCII representations like ^C (= 0x03). - buf[0] = L'^'; - buf[1] = gsl::narrow_cast(wch + L'@'); - len = 2; + const auto endX = _pagerPromptEnd.y == pagerPromptEnd.y ? _pagerPromptEnd.x : size.width; + const auto remaining = endX - pagerPromptEnd.x; + + if (remaining > 0) + { + auto& line = lines.back(); + + // CSI K may be expensive, so use spaces if we can. + if (remaining <= 8) + { + line.text.append(remaining, L' '); + } + else + { + line.text.append(L"\x1b[K"); + } + } } - if (measureOnly) + // Render the popups, if there are any. + if (!_popups.empty()) { - if (wch == UNICODE_TAB) + auto& popup = _popups.front(); + + // Ensure that the popup is not considered part of the prompt line. That is, if someone double-clicks + // to select the last word in the prompt, it should not select the first word in the popup. + auto& lastLine = lines.back(); + lastLine.text.append(L"\r\n"); + lastLine.columns = size.width; + + switch (popup.kind) { - const auto col = _getColumnAtRelativeCursorPosition(distance + cursorOffset); - const auto remaining = width - col; - distance += std::min(remaining, 8 - (col & 7)); + case PopupKind::CopyToChar: + _popupDrawPrompt(lines, size.width, ID_CONSOLE_MSGCMDLINEF2, {}, {}); + break; + case PopupKind::CopyFromChar: + _popupDrawPrompt(lines, size.width, ID_CONSOLE_MSGCMDLINEF4, {}, {}); + break; + case PopupKind::CommandNumber: + _popupDrawPrompt(lines, size.width, ID_CONSOLE_MSGCMDLINEF9, {}, { popup.commandNumber.buffer.data(), CommandNumberMaxInputLength }); + break; + case PopupKind::CommandList: + _popupDrawCommandList(lines, size, popup); + break; + default: + assert(false); } - else + + // Put the cursor at the end of the contents. This ensures we scroll all the way down. + cursorPositionFinal.x = lines.back().columns; + cursorPositionFinal.y = gsl::narrow_cast(lines.size()) - 1; + } + // If the cursor is at a delay-wrapped position, wrap it explicitly. + // This ensures that the cursor is always "after" the insertion position. + // We don't need to do this when popups are present, because they're not supposed to end in a newline. + else if (cursorPositionFinal.x >= size.width) + { + cursorPositionFinal.x = 0; + cursorPositionFinal.y++; + + // If the cursor is at the end of the buffer we must always show it after the last character. + // Since VT uses delayed EOL wrapping, we must write at least 1 more character to force the + // potential delayed line wrap at the end of the prompt, on the last line. + // This doubles as the code that erases the last character on the last line when backspacing. + // That's also why we append 2 spaces, because the last character may have been a ^E control + // character visualizer, which sneakily actually consists of 2 characters. + if (_bufferCursor == _buffer.size()) { - distance += 2; + lines.emplace_back(L" \r", 0, 0, 0); } } - else + + // Usually we'll be on a "prompt> ..." line and behave like a regular single-line-editor. + // But once the entire viewport is full of text, we need to behave more like a pager (= scrolling, etc.). + // This code retries the layout process if needed, because then the cursor starts at origin {0, 0}. + if (gsl::narrow_cast(lines.size()) > size.height && originInViewportFinal.x != 0) { - distance += _writeCharsUnprocessed({ &buf[0], len }); + lines.clear(); + _originInViewport.x = 0; + _bufferDirtyBeg = 0; + originInViewportFinal = {}; + continue; } - ++it; + break; } - return distance; -} + const auto lineCount = gsl::narrow_cast(lines.size()); + const auto pagerHeight = std::min(lineCount, size.height); -ptrdiff_t COOKED_READ_DATA::_measureCharsUnprocessed(const std::wstring_view& text, ptrdiff_t cursorOffset) const -{ - if (text.empty()) + // If the contents of the prompt are longer than the remaining number of lines in the viewport, + // we need to reduce originInViewportFinal.y towards 0 to account for that. In other words, + // as the viewport fills itself with text the _originInViewport will slowly move towards 0. + originInViewportFinal.y = std::min(originInViewportFinal.y, size.height - pagerHeight); + + auto pagerContentTop = _pagerContentTop; + // If the cursor is above the viewport, we go up... + pagerContentTop = std::min(pagerContentTop, cursorPositionFinal.y); + // and if the cursor is below it, we go down. + pagerContentTop = std::max(pagerContentTop, cursorPositionFinal.y - size.height + 1); + // The value may be out of bounds, because the above min/max doesn't ensure this on its own. + pagerContentTop = std::clamp(pagerContentTop, 0, lineCount - pagerHeight); + + // Transform the recorded position from the lines vector coordinate space into VT screen space. + // Due to the above scrolling of pagerTop, cursorPosition should now always be within the viewport. + // dirtyBegPosition however could be outside of it. + cursorPositionFinal.y += originInViewportFinal.y - pagerContentTop; + + std::wstring output; + + // Disable the cursor when opening a popup, reenable it when closing them. + if (const auto popupOpened = !_popups.empty(); _popupOpened != popupOpened) { - return 0; + wchar_t buf[] = L"\x1b[?25l"; + buf[5] = popupOpened ? 'l' : 'h'; + output.append(&buf[0], 6); + _popupOpened = popupOpened; } - auto& textBuffer = _screenInfo.GetTextBuffer(); - const auto width = textBuffer.GetSize().Width(); - auto columnLimit = width - _getColumnAtRelativeCursorPosition(cursorOffset); + // If we have so much text that it doesn't fit into the viewport (origin == {0,0}), + // then we can scroll the existing contents of the pager and only write what got newly uncovered. + if (const auto delta = pagerContentTop - _pagerContentTop; delta != 0 && _pagerHeight == size.height && pagerHeight == size.height) + { + const auto deltaAbs = abs(delta); + til::CoordType beg = 0; + til::CoordType end = pagerHeight; - size_t offset = 0; - ptrdiff_t distance = 0; + // If the top changed by more than the viewport height, scrolling doesn't make sense. + if (deltaAbs < size.height) + { + beg = delta >= 0 ? pagerHeight - deltaAbs : 0; + end = delta >= 0 ? pagerHeight : deltaAbs; + const auto cmd = delta >= 0 ? L'S' : L'T'; + fmt::format_to(std::back_inserter(output), FMT_COMPILE(L"\x1b[{}{}"), deltaAbs, cmd); + } - while (offset < text.size()) + // Mark each row that has been uncovered by the scroll as dirty. + for (auto i = beg; i < end; i++) + { + auto& line = lines.at(i + pagerContentTop); + line.dirtyBegOffset = 0; + line.dirtyBegColumn = 0; + } + } + + bool anyDirty = false; + for (til::CoordType i = 0; i < pagerHeight; i++) { - til::CoordType columns = 0; - offset += textBuffer.FitTextIntoColumns(text.substr(offset), columnLimit, columns); - distance += columns; - columnLimit = width; + const auto& line = lines.at(i + pagerContentTop); + anyDirty = line.dirtyBegOffset < line.text.size(); + if (anyDirty) + { + break; + } } - return distance; -} + til::point writeCursorPosition{ -1, -1 }; -// A helper to write text and calculate the number of cells we've written. -// _unwindCursorPosition then allows us to go that many cells back. Tracking cells instead of explicit -// buffer positions allows us to pay no further mind to whether the buffer scrolled up or not. -ptrdiff_t COOKED_READ_DATA::_writeCharsUnprocessed(const std::wstring_view& text) const -{ - if (text.empty()) + if (anyDirty) { - return 0; - } +#if COOKED_READ_DEBUG + static size_t debugColorIndex = 0; + const auto color = til::colorbrewer::dark2[++debugColorIndex % std::size(til::colorbrewer::dark2)]; + fmt::format_to(std::back_inserter(output), FMT_COMPILE(L"\x1b[48;2;{};{};{}m"), GetRValue(color), GetGValue(color), GetBValue(color)); +#endif - const auto& textBuffer = _screenInfo.GetTextBuffer(); - const auto& cursor = textBuffer.GetCursor(); - const auto width = static_cast(textBuffer.GetSize().Width()); - const auto initialCursorPos = cursor.GetPosition(); - til::CoordType scrollY = 0; + for (til::CoordType i = 0; i < pagerHeight; i++) + { + const auto row = std::min(_originInViewport.y + i, size.height - 1); - WriteCharsLegacy(_screenInfo, text, &scrollY); + // If the last write left the cursor at the end of a line, the next write will start at the beginning of the next line. + // This avoids needless calls to _appendCUP. The reason it's here and not at the end of the loop is similar to how + // delay-wrapping in VT works: The line wrap only occurs after writing 1 more character than fits on the line. + if (writeCursorPosition.x >= size.width) + { + writeCursorPosition.x = 0; + writeCursorPosition.y = row; + } - const auto finalCursorPos = cursor.GetPosition(); - const auto distance = (finalCursorPos.y - initialCursorPos.y + scrollY) * width + finalCursorPos.x - initialCursorPos.x; - assert(distance >= 0); - return distance; -} + const auto& line = lines.at(i + pagerContentTop); -// Moves the given point by the given distance inside the text buffer, as if moving a cursor with the left/right arrow keys. -til::point COOKED_READ_DATA::_offsetPosition(til::point pos, ptrdiff_t distance) const -{ - if (distance == 0) - { - return pos; - } + // Skip lines that aren't marked as dirty. + if (line.dirtyBegOffset >= line.text.size()) + { + continue; + } - const auto size = _screenInfo.GetTextBuffer().GetSize().Dimensions(); - const auto w = static_cast(size.width); - const auto h = static_cast(size.height); - const auto area = w * h; + // Position the cursor wherever the dirty part of the line starts. + if (const til::point pos{ line.dirtyBegColumn, row }; writeCursorPosition != pos) + { + writeCursorPosition = pos; + _appendCUP(output, pos); + } - auto off = w * pos.y + pos.x; - off += distance; - off = off < 0 ? 0 : (off > area ? area : off); + output.append(line.text, line.dirtyBegOffset); + writeCursorPosition.x = line.columns; + } - return { - gsl::narrow_cast(off % w), - gsl::narrow_cast(off / w), - }; -} +#if COOKED_READ_DEBUG + output.append(L"\x1b[m"); +#endif + } -// See _offsetCursorPositionAlways(). This wrapper is just here to avoid doing -// expensive cursor movements when there's nothing to move. A no-op wrapper. -void COOKED_READ_DATA::_offsetCursorPosition(ptrdiff_t distance) const -{ - if (distance != 0) + // Clear any lines that we previously filled and are now empty. { - _offsetCursorPositionAlways(distance); + const auto pagerHeightPrevious = std::min(_pagerHeight, size.height); + + if (pagerHeight < pagerHeightPrevious) + { + const auto row = std::min(_originInViewport.y + pagerHeight, size.height - 1); + _appendCUP(output, { 0, row }); + output.append(L"\x1b[K"); + + for (til::CoordType i = pagerHeight + 1; i < pagerHeightPrevious; i++) + { + output.append(L"\x1b[E\x1b[K"); + } + } } -} -// This moves the cursor `distance`-many cells around in the buffer. -// It's intended to be used in combination with _writeChars. -// Usually you should use _offsetCursorPosition() to no-op distance==0. -void COOKED_READ_DATA::_offsetCursorPositionAlways(ptrdiff_t distance) const -{ - const auto& textBuffer = _screenInfo.GetTextBuffer(); - const auto& cursor = textBuffer.GetCursor(); - const auto pos = _offsetPosition(cursor.GetPosition(), distance); + _appendCUP(output, cursorPositionFinal); + WriteCharsVT(_screenInfo, output); - std::ignore = _screenInfo.SetCursorPosition(pos, true); - _screenInfo.MakeCursorVisible(pos); + _originInViewport = originInViewportFinal; + _pagerPromptEnd = pagerPromptEnd; + _pagerContentTop = pagerContentTop; + _pagerHeight = pagerHeight; + _bufferDirtyBeg = _buffer.size(); + _dirty = false; } -til::CoordType COOKED_READ_DATA::_getColumnAtRelativeCursorPosition(ptrdiff_t distance) const +COOKED_READ_DATA::LayoutResult COOKED_READ_DATA::_layoutLine(std::wstring& output, const std::wstring_view& input, const size_t inputOffset, const til::CoordType columnBegin, const til::CoordType columnLimit) const { const auto& textBuffer = _screenInfo.GetTextBuffer(); - const auto size = _screenInfo.GetTextBuffer().GetSize().Dimensions(); - const auto& cursor = textBuffer.GetCursor(); - const auto pos = cursor.GetPosition(); + const auto beg = input.data(); + const auto end = beg + input.size(); + auto it = beg + std::min(inputOffset, input.size()); + auto column = std::min(columnBegin, columnLimit); + + output.reserve(output.size() + columnLimit - column); - auto x = gsl::narrow_cast((pos.x + distance) % size.width); - if (x < 0) + while (it != end && column < columnLimit) { - x += size.width; - } + const auto nextControlChar = std::find_if(it, end, [](const auto& wch) { return wch < L' '; }); + if (it != nextControlChar) + { + std::wstring_view text{ it, nextControlChar }; + til::CoordType cols = 0; + const auto len = textBuffer.FitTextIntoColumns(text, columnLimit - column, cols); - return x; -} + output.append(text, 0, len); + column += cols; + it += len; + } -// If the viewport is large enough to fit a popup, this function prepares everything for -// showing the given type. It handles computing the size of the popup, its position, -// backs the affected area up and draws the border and initial contents. -void COOKED_READ_DATA::_popupPush(const PopupKind kind) -try -{ - auto& textBuffer = _screenInfo.GetTextBuffer(); - const auto viewport = _screenInfo.GetViewport(); - const auto viewportOrigin = viewport.Origin(); - const auto viewportSize = viewport.Dimensions(); + const auto nextPlainChar = std::find_if(it, end, [](const auto& wch) { return wch >= L' '; }); + for (; it != nextPlainChar; ++it) + { + if (column >= columnLimit) + { + goto outerLoopExit; + } - til::size proposedSize; - switch (kind) - { - case PopupKind::CopyToChar: - proposedSize = { 26, 1 }; - break; - case PopupKind::CopyFromChar: - proposedSize = { 28, 1 }; - break; - case PopupKind::CommandNumber: - proposedSize = { 22 + CommandNumberMaxInputLength, 1 }; - break; - case PopupKind::CommandList: - { - const auto& commands = _history->GetCommands(); - const auto commandCount = _history->GetNumberOfCommands(); + const auto wch = *it; + wchar_t buf[8]; + til::CoordType len = 0; - size_t maxStringLength = 0; - for (const auto& c : commands) - { - maxStringLength = std::max(maxStringLength, c.size()); - } + if (wch == UNICODE_TAB) + { + const auto remaining = columnLimit - column; + len = std::min(8 - (column & 7), remaining); + std::fill_n(&buf[0], len, L' '); + } + else + { + buf[0] = L'^'; + buf[1] = wch + L'@'; + len = 2; + } - // Account for the "123: " prefix each line gets. - maxStringLength += integerLog10(commandCount); - maxStringLength += 3; - - // conhost used to draw the command list with a size of 40x10, but at some point it switched over to dynamically - // sizing it depending on the history count and width of the entries. Back then it was implemented with the - // assumption that the code unit count equals the column count, which I kept because I don't want the TextBuffer - // class to expose how wide characters are, any more than necessary. It makes implementing Unicode support - // much harder, because things like combining marks and work zones may cause TextBuffer to end up deciding - // a piece of text has a different size than what you thought it had when measuring it on its own. - proposedSize.width = gsl::narrow_cast(std::clamp(maxStringLength, 40, til::CoordTypeMax)); - proposedSize.height = std::clamp(commandCount, 10, 20); - break; - } - default: - assert(false); - return; + if (column + len > columnLimit) + { + goto outerLoopExit; + } + + output.append(buf, len); + column += len; + } } - // Subtract 2 because we need to draw the border around our content. We must return early if we're - // unable to do so, otherwise the remaining code fails because the size would be zero/negative. - const til::size contentSize{ - std::min(proposedSize.width, viewportSize.width - 2), - std::min(proposedSize.height, viewportSize.height - 2), - }; - if (!contentSize) +outerLoopExit: + if (it != end && column < columnLimit) { - return; + output.append(columnLimit - column, L' '); + column = columnLimit; } - const auto widthSizeT = gsl::narrow_cast(contentSize.width + 2); - const auto heightSizeT = gsl::narrow_cast(contentSize.height + 2); - const til::point contentOrigin{ - (viewportSize.width - contentSize.width) / 2 + viewportOrigin.x, - (viewportSize.height - contentSize.height) / 2 + viewportOrigin.y, - }; - const til::rect contentRect{ - contentOrigin, - contentSize, + return { + .offset = static_cast(it - beg), + .column = column, }; - const auto backupRect = Viewport::FromExclusive({ - contentRect.left - 1, - contentRect.top - 1, - contentRect.right + 1, - contentRect.bottom + 1, - }); - - auto& popup = _popups.emplace_back(kind, contentRect, backupRect, std::vector{ widthSizeT * heightSizeT }); - - // Create a backup of the TextBuffer parts we're scribbling over. - // We need to flush the buffer to ensure we capture the latest contents. - // NOTE: This may theoretically modify popup.backupRect (practically unlikely). - _flushBuffer(); - THROW_IF_FAILED(ServiceLocator::LocateGlobals().api->ReadConsoleOutputWImpl(_screenInfo, popup.backup, backupRect, popup.backupRect)); - - // Draw the border around our content and fill it with whitespace to prepare it for future usage. - { - const auto attributes = _screenInfo.GetPopupAttributes(); +} - RowWriteState state{ - .columnBegin = contentRect.left - 1, - .columnLimit = contentRect.right + 1, - }; +void COOKED_READ_DATA::_appendCUP(std::wstring& output, til::point pos) +{ + fmt::format_to(std::back_inserter(output), FMT_COMPILE(L"\x1b[{};{}H"), pos.y + 1, pos.x + 1); +} - // top line ┌───┐ - std::wstring buffer; - buffer.assign(widthSizeT, L'─'); - buffer.front() = L'┌'; - buffer.back() = L'┐'; - state.text = buffer; - textBuffer.Replace(contentRect.top - 1, attributes, state); - - // bottom line └───┘ - buffer.front() = L'└'; - buffer.back() = L'┘'; - state.text = buffer; - textBuffer.Replace(contentRect.bottom, attributes, state); - - // middle lines │ │ - buffer.assign(widthSizeT, L' '); - buffer.front() = L'│'; - buffer.back() = L'│'; - for (til::CoordType y = contentRect.top; y < contentRect.bottom; ++y) - { - state.text = buffer; - textBuffer.Replace(y, attributes, state); - } - } +void COOKED_READ_DATA::_appendPopupAttr(std::wstring& output) const +{ + VtIo::FormatAttributes(output, _screenInfo.GetPopupAttributes()); +} + +void COOKED_READ_DATA::_popupPush(const PopupKind kind) +try +{ + auto& popup = _popups.emplace_back(kind); + _dirty = true; switch (kind) { - case PopupKind::CopyToChar: - _popupDrawPrompt(popup, ID_CONSOLE_MSGCMDLINEF2); - break; - case PopupKind::CopyFromChar: - _popupDrawPrompt(popup, ID_CONSOLE_MSGCMDLINEF4); - break; case PopupKind::CommandNumber: popup.commandNumber.buffer.fill(' '); popup.commandNumber.bufferSize = 0; - _popupDrawPrompt(popup, ID_CONSOLE_MSGCMDLINEF9); break; case PopupKind::CommandList: + popup.commandList.top = -1; + popup.commandList.height = 10; popup.commandList.selected = _history->LastDisplayed; - popup.commandList.top = popup.commandList.selected - contentSize.height / 2; - _popupDrawCommandList(popup); break; default: - assert(false); - } - - // If this is the first popup to be shown, stop the cursor from appearing/blinking - if (_popups.size() == 1) - { - textBuffer.GetCursor().SetIsPopupShown(true); + break; } } catch (...) { LOG_CAUGHT_EXCEPTION(); - // Using _popupsDone() is a convenient way to restore the buffer contents if anything in this call failed. - // This could technically dismiss an unrelated popup that was already in _popups, but reaching this point is unlikely anyways. _popupsDone(); } @@ -1252,23 +1297,8 @@ catch (...) // Pressing F7 followed by F9 (CommandNumber on top of CommandList). void COOKED_READ_DATA::_popupsDone() { - while (!_popups.empty()) - { - auto& popup = _popups.back(); - - // Restore TextBuffer contents. They could be empty if _popupPush() - // threw an exception in the middle of construction. - if (!popup.backup.empty()) - { - [[maybe_unused]] Viewport unused; - LOG_IF_FAILED(ServiceLocator::LocateGlobals().api->WriteConsoleOutputWImpl(_screenInfo, popup.backup, popup.backupRect, unused)); - } - - _popups.pop_back(); - } - - // Restore cursor blinking. - _screenInfo.GetTextBuffer().GetCursor().SetIsPopupShown(false); + _popups.clear(); + _dirty = true; } void COOKED_READ_DATA::_popupHandleInput(wchar_t wch, uint16_t vkey, DWORD modifiers) @@ -1312,16 +1342,16 @@ void COOKED_READ_DATA::_popupHandleCopyToCharInput(Popup& /*popup*/, const wchar { // See PopupKind::CopyToChar for more information about this code. const auto cmd = _history->GetLastCommand(); - const auto cursor = _buffer.GetCursorPosition(); + const auto cursor = _bufferCursor; const auto idx = cmd.find(wch, cursor); if (idx != decltype(cmd)::npos) { - // When we enter this if condition it's guaranteed that _buffer.GetCursorPosition() must be + // When we enter this if condition it's guaranteed that _bufferCursor must be // smaller than idx, which in turn implies that it's smaller than cmd.size(). // As such, calculating length is safe and str.size() == length. const auto count = idx - cursor; - _buffer.Replace(cursor, count, cmd.data() + cursor, count); + _replace(cursor, count, cmd.data() + cursor, count); } _popupsDone(); @@ -1340,10 +1370,10 @@ void COOKED_READ_DATA::_popupHandleCopyFromCharInput(Popup& /*popup*/, const wch else { // See PopupKind::CopyFromChar for more information about this code. - const auto cursor = _buffer.GetCursorPosition(); - auto idx = _buffer.Get().find(wch, cursor); - idx = std::min(idx, _buffer.Get().size()); - _buffer.Replace(cursor, idx - cursor, nullptr, 0); + const auto cursor = _bufferCursor; + auto idx = _buffer.find(wch, cursor); + idx = std::min(idx, _buffer.size()); + _replace(cursor, idx - cursor, nullptr, 0); _popupsDone(); } } @@ -1362,16 +1392,15 @@ void COOKED_READ_DATA::_popupHandleCommandNumberInput(Popup& popup, const wchar_ if (wch == UNICODE_CARRIAGERETURN) { popup.commandNumber.buffer[popup.commandNumber.bufferSize++] = L'\0'; - _buffer.Replace(_history->RetrieveNth(std::stoi(popup.commandNumber.buffer.data()))); + _replace(_history->RetrieveNth(std::stoi(popup.commandNumber.buffer.data()))); _popupsDone(); - return; } - - if (wch >= L'0' && wch <= L'9') + else if (wch >= L'0' && wch <= L'9') { if (popup.commandNumber.bufferSize < CommandNumberMaxInputLength) { popup.commandNumber.buffer[popup.commandNumber.bufferSize++] = wch; + _dirty = true; } } else if (wch == UNICODE_BACKSPACE) @@ -1379,19 +1408,9 @@ void COOKED_READ_DATA::_popupHandleCommandNumberInput(Popup& popup, const wchar_ if (popup.commandNumber.bufferSize > 0) { popup.commandNumber.buffer[--popup.commandNumber.bufferSize] = L' '; + _dirty = true; } } - else - { - return; - } - - RowWriteState state{ - .text = { popup.commandNumber.buffer.data(), CommandNumberMaxInputLength }, - .columnBegin = popup.contentRect.right - CommandNumberMaxInputLength, - .columnLimit = popup.contentRect.right, - }; - _screenInfo.GetTextBuffer().Replace(popup.contentRect.top, _screenInfo.GetPopupAttributes(), state); } } @@ -1401,7 +1420,7 @@ void COOKED_READ_DATA::_popupHandleCommandListInput(Popup& popup, const wchar_t if (wch == UNICODE_CARRIAGERETURN) { - _buffer.Replace(_history->RetrieveNth(cl.selected)); + _replace(_history->RetrieveNth(cl.selected)); _popupsDone(); _handleChar(UNICODE_CARRIAGERETURN, modifiers); return; @@ -1425,7 +1444,7 @@ void COOKED_READ_DATA::_popupHandleCommandListInput(Popup& popup, const wchar_t break; case VK_LEFT: case VK_RIGHT: - _buffer.Replace(_history->RetrieveNth(cl.selected)); + _replace(_history->RetrieveNth(cl.selected)); _popupsDone(); return; case VK_UP: @@ -1453,88 +1472,138 @@ void COOKED_READ_DATA::_popupHandleCommandListInput(Popup& popup, const wchar_t break; case VK_PRIOR: // _popupDrawCommandList() clamps all values to valid ranges in `cl`. - cl.selected -= popup.contentRect.height(); + cl.selected -= cl.height; break; case VK_NEXT: // _popupDrawCommandList() clamps all values to valid ranges in `cl`. - cl.selected += popup.contentRect.height(); + cl.selected += cl.height; break; default: return; } - _popupDrawCommandList(popup); + _dirty = true; } -void COOKED_READ_DATA::_popupDrawPrompt(const Popup& popup, const UINT id) const +void COOKED_READ_DATA::_popupDrawPrompt(std::vector& lines, const til::CoordType width, const UINT id, const std::wstring_view& prefix, const std::wstring_view& suffix) const { - const auto text = _LoadString(id); - RowWriteState state{ - .text = text, - .columnBegin = popup.contentRect.left, - .columnLimit = popup.contentRect.right, - }; - _screenInfo.GetTextBuffer().Replace(popup.contentRect.top, _screenInfo.GetPopupAttributes(), state); + std::wstring str; + str.append(prefix); + _LoadString(id, str); + str.append(suffix); + + std::wstring line; + line.append(L"\x1b[K"); + _appendPopupAttr(line); + const auto res = _layoutLine(line, str, 0, 0, width); + line.append(L"\x1b[m"); + + lines.emplace_back(std::move(line), 0, 0, res.column); } -void COOKED_READ_DATA::_popupDrawCommandList(Popup& popup) const +void COOKED_READ_DATA::_popupDrawCommandList(std::vector& lines, const til::size size, Popup& popup) const { assert(popup.kind == PopupKind::CommandList); auto& cl = popup.commandList; - const auto max = _history->GetNumberOfCommands(); - const auto width = popup.contentRect.narrow_width(); - const auto height = std::min(popup.contentRect.height(), _history->GetNumberOfCommands()); - const auto dirtyHeight = std::max(height, cl.dirtyHeight); + const auto historySize = _history->GetNumberOfCommands(); + const auto indexWidth = gsl::narrow_cast(fmt::formatted_size(FMT_COMPILE(L"{}"), historySize)); + + // The popup is half the height of the viewport, but at least 1 and at most 20 lines. + // Unless of course the history size is less than that. We also reserve 1 additional line + // of space in case the user presses F9 which will open the "Enter command number:" popup. + const auto height = std::min(historySize, std::min(size.height / 2 - 1, 20)); + if (height < 1) + { + return; + } + + // cl.selected may be out of bounds after a page up/down, etc., so we need to clamp it. + cl.selected = std::clamp(cl.selected, 0, historySize - 1); + // If it hasn't been initialized it yet, center the selected item. + if (cl.top < 0) { - // The viewport movement of the popup is anchored around the current selection first and foremost. - cl.selected = std::clamp(cl.selected, 0, max - 1); + cl.top = std::max(0, cl.selected - height / 2); + } + + // If the selection is above the viewport, we go up... + cl.top = std::min(cl.top, cl.selected); + // and if the selection is below it, we go down. + cl.top = std::max(cl.top, cl.selected - height + 1); + // The value may be out of bounds, because the above min/max doesn't ensure this on its own. + cl.top = std::clamp(cl.top, 0, historySize - height); + + // We also need to update the height for future page up/down movements. + cl.height = height; + + // Calculate the position of the █ track in the scrollbar among all the ▒. + // The position is offset by +1 because at off == 0 we draw the ▲. + // We add historyMax/2 to round the division result to the nearest value. + const auto historyMax = historySize - 1; + const auto trackPositionMax = height - 3; + const auto trackPosition = historyMax <= 0 ? 0 : 1 + (trackPositionMax * cl.selected + historyMax / 2) / historyMax; + const auto stackedCommandNumberPopup = _popups.size() == 2 && _popups.back().kind == PopupKind::CommandNumber; + + for (til::CoordType off = 0; off < height; ++off) + { + const auto index = cl.top + off; + const auto str = _history->GetNth(index); + const auto selected = index == cl.selected && !stackedCommandNumberPopup; + + std::wstring line; + line.append(L"\x1b[K"); + _appendPopupAttr(line); + + wchar_t scrollbarChar = L' '; + if (historySize > height) + { + if (off == 0) + { + scrollbarChar = L'▴'; + } + else if (off == height - 1) + { + scrollbarChar = L'▾'; + } + else + { + scrollbarChar = off == trackPosition ? L'█' : L'▒'; + } + } + line.push_back(scrollbarChar); - // It then lazily follows it when the selection goes out of the viewport. - if (cl.selected < cl.top) + if (selected) { - cl.top = cl.selected; + line.push_back(L'▸'); } - else if (cl.selected >= cl.top + height) + else { - cl.top = cl.selected - height + 1; + line.append(L"\x1b[m "); } - cl.top = std::clamp(cl.top, 0, max - height); - } + fmt::format_to(std::back_inserter(line), FMT_COMPILE(L"{:{}}: "), index, indexWidth); - std::wstring buffer; - buffer.reserve(width * 2 + 4); + _layoutLine(line, str, 0, indexWidth + 4, size.width); - const auto& attrRegular = _screenInfo.GetPopupAttributes(); - auto attrInverted = attrRegular; - attrInverted.Invert(); - - RowWriteState state{ - .columnBegin = popup.contentRect.left, - .columnLimit = popup.contentRect.right, - }; - - for (til::CoordType off = 0; off < dirtyHeight; ++off) - { - const auto y = popup.contentRect.top + off; - const auto historyIndex = cl.top + off; - const auto str = _history->GetNth(historyIndex); - const auto& attr = historyIndex == cl.selected ? attrInverted : attrRegular; - - buffer.clear(); - if (!str.empty()) + if (selected) { - buffer.append(std::to_wstring(historyIndex)); - buffer.append(L": "); - buffer.append(str); + line.append(L"\x1b[m"); } - buffer.append(width, L' '); - state.text = buffer; - _screenInfo.GetTextBuffer().Replace(y, attr, state); + line.append(L"\r\n"); + lines.emplace_back(std::move(line), 0, 0, size.width); } - cl.dirtyHeight = height; + if (stackedCommandNumberPopup) + { + const std::wstring_view suffix{ _popups.back().commandNumber.buffer.data(), CommandNumberMaxInputLength }; + _popupDrawPrompt(lines, size.width - 1, ID_CONSOLE_MSGCMDLINEF9, L"╰", suffix); + } + else + { + // Remove the \r\n we added to the last line, as we don't want to have an empty line at the end. + auto& lastLine = lines.back(); + lastLine.text.erase(lastLine.text.size() - 2); + } } diff --git a/src/host/readDataCooked.hpp b/src/host/readDataCooked.hpp index 035c3c96679..22da5334276 100644 --- a/src/host/readDataCooked.hpp +++ b/src/host/readDataCooked.hpp @@ -39,7 +39,7 @@ class COOKED_READ_DATA final : public ReadData til::point_span GetBoundaries() const noexcept; private: - static constexpr uint8_t CommandNumberMaxInputLength = 5; + static constexpr size_t CommandNumberMaxInputLength = 5; static constexpr size_t npos = static_cast(-1); enum class State : uint8_t @@ -49,40 +49,9 @@ class COOKED_READ_DATA final : public ReadData DoneWithCarriageReturn, }; - // A helper struct to ensure we keep track of _dirtyBeg while the - // underlying _buffer is being modified by COOKED_READ_DATA. - struct BufferState - { - const std::wstring& Get() const noexcept; - std::wstring Extract() noexcept - { - return std::move(_buffer); - } - void Replace(size_t offset, size_t remove, const wchar_t* input, size_t count); - void Replace(const std::wstring_view& str); - - size_t GetCursorPosition() const noexcept; - void SetCursorPosition(size_t pos) noexcept; - - bool IsClean() const noexcept; - void MarkEverythingDirty() noexcept; - void MarkAsClean() noexcept; - - std::wstring_view GetUnmodifiedTextBeforeCursor() const noexcept; - std::wstring_view GetUnmodifiedTextAfterCursor() const noexcept; - std::wstring_view GetModifiedTextBeforeCursor() const noexcept; - std::wstring_view GetModifiedTextAfterCursor() const noexcept; - - private: - std::wstring_view _slice(size_t from, size_t to) const noexcept; - - std::wstring _buffer; - size_t _dirtyBeg = npos; - size_t _cursor = 0; - }; - enum class PopupKind { + // The F2 popup: // Copies text from the previous command between the current cursor position and the first instance // of a given char (but not including it) into the current prompt line at the current cursor position. // Basically, F3 and this prompt have identical behavior, but the prompt searches for a terminating character. @@ -95,11 +64,14 @@ class COOKED_READ_DATA final : public ReadData // Then this command, given the char "o" will turn it into // echo hell efgh CopyToChar, + // The F4 popup: // Erases text between the current cursor position and the first instance of a given char (but not including it). // It's unknown to me why this is was historically called "copy from char" as it conhost never copied anything. CopyFromChar, + // The F9 popup: // Let's you choose to replace the current prompt with one from the command history by index. CommandNumber, + // The F7 popup: // Let's you choose to replace the current prompt with one from the command history via a // visual select dialog. Among all the popups this one is the most widely used one by far. CommandList, @@ -109,15 +81,6 @@ class COOKED_READ_DATA final : public ReadData { PopupKind kind; - // The inner rectangle of the popup, excluding the border that we draw. - // In absolute TextBuffer coordinates. - til::rect contentRect; - // The area we've backed up and need to restore when we dismiss the popup. - // It'll practically always be 1 larger than contentRect in all 4 directions. - Microsoft::Console::Types::Viewport backupRect; - // The backed up buffer contents. Uses CHAR_INFO for convenience. - std::vector backup; - // Using a std::variant would be preferable in modern C++ but is practically equally annoying to use. union { @@ -126,23 +89,37 @@ class COOKED_READ_DATA final : public ReadData { // Keep 1 char space for the trailing \0 char. std::array buffer; - uint8_t bufferSize; + size_t bufferSize; } commandNumber; // Used by PopupKind::CommandList struct { + // The previous height of the popup. + til::CoordType height; // Command history index of the first row we draw in the popup. + // A value of -1 means it hasn't been initialized yet. CommandHistory::Index top; // Command history index of the currently selected row. CommandHistory::Index selected; - // Tracks the part of the popup that has previously been drawn and needs to be redrawn in the next paint. - // This becomes relevant when the length of the history changes while the popup is open (= when deleting entries). - til::CoordType dirtyHeight; } commandList; }; }; + struct LayoutResult + { + size_t offset; + til::CoordType column = 0; + }; + + struct Line + { + std::wstring text; + size_t dirtyBegOffset = 0; + til::CoordType dirtyBegColumn = 0; + til::CoordType columns = 0; + }; + static size_t _wordPrev(const std::wstring_view& chars, size_t position); static size_t _wordNext(const std::wstring_view& chars, size_t position); @@ -151,17 +128,15 @@ class COOKED_READ_DATA final : public ReadData void _handleVkey(uint16_t vkey, DWORD modifiers); void _handlePostCharInputLoop(bool isUnicode, size_t& numBytes, ULONG& controlKeyState); void _transitionState(State state) noexcept; - void _flushBuffer(); - void _erase(ptrdiff_t distance) const; - ptrdiff_t _measureChars(const std::wstring_view& text, ptrdiff_t cursorOffset) const; - ptrdiff_t _writeChars(const std::wstring_view& text) const; - ptrdiff_t _writeCharsImpl(const std::wstring_view& text, bool measureOnly, ptrdiff_t cursorOffset) const; - ptrdiff_t _measureCharsUnprocessed(const std::wstring_view& text, ptrdiff_t cursorOffset) const; - ptrdiff_t _writeCharsUnprocessed(const std::wstring_view& text) const; - til::point _offsetPosition(til::point pos, ptrdiff_t distance) const; - void _offsetCursorPosition(ptrdiff_t distance) const; - void _offsetCursorPositionAlways(ptrdiff_t distance) const; - til::CoordType _getColumnAtRelativeCursorPosition(ptrdiff_t distance) const; + til::point _getViewportCursorPosition() const noexcept; + void _replace(size_t offset, size_t remove, const wchar_t* input, size_t count); + void _replace(const std::wstring_view& str); + std::wstring_view _slice(size_t from, size_t to) const noexcept; + void _setCursorPosition(size_t position) noexcept; + void _redisplay(); + LayoutResult _layoutLine(std::wstring& output, const std::wstring_view& input, size_t inputOffset, til::CoordType columnBegin, til::CoordType columnLimit) const; + static void _appendCUP(std::wstring& output, til::point pos); + void _appendPopupAttr(std::wstring& output) const; void _popupPush(PopupKind kind); void _popupsDone(); @@ -170,8 +145,8 @@ class COOKED_READ_DATA final : public ReadData void _popupHandleCommandNumberInput(Popup& popup, wchar_t wch, uint16_t vkey, DWORD modifiers); void _popupHandleCommandListInput(Popup& popup, wchar_t wch, uint16_t vkey, DWORD modifiers); void _popupHandleInput(wchar_t wch, uint16_t vkey, DWORD keyState); - void _popupDrawPrompt(const Popup& popup, UINT id) const; - void _popupDrawCommandList(Popup& popup) const; + void _popupDrawPrompt(std::vector& lines, const til::CoordType width, UINT id, const std::wstring_view& prefix, const std::wstring_view& suffix) const; + void _popupDrawCommandList(std::vector& lines, til::size size, Popup& popup) const; SCREEN_INFORMATION& _screenInfo; std::span _userBuffer; @@ -182,15 +157,23 @@ class COOKED_READ_DATA final : public ReadData ULONG _controlKeyState = 0; std::unique_ptr _tempHandle; - BufferState _buffer; - // _distanceCursor is the distance between the start of the prompt and the - // current cursor location in columns (including wide glyph padding columns). - ptrdiff_t _distanceCursor = 0; - // _distanceEnd is the distance between the start of the prompt and its last - // glyph at the end in columns (including wide glyph padding columns). - ptrdiff_t _distanceEnd = 0; - bool _insertMode = false; + std::wstring _buffer; + size_t _bufferDirtyBeg = npos; + size_t _bufferCursor = 0; State _state = State::Accumulating; + bool _insertMode = false; + bool _dirty = false; + bool _redrawPending = false; + + til::point _originInViewport; + // This value is in the pager coordinate space. (0,0) is the first character of the + // first line, independent on where the prompt actually appears on the screen. + til::point _pagerPromptEnd; + // The scroll position of the pager. + til::CoordType _pagerContentTop = 0; + // Contains the viewport height for which it previously was drawn for. + til::CoordType _pagerHeight = 0; std::vector _popups; + bool _popupOpened = false; }; diff --git a/src/host/renderData.cpp b/src/host/renderData.cpp index f242f2753f0..b8b3b71e3dd 100644 --- a/src/host/renderData.cpp +++ b/src/host/renderData.cpp @@ -134,7 +134,7 @@ bool RenderData::IsCursorVisible() const noexcept { const auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation(); const auto& cursor = gci.GetActiveOutputBuffer().GetTextBuffer().GetCursor(); - return cursor.IsVisible() && !cursor.IsPopupShown(); + return cursor.IsVisible(); } // Method Description: diff --git a/src/host/screenInfo.cpp b/src/host/screenInfo.cpp index 6a823b3b39f..f383bff4755 100644 --- a/src/host/screenInfo.cpp +++ b/src/host/screenInfo.cpp @@ -1041,6 +1041,11 @@ void SCREEN_INFORMATION::_InternalSetViewportSize(const til::size* const pcoordS const auto DeltaY = pcoordSize->height - _viewport.Height(); const auto coordScreenBufferSize = GetBufferSize().Dimensions(); + if (DeltaX == 0 && DeltaY == 0) + { + return; + } + // do adjustments on a copy that's easily manipulated. auto srNewViewport = _viewport.ToInclusive(); @@ -1124,15 +1129,11 @@ void SCREEN_INFORMATION::_InternalSetViewportSize(const til::size* const pcoordS // If the new bottom is supposed to be before the final line of the buffer // Check to ensure that we don't hide the prompt by collapsing the window. - // The final valid end position will be the coordinates of - // the last character displayed (including any characters - // in the input line) - til::point coordValidEnd; - Selection::Instance().GetValidAreaBoundaries(nullptr, &coordValidEnd); + const auto coordValidEnd = _textBuffer->GetCursor().GetPosition(); // If the bottom of the window when adjusted would be // above the final line of valid text... - if (srNewViewport.bottom + DeltaY < coordValidEnd.y) + if (sBottomProposed < coordValidEnd.y) { // Adjust the top of the window instead of the bottom // (so the lines slide upward) @@ -1208,11 +1209,16 @@ void SCREEN_INFORMATION::_InternalSetViewportSize(const til::size* const pcoordS // till the start of the next frame. If any other text gets output before // that frame starts, there's a very real chance that it'll cause errors as // the engine tries to invalidate those regions. - const auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation(); + auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation(); if (gci.IsInVtIoMode() && ServiceLocator::LocateGlobals().pRender) { ServiceLocator::LocateGlobals().pRender->TriggerScroll(); } + if (gci.HasPendingCookedRead()) + { + gci.CookedReadData().RedrawAfterResize(); + MakeCurrentCursorVisible(); + } } // Routine Description: @@ -1465,13 +1471,6 @@ NT_CATCH_RETURN() { gci.CookedReadData().EraseBeforeResize(); } - const auto cookedReadRestore = wil::scope_exit([]() { - auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation(); - if (gci.HasPendingCookedRead()) - { - gci.CookedReadData().RedrawAfterResize(); - } - }); const auto fWrapText = gci.GetWrapText(); // GH#3493: Don't reflow the alt buffer. @@ -2454,20 +2453,24 @@ void SCREEN_INFORMATION::UpdateBottom() _virtualBottom = _viewport.BottomInclusive(); } -// Method Description: -// - Returns the "virtual" Viewport - the viewport with its bottom at -// `_virtualBottom`. For VT operations, this is essentially the mutable -// section of the buffer. -// Arguments: -// - -// Return Value: -// - the virtual terminal viewport +// Returns the section of the text buffer that would be visible on the screen +// if the user didn't scroll away vertically. It's essentially the same as +// GetVtPageArea() but includes the horizontal scroll offset and window width. Viewport SCREEN_INFORMATION::GetVirtualViewport() const noexcept { const auto newTop = _virtualBottom - _viewport.Height() + 1; return Viewport::FromDimensions({ _viewport.Left(), newTop }, _viewport.Dimensions()); } +// Returns the section of the text buffer that's addressable by VT sequences. +Viewport SCREEN_INFORMATION::GetVtPageArea() const noexcept +{ + const auto viewportHeight = _viewport.Height(); + const auto bufferWidth = _textBuffer->GetSize().Width(); + const auto top = std::max(0, _virtualBottom - viewportHeight + 1); + return Viewport::FromExclusive({ 0, top, bufferWidth, top + viewportHeight }); +} + // Method Description: // - Returns true if the character at the cursor's current position is wide. // Arguments: diff --git a/src/host/screenInfo.hpp b/src/host/screenInfo.hpp index 035529e0d6e..bf2d5e29fd6 100644 --- a/src/host/screenInfo.hpp +++ b/src/host/screenInfo.hpp @@ -115,6 +115,7 @@ class SCREEN_INFORMATION : public ConsoleObjectHeader, public Microsoft::Console const Microsoft::Console::Types::Viewport& GetViewport() const noexcept; void SetViewport(const Microsoft::Console::Types::Viewport& newViewport, const bool updateBottom); Microsoft::Console::Types::Viewport GetVirtualViewport() const noexcept; + Microsoft::Console::Types::Viewport GetVtPageArea() const noexcept; void ProcessResizeWindow(const til::rect* const prcClientNew, const til::rect* const prcClientOld); void SetViewportSize(const til::size* const pcoordSize); diff --git a/src/host/utils.cpp b/src/host/utils.cpp index 74d08845412..0a3162e1f57 100644 --- a/src/host/utils.cpp +++ b/src/host/utils.cpp @@ -59,9 +59,8 @@ WORD ConvertStringToDec(_In_ PCWSTR pwchToConvert, _Out_opt_ PCWSTR* const ppwch // - Retrieves string resources from our resource files. // Arguments: // - id - Resource id from resource.h to the string we need to load. -// Return Value: -// - The string resource -std::wstring _LoadString(const UINT id) +// - out - Receives the translated string. +void _LoadString(const UINT id, std::wstring& out) { const auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation(); WCHAR ItemString[70]; @@ -78,7 +77,7 @@ std::wstring _LoadString(const UINT id) ItemLength = LoadStringW(ServiceLocator::LocateGlobals().hInstance, id, ItemString, ARRAYSIZE(ItemString)); } - return std::wstring(ItemString, ItemLength); + out.append(ItemString, ItemLength); } // Routine Description: diff --git a/src/host/utils.hpp b/src/host/utils.hpp index e0b6f62e916..6692a3e8ac0 100644 --- a/src/host/utils.hpp +++ b/src/host/utils.hpp @@ -27,7 +27,7 @@ til::CoordType CalcWindowSizeY(const til::inclusive_rect& rect) noexcept; til::CoordType CalcCursorYOffsetInPixels(const til::CoordType sFontSizeY, const ULONG ulSize) noexcept; WORD ConvertStringToDec(_In_ PCWSTR pwchToConvert, _Out_opt_ PCWSTR* const ppwchEnd) noexcept; -std::wstring _LoadString(const UINT id); +void _LoadString(const UINT id, std::wstring& out); static UINT s_LoadStringEx(_In_ HINSTANCE hModule, _In_ UINT wID, _Out_writes_(cchBufferMax) LPWSTR lpBuffer,