Files
terminal/src/host/readDataCooked.cpp
Leonard Hecker dfcc8f3c62 Fix use-after-free when disabling the ASB (#19186)
Closes #17515

## Validation Steps Performed
* Disable the ASB while there's a pending cooked read
* Type some text
* No crash 
2025-07-29 13:48:33 -05:00

1738 lines
67 KiB
C++

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#include "precomp.h"
#include "readDataCooked.hpp"
#include "alias.h"
#include "history.h"
#include "resource.h"
#include "stream.h"
#include "_stream.h"
#include "../interactivity/inc/ServiceLocator.hpp"
#define COOKED_READ_DEBUG 0
#if COOKED_READ_DEBUG
#include <til/colorbrewer.h>
#endif
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'.
// Arguments:
// - pInputBuffer - Buffer that data will be read from.
// - pInputReadHandleData - Context stored across calls from the same input handle to return partial data appropriately.
// - screenInfo - Output buffer that will be used for 'echoing' the line back to the user so they can see/manipulate it
// - UserBufferSize - The byte count of the buffer presented by the client
// - UserBuffer - The buffer that was presented by the client for filling with input data on read conclusion/return from server/host.
// - CtrlWakeupMask - Special client parameter to interrupt editing, end the wait, and return control to the client application
// - initialData - any text data that should be prepopulated into the buffer
// - pClientProcess - Attached process handle object
COOKED_READ_DATA::COOKED_READ_DATA(_In_ InputBuffer* const pInputBuffer,
_In_ INPUT_READ_HANDLE_DATA* const pInputReadHandleData,
SCREEN_INFORMATION& screenInfo,
_In_ size_t UserBufferSize,
_In_ char* UserBuffer,
_In_ ULONG CtrlWakeupMask,
_In_ const std::wstring_view exeName,
_In_ const std::wstring_view initialData,
_In_ ConsoleProcessHandle* const pClientProcess) :
ReadData(pInputBuffer, pInputReadHandleData),
_screenInfo{ screenInfo },
_userBuffer{ UserBuffer, UserBufferSize },
_exeName{ exeName },
_processHandle{ pClientProcess },
_history{ CommandHistory::s_Find(pClientProcess) },
_ctrlWakeupMask{ CtrlWakeupMask },
_insertMode{ ServiceLocator::LocateGlobals().getConsoleInformation().GetInsertMode() }
{
#ifndef UNIT_TESTING
// The screen buffer instance is basically a reference counted HANDLE given out to the user.
// We need to ensure that it stays alive for the duration of the read.
// Coincidentally this serves another important purpose: It checks whether we're allowed to read from
// the given buffer in the first place. If it's missing the FILE_SHARE_READ flag, we can't read from it.
//
// GH#16158: It's important that we hold a handle to the main instead of the alt buffer
// even if this cooked read targets the latter, because alt buffers are fake
// SCREEN_INFORMATION objects that are owned by the main buffer.
THROW_IF_FAILED(_screenInfo.GetMainBuffer().AllocateIoHandle(ConsoleHandleData::HandleType::Output, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, _tempHandle));
#endif
if (!initialData.empty())
{
// 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.
// The weird part is that it's not us who then prints the autocompletion. It's cmd.exe which calls WriteConsoleW().
// It then initiates another ReadConsole() where the `nInitialChars` is the amount of chars it wrote via WriteConsoleW().
//
// In other words, `nInitialChars` is a "trust me bro, I just wrote that in the buffer" API.
// This unfortunately means that the API is inherently broken: ReadConsole() visualizes control
// 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 implementation (from the 1990s up to 2023) looked something like that:
// cursor = cursor.GetPosition();
// cursor.x -= initialData.size();
// while (cursor.x < 0)
// {
// cursor.x += textBuffer.Width();
// cursor.y -= 1;
// }
//
// 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 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 cursorPos = _getViewportCursorPosition();
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<til::CoordType>((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)
{
originInViewport = {};
}
// 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);
}
}
const SCREEN_INFORMATION* COOKED_READ_DATA::GetScreenBuffer() const noexcept
{
return &_screenInfo;
}
// Routine Description:
// - This routine is called to complete a cooked read that blocked in ReadInputBuffer.
// - The context of the read was saved in the CookedReadData structure.
// - This routine is called when events have been written to the input buffer.
// - It is called in the context of the writing thread.
// - It may be called more than once.
// Arguments:
// - TerminationReason - if this routine is called because a ctrl-c or ctrl-break was seen, this argument
// contains CtrlC or CtrlBreak. If the owning thread is exiting, it will have ThreadDying. Otherwise, 0.
// - fIsUnicode - Whether to convert the final data to A (using Console Input CP) at the end or treat everything as Unicode (UCS-2)
// - pReplyStatus - The status code to return to the client application that originally called the API (before it was queued to wait)
// - pNumBytes - The number of bytes of data that the server/driver will need to transmit back to the client process
// - pControlKeyState - For certain types of reads, this specifies which modifier keys were held.
// - pOutputData - not used
// Return Value:
// - true if the wait is done and result buffer/status code can be sent back to the client.
// - false if we need to continue to wait until more data is available.
bool COOKED_READ_DATA::Notify(const WaitTerminationReason TerminationReason,
const bool fIsUnicode,
_Out_ NTSTATUS* const pReplyStatus,
_Out_ size_t* const pNumBytes,
_Out_ DWORD* const pControlKeyState,
_Out_ void* const /*pOutputData*/) noexcept
try
{
auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
*pNumBytes = 0;
*pControlKeyState = 0;
*pReplyStatus = STATUS_SUCCESS;
// if ctrl-c or ctrl-break was seen, terminate read.
if (WI_IsAnyFlagSet(TerminationReason, (WaitTerminationReason::CtrlC | WaitTerminationReason::CtrlBreak)))
{
*pReplyStatus = STATUS_ALERTED;
gci.SetCookedReadData(nullptr);
return true;
}
// See if we were called because the thread that owns this wait block is exiting.
if (WI_IsFlagSet(TerminationReason, WaitTerminationReason::ThreadDying))
{
*pReplyStatus = STATUS_THREAD_IS_TERMINATING;
gci.SetCookedReadData(nullptr);
return true;
}
// We must see if we were woken up because the handle is being closed. If
// so, we decrement the read count. If it goes to zero, we wake up the
// close thread. Otherwise, we wake up any other thread waiting for data.
if (WI_IsFlagSet(TerminationReason, WaitTerminationReason::HandleClosing))
{
*pReplyStatus = STATUS_ALERTED;
gci.SetCookedReadData(nullptr);
return true;
}
if (Read(fIsUnicode, *pNumBytes, *pControlKeyState))
{
gci.SetCookedReadData(nullptr);
return true;
}
return false;
}
NT_CATCH_RETURN()
void COOKED_READ_DATA::MigrateUserBuffersOnTransitionToBackgroundWait(const void* oldBuffer, void* newBuffer) noexcept
{
// See the comment in WaitBlock.cpp for more information.
if (_userBuffer.data() == oldBuffer)
{
_userBuffer = { static_cast<char*>(newBuffer), _userBuffer.size() };
}
}
// Routine Description:
// - Method that actually retrieves a character/input record from the buffer (key press form)
// and determines the next action based on the various possible cooked read modes.
// - Mode options include the F-keys popup menus, keyboard manipulation of the edit line, etc.
// - This method also does the actual copying of the final manipulated data into the return buffer.
// Arguments:
// - isUnicode - Treat as UCS-2 unicode or use Input CP to convert when done.
// - numBytes - On in, the number of bytes available in the client
// buffer. On out, the number of bytes consumed in the client buffer.
// - controlKeyState - For some types of reads, this is the modifier key state with the last button press.
bool COOKED_READ_DATA::Read(const bool isUnicode, size_t& numBytes, ULONG& controlKeyState)
{
controlKeyState = 0;
_readCharInputLoop();
// NOTE: Don't call _flushBuffer in a wil::scope_exit/defer.
// It may throw and throwing during an ongoing exception is a bad idea.
_redisplay();
if (_state == State::Accumulating)
{
return false;
}
_handlePostCharInputLoop(isUnicode, numBytes, controlKeyState);
return true;
}
// Printing wide glyphs at the end of a row results in a forced line wrap and a padding whitespace to be inserted.
// When the text buffer resizes these padding spaces may vanish and the _distanceCursor and _distanceEnd measurements become inaccurate.
// To fix this, this function is called before a resize and will clear the input line. Afterward, RedrawAfterResize() will restore it.
void COOKED_READ_DATA::EraseBeforeResize()
{
// If we've already erased the buffer, we don't need to do it again.
if (_redrawPending)
{
return;
}
// If we don't have an origin, we've never had user input, and consequently there's nothing to erase.
if (!_originInViewport)
{
return;
}
_redrawPending = true;
// Position the cursor the start of the prompt before reflow.
// Then, after reflow, we'll be able to ask the buffer where it went (the new origin).
// This uses the buffer APIs directly, so that we don't emit unnecessary VT into ConPTY's output.
auto& textBuffer = _screenInfo.GetTextBuffer();
auto& cursor = textBuffer.GetCursor();
auto cursorPos = *_originInViewport;
_screenInfo.GetVtPageArea().ConvertFromOrigin(&cursorPos);
cursor.SetPosition(cursorPos);
}
// The counter-part to EraseBeforeResize().
void COOKED_READ_DATA::RedrawAfterResize()
{
if (!_redrawPending)
{
return;
}
_redrawPending = false;
// Get the new cursor position after the reflow, since it may have changed.
if (_originInViewport)
{
_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();
// Let _redisplay() know to inject a CSI J at the start of the output.
// This ensures we fully erase the previous contents, that are now in disarray.
_clearPending = true;
_redisplay();
}
void COOKED_READ_DATA::SetInsertMode(bool insertMode) noexcept
{
_insertMode = insertMode;
}
bool COOKED_READ_DATA::IsEmpty() const noexcept
{
return _buffer.empty() && _popups.empty();
}
bool COOKED_READ_DATA::PresentingPopup() const noexcept
{
return !_popups.empty();
}
til::point_span COOKED_READ_DATA::GetBoundaries() noexcept
{
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 = _getOriginInViewport();
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 };
}
// _wordPrev and _wordNext implement the classic Windows word-wise cursor movement algorithm, as traditionally used by
// conhost, notepad, Visual Studio and other "old" applications. If you look closely you can see how they're the exact
// same "skip 1 char, skip x, skip not-x", but since the "x" between them is different (non-words for _wordPrev and
// words for _wordNext) it results in the inconsistent feeling that these have compared to more modern algorithms.
// TODO: GH#15787
size_t COOKED_READ_DATA::_wordPrev(const std::wstring_view& chars, size_t position)
{
if (position != 0)
{
--position;
while (position != 0 && chars[position] == L' ')
{
--position;
}
const auto dc = DelimiterClass(chars[position]);
while (position != 0 && DelimiterClass(chars[position - 1]) == dc)
{
--position;
}
}
return position;
}
size_t COOKED_READ_DATA::_wordNext(const std::wstring_view& chars, size_t position)
{
if (position < chars.size())
{
++position;
const auto dc = DelimiterClass(chars[position - 1]);
while (position != chars.size() && dc == DelimiterClass(chars[position]))
{
++position;
}
while (position != chars.size() && chars[position] == L' ')
{
++position;
}
}
return position;
}
// Reads text off of the InputBuffer and dispatches it to the current popup or otherwise into the _buffer contents.
void COOKED_READ_DATA::_readCharInputLoop()
{
while (_state == State::Accumulating)
{
const auto hasPopup = !_popups.empty();
auto charOrVkey = UNICODE_NULL;
auto commandLineEditingKeys = false;
auto popupKeys = false;
const auto pCommandLineEditingKeys = hasPopup ? nullptr : &commandLineEditingKeys;
const auto pPopupKeys = hasPopup ? &popupKeys : nullptr;
DWORD modifiers = 0;
const auto status = GetChar(_pInputBuffer, &charOrVkey, true, pCommandLineEditingKeys, pPopupKeys, &modifiers);
if (status == CONSOLE_STATUS_WAIT)
{
break;
}
THROW_IF_NTSTATUS_FAILED(status);
if (hasPopup)
{
const auto wch = static_cast<wchar_t>(popupKeys ? 0 : charOrVkey);
const auto vkey = static_cast<uint16_t>(popupKeys ? charOrVkey : 0);
_popupHandleInput(wch, vkey, modifiers);
}
else
{
if (commandLineEditingKeys)
{
_handleVkey(charOrVkey, modifiers);
}
else
{
_handleChar(charOrVkey, modifiers);
}
}
}
}
// Handles character input for _readCharInputLoop() when no popups exist.
void COOKED_READ_DATA::_handleChar(wchar_t wch, const DWORD modifiers)
{
// All paths in this function modify the buffer.
if (_ctrlWakeupMask != 0 && wch < L' ' && (_ctrlWakeupMask & (1 << wch)))
{
// The old implementation (all the way since the 90s) overwrote the character at the current cursor position with the given wch.
// But simultaneously it incremented the buffer length, which would have only worked if it was written at the end of the buffer.
// Press tab past the "f" in the string "foo" and you'd get "f\to " (a trailing whitespace; the initial contents of the buffer back then).
// It's unclear whether the original intention was to write at the end of the buffer at all times or to implement an insert mode.
// I went with insert mode.
//
// The old implementation also failed to clear the end of the prompt if you pressed tab in the middle of it.
// You can reproduce this issue by launching cmd in an old conhost build and writing "<command that doesn't exist> foo",
// moving your cursor to the space past the <command> and pressing tab. Nothing will happen but the "foo" will be inaccessible.
// I've now fixed this behavior by adding an additional Replace() before the _flushBuffer() call that removes the tail end.
//
// 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.
_replace(_bufferCursor, npos, nullptr, 0);
_redisplay();
_replace(_bufferCursor, 0, &wch, 1);
_dirty = false;
_controlKeyState = modifiers;
_transitionState(State::DoneWithWakeupMask);
return;
}
switch (wch)
{
case UNICODE_CARRIAGERETURN:
{
// NOTE: Don't append newlines to the buffer just yet! See _handlePostCharInputLoop for more information.
_setCursorPosition(npos);
_transitionState(State::DoneWithCarriageReturn);
return;
}
case EXTKEY_ERASE_PREV_WORD: // Ctrl+Backspace
case UNICODE_BACKSPACE:
if (WI_IsFlagSet(_pInputBuffer->InputMode, ENABLE_PROCESSED_INPUT))
{
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.
break;
default:
break;
}
size_t remove = 0;
if (!_insertMode)
{
// 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 = _bufferCursor;
remove = TextBuffer::GraphemeNext(_buffer, cursor) - cursor;
}
_replace(_bufferCursor, remove, &wch, 1);
}
// Handles non-character input for _readCharInputLoop() when no popups exist.
void COOKED_READ_DATA::_handleVkey(uint16_t vkey, DWORD modifiers)
{
const auto ctrlPressed = WI_IsAnyFlagSet(modifiers, LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED);
const auto altPressed = WI_IsAnyFlagSet(modifiers, LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED);
switch (vkey)
{
case VK_ESCAPE:
if (!_buffer.empty())
{
_replace(0, npos, nullptr, 0);
}
break;
case VK_HOME:
if (_bufferCursor > 0)
{
if (ctrlPressed)
{
_replace(0, _bufferCursor, nullptr, 0);
}
_setCursorPosition(0);
}
break;
case VK_END:
if (_bufferCursor < _buffer.size())
{
if (ctrlPressed)
{
_replace(_bufferCursor, npos, nullptr, 0);
}
_setCursorPosition(npos);
}
break;
case VK_LEFT:
if (_bufferCursor != 0)
{
if (ctrlPressed)
{
_setCursorPosition(_wordPrev(_buffer, _bufferCursor));
}
else
{
_setCursorPosition(TextBuffer::GraphemePrev(_buffer, _bufferCursor));
}
}
break;
case VK_F1:
case VK_RIGHT:
if (_bufferCursor != _buffer.size())
{
if (ctrlPressed && vkey == VK_RIGHT)
{
_setCursorPosition(_wordNext(_buffer, _bufferCursor));
}
else
{
_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.size();
const auto cmdSize = cmd.size();
size_t bufferBeg = 0;
size_t cmdBeg = 0;
// We cannot just check if the cmd is longer than the _buffer, because we want to copy graphemes,
// not characters and there's no correlation between the number of graphemes and their byte length.
while (cmdBeg < cmdSize)
{
const auto cmdEnd = TextBuffer::GraphemeNext(cmd, cmdBeg);
if (bufferBeg >= bufferSize)
{
_replace(npos, 0, cmd.data() + cmdBeg, cmdEnd - cmdBeg);
break;
}
bufferBeg = TextBuffer::GraphemeNext(_buffer, bufferBeg);
cmdBeg = cmdEnd;
}
}
break;
case VK_INSERT:
_insertMode = !_insertMode;
_screenInfo.SetCursorDBMode(_insertMode != ServiceLocator::LocateGlobals().getConsoleInformation().GetInsertMode());
break;
case VK_DELETE:
if (_bufferCursor < _buffer.size())
{
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())
{
_replace(_history->Retrieve(CommandHistory::SearchDirection::Previous));
}
break;
case VK_DOWN:
if (_history && !_history->AtLastCommand())
{
_replace(_history->Retrieve(CommandHistory::SearchDirection::Next));
}
break;
case VK_PRIOR:
if (_history && !_history->AtFirstCommand())
{
_replace(_history->RetrieveNth(0));
}
break;
case VK_NEXT:
if (_history && !_history->AtLastCommand())
{
_replace(_history->RetrieveNth(INT_MAX));
}
break;
case VK_F2:
if (_history)
{
_popupPush(PopupKind::CopyToChar);
}
break;
case VK_F3:
if (_history)
{
const auto last = _history->GetLastCommand();
if (last.size() > _bufferCursor)
{
const auto count = last.size() - _bufferCursor;
_replace(_bufferCursor, npos, last.data() + _bufferCursor, count);
}
}
break;
case VK_F4:
// Historically the CopyFromChar popup was constrained to only work when a history exists,
// but I don't see why that should be. It doesn't depend on _history at all.
_popupPush(PopupKind::CopyFromChar);
break;
case VK_F6:
// Don't ask me why but F6 is an alias for ^Z.
_handleChar(0x1a, modifiers);
break;
case VK_F7:
if (!ctrlPressed && !altPressed)
{
if (_history && _history->GetNumberOfCommands())
{
_popupPush(PopupKind::CommandList);
}
}
else if (altPressed)
{
if (_history)
{
_history->Empty();
_history->Flags |= CommandHistory::CLE_ALLOCATED;
}
}
break;
case VK_F8:
if (_history)
{
CommandHistory::Index index = 0;
const auto cursorPos = _bufferCursor;
const auto prefix = std::wstring_view{ _buffer }.substr(0, cursorPos);
if (_history->FindMatchingCommand(prefix, _history->LastDisplayed, index, CommandHistory::MatchOptions::None))
{
_replace(_history->RetrieveNth(index));
_setCursorPosition(cursorPos);
}
}
break;
case VK_F9:
if (_history && _history->GetNumberOfCommands())
{
_popupPush(PopupKind::CommandNumber);
}
break;
case VK_F10:
// Alt+F10 clears the aliases for specifically cmd.exe.
if (altPressed)
{
Alias::s_ClearCmdExeAliases();
}
break;
default:
assert(false); // Unrecognized VK. Fix or don't call this function?
break;
}
}
// Handles any tasks that need to be completed after the read input loop finishes,
// like handling doskey aliases and converting the input to non-UTF16.
void COOKED_READ_DATA::_handlePostCharInputLoop(const bool isUnicode, size_t& numBytes, ULONG& controlKeyState)
{
auto writer = _userBuffer;
auto buffer = std::move(_buffer);
std::wstring_view input{ buffer };
size_t lineCount = 1;
if (_state == State::DoneWithCarriageReturn)
{
static constexpr std::wstring_view cr{ L"\r" };
static constexpr std::wstring_view crlf{ L"\r\n" };
const auto newlineSuffix = WI_IsFlagSet(_pInputBuffer->InputMode, ENABLE_PROCESSED_INPUT) ? crlf : cr;
std::wstring alias;
// Here's why we can't easily use _flushBuffer() to handle newlines:
//
// A carriage return (enter key) will increase the _distanceEnd by up to viewport-width many columns,
// since it increases the Y distance between the start and end by 1 (it's a newline after all).
// This will make _flushBuffer() think that the new _buffer is way longer than the old one and so
// _erase() ends up not erasing the tail end of the prompt, even if the new prompt is actually shorter.
//
// If you were to break this (remove this code and then append \r\n in _handleChar())
// you can reproduce the issue easily if you do this:
// * Run cmd.exe
// * Write "echo hello" and press Enter
// * Write "foobar foo bar" (don't press Enter)
// * Press F7, select "echo hello" and press Enter
//
// It'll print "hello" but the previous prompt will say "echo hello bar" because the _distanceEnd
// ended up being well over 14 leading it to believe that "bar" got overwritten during WriteCharsLegacy().
WriteCharsLegacy(_screenInfo, newlineSuffix, nullptr);
if (WI_IsFlagSet(_pInputBuffer->InputMode, ENABLE_ECHO_INPUT))
{
if (_history)
{
auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
LOG_IF_FAILED(_history->Add(input, WI_IsFlagSet(gci.Flags, CONSOLE_HISTORY_NODUP)));
}
Tracing::s_TraceCookedRead(_processHandle, input);
alias = Alias::s_MatchAndCopyAlias(input, _exeName, lineCount);
}
if (!alias.empty())
{
buffer = std::move(alias);
}
else
{
buffer.append(newlineSuffix);
}
input = std::wstring_view{ buffer };
// doskey aliases may result in multiple lines of output (for instance `doskey test=echo foo$Techo bar$Techo baz`).
// We need to emit them as multiple cooked reads as well, so that each read completes at a \r\n.
if (lineCount > 1)
{
// ProcessAliases() is supposed to end each line with \r\n. If it doesn't we might as well fail-fast.
const auto firstLineEnd = input.find(UNICODE_LINEFEED) + 1;
input = input.substr(0, std::min(input.size(), firstLineEnd));
}
}
const auto inputSizeBefore = input.size();
_pInputBuffer->Consume(isUnicode, input, writer);
if (lineCount > 1)
{
// This is a continuation of the above identical if condition.
// We've truncated the `input` slice and now we need to restore it.
const auto inputSizeAfter = input.size();
const auto amountConsumed = inputSizeBefore - inputSizeAfter;
input = std::wstring_view{ buffer };
input = input.substr(std::min(input.size(), amountConsumed));
GetInputReadHandleData()->SaveMultilinePendingInput(input);
}
else if (!input.empty())
{
GetInputReadHandleData()->SavePendingInput(input);
}
auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
gci.Flags |= CONSOLE_IGNORE_NEXT_KEYUP;
// If we previously called SetCursorDBMode() with true,
// this will ensure that the cursor returns to its normal look.
_screenInfo.SetCursorDBMode(false);
numBytes = _userBuffer.size() - writer.size();
controlKeyState = _controlKeyState;
}
void COOKED_READ_DATA::_transitionState(State state) noexcept
{
assert(_state == State::Accumulating);
_state = state;
}
til::point COOKED_READ_DATA::_getViewportCursorPosition() const noexcept
{
const auto& textBuffer = _screenInfo.GetTextBuffer();
const auto& cursor = textBuffer.GetCursor();
auto cursorPos = cursor.GetPosition();
_screenInfo.GetVtPageArea().ConvertToOrigin(&cursorPos);
cursorPos.x = std::max(0, cursorPos.x);
cursorPos.y = std::max(0, cursorPos.y);
return cursorPos;
}
// Some applications initiate a read on stdin and _then_ print the prompt prefix to stdout.
// While that's not correct (because it's a race condition), we can make it significantly
// less bad by delaying the calculation of the origin until we actually need it.
// This turns it from a race between application and terminal into a race between
// application and user, which is much less likely to hit.
til::point COOKED_READ_DATA::_getOriginInViewport() noexcept
{
if (!_originInViewport)
{
_originInViewport.emplace(_getViewportCursorPosition());
}
return *_originInViewport;
}
void COOKED_READ_DATA::_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);
// Nothing to do. Avoid marking it as dirty.
if (remove == 0 && count == 0)
{
return;
}
_buffer.replace(offset, remove, input, count);
_bufferCursor = offset + count;
_bufferDirtyBeg = std::min(_bufferDirtyBeg, offset);
_dirty = true;
}
void COOKED_READ_DATA::_replace(const std::wstring_view& str)
{
_buffer.assign(str);
_bufferCursor = _buffer.size();
_bufferDirtyBeg = 0;
_dirty = true;
}
void COOKED_READ_DATA::_setCursorPosition(size_t position) noexcept
{
_bufferCursor = std::min(position, _buffer.size());
_dirty = true;
}
std::wstring_view COOKED_READ_DATA::_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 };
}
// 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 (!_dirty || WI_IsFlagClear(_pInputBuffer->InputMode, ENABLE_ECHO_INPUT))
{
return;
}
const auto size = _screenInfo.GetVtPageArea().Dimensions();
auto originInViewport = _getOriginInViewport();
auto originInViewportFinal = originInViewport;
til::point cursorPositionFinal;
til::point pagerPromptEnd;
std::vector<Line> 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 (;;)
{
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++)
{
const auto& segment = til::safe_slice_abs(_buffer, offsets[i], offsets[i + 1]);
if (segment.empty())
{
continue;
}
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)
{
cursorPositionFinal = { res.column, gsl::narrow_cast<til::CoordType>(lines.size() - 1) };
}
}
pagerPromptEnd = { res.column, gsl::narrow_cast<til::CoordType>(lines.size() - 1) };
// 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)
{
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 <= 16)
{
line.text.append(remaining, L' ');
line.columns += remaining;
}
else
{
// CSI K doesn't change the cursor position, so we don't modify .columns.
line.text.append(L"\x1b[K");
}
}
}
// Render the popups, if there are any.
if (!_popups.empty())
{
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)
{
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);
}
// 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<til::CoordType>(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.
// We append an extra line to get the lineCount for scrolling right.
if (_bufferCursor == _buffer.size())
{
auto& line = lines.emplace_back();
// This mirrors the `if (pagerPromptEnd.y <= _pagerPromptEnd.y)` above. We need to repeat this here,
// because if we append another line then we also need to repeat the "delete to end" logic.
// The best way to see this code kick in is if you have a prompt like this:
// +----------+
// |C:\> foo | <-- end the line in >=1 spaces
// |bar_ | <-- start the line with a word >2 characters
// +----------+
// Then put the cursor at the end (where the "_" is) and press Ctrl+Backspace.
auto remaining = (_pagerPromptEnd.y - pagerPromptEnd.y) * size.width + _pagerPromptEnd.x - pagerPromptEnd.x;
// Here we ensure that we force a EOL wrap no matter what. At a minimum this will result in " \r".
remaining = std::max(1, remaining);
// CSI K may be expensive, so use spaces if we can.
if (remaining <= 16)
{
line.text.append(remaining, L' ');
line.text.push_back(L'\r');
}
else
{
line.text.append(L" \r\x1b[K");
}
}
}
// 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<til::CoordType>(lines.size()) > size.height && originInViewportFinal.x != 0)
{
lines.clear();
_bufferDirtyBeg = 0;
originInViewport.x = 0;
originInViewportFinal = {};
continue;
}
break;
}
const auto lineCount = gsl::narrow_cast<til::CoordType>(lines.size());
const auto pagerHeight = std::min(lineCount, size.height);
// 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;
if (_clearPending)
{
_clearPending = false;
_appendCUP(output, originInViewport);
output.append(L"\x1b[J");
}
// Backup the attributes (DECSC) and disable the cursor when opening a popup (DECTCEM).
// Restore the attributes (DECRC) reenable the cursor when closing them (DECTCEM).
if (const auto popupOpened = !_popups.empty(); _popupOpened != popupOpened)
{
wchar_t buf[] =
// Back/restore cursor position & attributes (commonly supported)
L"\u001b7"
// Show/hide cursor (commonly supported)
"\u001b[?25l"
// The popup code uses XTPUSHSGR (CSI # {) / XTPOPSGR (CSI # }) to draw the popups in the popup-colors,
// while properly restoring the previous VT attributes. On terminals that support them, the following
// won't do anything. On other terminals however, it'll reset the attributes to default.
// This is important as the first thing the popup drawing code uses CSI K to erase the previous contents
// and CSI m to reset the attributes on terminals that don't support XTPUSHSGR/XTPOPSGR. In order for
// the first CSI K to behave as if there had a previous CSI m, we must emit an initial CSI m here.
// (rarely supported)
"\x1b[#{\x1b[m\x1b[#}";
buf[1] = popupOpened ? '7' : '8';
buf[7] = popupOpened ? 'l' : 'h';
// When the popup closes we skip the XTPUSHSGR/XTPOPSGR sequence. This is crucial because we
// use DECRC to restore the cursor position and attributes with a widely supported sequence.
// If we emitted that XTPUSHSGR/XTPOPSGR sequence it would reset the attributes again.
const size_t len = popupOpened ? 19 : 8;
output.append(buf, len);
_popupOpened = popupOpened;
}
// 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.
//
// The check for origin == {0,0} is important because it ensures that we "own" the entire viewport and
// that scrolling our contents doesn't scroll away the user's output that may still be in the viewport.
// (Anything below the origin is assumed to belong to us.)
if (const auto delta = pagerContentTop - _pagerContentTop; delta != 0 && originInViewport == til::point{})
{
const auto deltaAbs = abs(delta);
til::CoordType beg = 0;
til::CoordType end = pagerHeight;
// Let's say the viewport is 10 lines tall. Scenarios:
// * We had 2 lines (_pagerContentTop == 0, _pagerHeight == 2),
// and now it's 11 lines (pagerContentTop == 1, pagerHeight == 11).
// --> deltaAbs == 1
// --> Scroll ✔️
// * We had 2 lines (_pagerContentTop == 0, _pagerHeight == 2),
// and now it's 12 lines (pagerContentTop == 2, pagerHeight == 12).
// --> deltaAbs == 2
// --> Scroll ❌
//
// The same applies when going from 11/12 lines back to 2. It appears scrolling
// makes sense if the delta is smaller than the current or previous pagerHeight.
if (deltaAbs < std::min(_pagerHeight, pagerHeight))
{
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);
}
else
{
// We may not be scrolling with VT, because we're scrolling by more rows than the pagerHeight.
// Since no one is now clearing the scrolled in rows for us anymore, we need to do it ourselves.
auto& lastLine = lines.at(pagerHeight - 1 + pagerContentTop);
if (lastLine.columns < size.width)
{
lastLine.text.append(L"\x1b[K");
}
}
// 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++)
{
const auto& line = lines.at(i + pagerContentTop);
anyDirty = line.dirtyBegOffset < line.text.size();
if (anyDirty)
{
break;
}
}
til::point writeCursorPosition{ -1, -1 };
if (anyDirty)
{
#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
for (til::CoordType i = 0; i < pagerHeight; i++)
{
const auto row = std::min(originInViewport.y + i, size.height - 1);
// 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& line = lines.at(i + pagerContentTop);
// Skip lines that aren't marked as dirty.
// We use dirtyBegColumn instead of dirtyBegOffset to test for dirtiness, because a line that has 1 column
// of space for layout and was asked to fit a wide glyph will have no text, but still be "dirty".
// This ensures that we get the initial starting position of the _appendCUP below right.
if (line.dirtyBegColumn >= size.width)
{
continue;
}
// 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);
}
output.append(line.text, line.dirtyBegOffset);
writeCursorPosition.x = line.columns;
}
#if COOKED_READ_DEBUG
output.append(L"\x1b[m");
#endif
}
// Clear any lines that we previously filled and are now empty.
{
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");
}
}
}
_appendCUP(output, cursorPositionFinal);
WriteCharsVT(_screenInfo, output);
_originInViewport = originInViewportFinal;
_pagerPromptEnd = pagerPromptEnd;
_pagerContentTop = pagerContentTop;
_pagerHeight = pagerHeight;
_bufferDirtyBeg = _buffer.size();
_dirty = false;
}
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 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);
while (it != end && column < columnLimit)
{
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);
output.append(text, 0, len);
column += cols;
it += len;
if (it != nextControlChar)
{
// The only reason that not all text could be fit into the line is if the last character was a wide glyph.
// In that case we want to return the columnLimit, to indicate that the row is full and a line wrap is required,
// BUT DON'T want to pad the line with a whitespace to actually fill the line to the columnLimit.
// This is because copying the prompt contents (Ctrl-A, Ctrl-C) should not copy any trailing padding whitespace.
//
// Thanks to this lie, the _redisplay() code will not use a CRLF sequence or similar to move to the next line,
// as it thinks that this row has naturally wrapped. This causes it to print the wide glyph on the preceding line
// which causes the terminal to insert the padding whitespace for us.
column = columnLimit;
break;
}
if (column >= columnLimit)
{
break;
}
}
const auto nextPlainChar = std::find_if(it, end, [](const auto& wch) { return wch >= L' '; });
for (; it != nextPlainChar; ++it)
{
const auto wch = *it;
wchar_t buf[8];
til::CoordType len = 0;
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;
}
if (column + len > columnLimit)
{
// Unlike above with regular text we can't avoid padding the line with whitespace, because a string
// like "^A" is not a wide glyph, and so we cannot trick the terminal to insert the padding for us.
output.append(columnLimit - column, L' ');
column = columnLimit;
goto outerLoopExit;
}
output.append(buf, len);
column += len;
if (column >= columnLimit)
{
goto outerLoopExit;
}
}
}
outerLoopExit:
return {
.offset = static_cast<size_t>(it - beg),
.column = column,
};
}
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);
}
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::CommandNumber:
popup.commandNumber.buffer.fill(' ');
popup.commandNumber.bufferSize = 0;
break;
case PopupKind::CommandList:
popup.commandList.top = -1;
popup.commandList.height = 10;
popup.commandList.selected = _history->LastDisplayed;
break;
default:
break;
}
}
catch (...)
{
LOG_CAUGHT_EXCEPTION();
_popupsDone();
}
// Dismisses all current popups at once. Right now we don't need support for just dismissing the topmost popup.
// In fact, there's only a single situation right now where there can be >1 popup:
// Pressing F7 followed by F9 (CommandNumber on top of CommandList).
void COOKED_READ_DATA::_popupsDone()
{
_popups.clear();
_dirty = true;
}
void COOKED_READ_DATA::_popupHandleInput(wchar_t wch, uint16_t vkey, DWORD modifiers)
{
if (_popups.empty())
{
assert(false); // Don't call this function.
return;
}
auto& popup = _popups.back();
switch (popup.kind)
{
case PopupKind::CopyToChar:
_popupHandleCopyToCharInput(popup, wch, vkey, modifiers);
break;
case PopupKind::CopyFromChar:
_popupHandleCopyFromCharInput(popup, wch, vkey, modifiers);
break;
case PopupKind::CommandNumber:
_popupHandleCommandNumberInput(popup, wch, vkey, modifiers);
break;
case PopupKind::CommandList:
_popupHandleCommandListInput(popup, wch, vkey, modifiers);
break;
default:
break;
}
}
void COOKED_READ_DATA::_popupHandleCopyToCharInput(Popup& /*popup*/, const wchar_t wch, const uint16_t vkey, const DWORD /*modifiers*/)
{
if (vkey)
{
if (vkey == VK_ESCAPE)
{
_popupsDone();
}
}
else
{
// See PopupKind::CopyToChar for more information about this code.
const auto cmd = _history->GetLastCommand();
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 _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;
_replace(cursor, count, cmd.data() + cursor, count);
}
_popupsDone();
}
}
void COOKED_READ_DATA::_popupHandleCopyFromCharInput(Popup& /*popup*/, const wchar_t wch, const uint16_t vkey, const DWORD /*modifiers*/)
{
if (vkey)
{
if (vkey == VK_ESCAPE)
{
_popupsDone();
}
}
else
{
// See PopupKind::CopyFromChar for more information about this code.
const auto cursor = _bufferCursor;
auto idx = _buffer.find(wch, cursor);
idx = std::min(idx, _buffer.size());
_replace(cursor, idx - cursor, nullptr, 0);
_popupsDone();
}
}
void COOKED_READ_DATA::_popupHandleCommandNumberInput(Popup& popup, const wchar_t wch, const uint16_t vkey, const DWORD /*modifiers*/)
{
if (vkey)
{
if (vkey == VK_ESCAPE)
{
_popupsDone();
}
}
else
{
if (wch == UNICODE_CARRIAGERETURN)
{
popup.commandNumber.buffer[popup.commandNumber.bufferSize++] = L'\0';
_replace(_history->RetrieveNth(std::stoi(popup.commandNumber.buffer.data())));
_popupsDone();
}
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)
{
if (popup.commandNumber.bufferSize > 0)
{
popup.commandNumber.buffer[--popup.commandNumber.bufferSize] = L' ';
_dirty = true;
}
}
}
}
void COOKED_READ_DATA::_popupHandleCommandListInput(Popup& popup, const wchar_t wch, const uint16_t vkey, const DWORD modifiers)
{
auto& cl = popup.commandList;
if (wch == UNICODE_CARRIAGERETURN)
{
_replace(_history->RetrieveNth(cl.selected));
_popupsDone();
_handleChar(UNICODE_CARRIAGERETURN, modifiers);
return;
}
switch (vkey)
{
case VK_ESCAPE:
_popupsDone();
return;
case VK_F9:
_popupPush(PopupKind::CommandNumber);
return;
case VK_DELETE:
_history->Remove(cl.selected);
if (_history->GetNumberOfCommands() <= 0)
{
_popupsDone();
return;
}
break;
case VK_LEFT:
case VK_RIGHT:
_replace(_history->RetrieveNth(cl.selected));
_popupsDone();
return;
case VK_UP:
if (WI_IsFlagSet(modifiers, SHIFT_PRESSED))
{
_history->Swap(cl.selected, cl.selected - 1);
}
// _popupDrawCommandList() clamps all values to valid ranges in `cl`.
cl.selected--;
break;
case VK_DOWN:
if (WI_IsFlagSet(modifiers, SHIFT_PRESSED))
{
_history->Swap(cl.selected, cl.selected + 1);
}
// _popupDrawCommandList() clamps all values to valid ranges in `cl`.
cl.selected++;
break;
case VK_HOME:
cl.selected = 0;
break;
case VK_END:
// _popupDrawCommandList() clamps all values to valid ranges in `cl`.
cl.selected = INT_MAX;
break;
case VK_PRIOR:
// _popupDrawCommandList() clamps all values to valid ranges in `cl`.
cl.selected -= cl.height;
break;
case VK_NEXT:
// _popupDrawCommandList() clamps all values to valid ranges in `cl`.
cl.selected += cl.height;
break;
default:
return;
}
_dirty = true;
}
void COOKED_READ_DATA::_popupDrawPrompt(std::vector<Line>& lines, const til::CoordType width, const UINT id, const std::wstring_view& prefix, const std::wstring_view& suffix) const
{
std::wstring str;
str.append(prefix);
_LoadString(id, str);
str.append(suffix);
std::wstring line;
line.append(L"\x1b[#{\x1b[K");
_appendPopupAttr(line);
const auto res = _layoutLine(line, str, 0, 0, width);
line.append(L"\x1b[m\x1b[#}");
lines.emplace_back(std::move(line), 0, 0, res.column);
}
void COOKED_READ_DATA::_popupDrawCommandList(std::vector<Line>& lines, const til::size size, Popup& popup) const
{
assert(popup.kind == PopupKind::CommandList);
auto& cl = popup.commandList;
const auto historySize = _history->GetNumberOfCommands();
const auto indexWidth = gsl::narrow_cast<til::CoordType>(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)
{
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[#{\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);
if (selected)
{
line.push_back(L'');
}
else
{
line.append(L"\x1b[m\x1b[#} ");
}
fmt::format_to(std::back_inserter(line), FMT_COMPILE(L"{:{}}: "), index, indexWidth);
_layoutLine(line, str, 0, indexWidth + 4, size.width);
if (selected)
{
line.append(L"\x1b[m\x1b[#}");
}
line.append(L"\r\n");
lines.emplace_back(std::move(line), 0, 0, size.width);
}
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);
}
}