diff --git a/PSReadLine/Render.cs b/PSReadLine/Render.cs index df6c9e9a..a0f4757a 100644 --- a/PSReadLine/Render.cs +++ b/PSReadLine/Render.cs @@ -28,6 +28,15 @@ public override string ToString() public partial class PSConsoleReadLine { + struct LineInfoForRendering + { + public int CurrentLogicalLineIndex; + public int CurrentPhysicalLineCount; + public int PreviousLogicalLineIndex; + public int PreviousPhysicalLineCount; + public int PseudoPhysicalLineOffset; + } + struct RenderedLineData { public string line; @@ -340,135 +349,322 @@ void MaybeEmphasize(int i, string currColor) return currentLogicalLine + 1; } - private void ReallyRender(RenderData renderData, string defaultColor) + /// + /// Flip the color on the prompt if the error state changed. + /// + /// + /// A bool value indicating whether we need to flip the color, + /// namely whether we moved cursor to the initial position. + /// + private bool RenderErrorPrompt(RenderData renderData, string defaultColor) { - string activeColor = ""; + // We may need to flip the color on the prompt if the error state changed. - void UpdateColorsIfNecessary(string newColor) + int bufferWidth = _console.BufferWidth; + string promptText = _options.PromptText; + + if (string.IsNullOrEmpty(promptText) || _initialY < 0) { - if (!object.ReferenceEquals(newColor, activeColor)) + // No need to flip the prompt color if either the error prompt is not defined + // or the initial cursor point has already been scrolled off the buffer. + return false; + } + + renderData.errorPrompt = (_parseErrors != null && _parseErrors.Length > 0); + if (renderData.errorPrompt == _previousRender.errorPrompt) + { + // No need to flip the prompt color if the error state didn't change. + return false; + } + + // We need to update the prompt + _console.SetCursorPosition(_initialX, _initialY); + + // promptBufferCells is the number of visible characters in the prompt + int promptBufferCells = LengthInBufferCells(promptText); + bool renderErrorPrompt = false; + + if (_console.CursorLeft >= promptBufferCells) + { + renderErrorPrompt = true; + _console.CursorLeft -= promptBufferCells; + } + else + { + // The 'CursorLeft' could be less than error-prompt-cell-length in one of the following 3 cases: + // 1. console buffer was resized, which causes the initial cursor to appear on the next line; + // 2. prompt string gets longer (e.g. by 'cd' into nested folders), which causes the line to be wrapped to the next line; + // 3. the prompt function was changed, which causes the new prompt string is shorter than the error prompt. + // Here, we always assume it's the case 1 or 2, and wrap back to the previous line to change the error prompt color. + // In case of case 3, the rendering would be off, but it's more of a user error because the prompt is changed without + // updating 'PromptText' with 'Set-PSReadLineOption'. + + int diffs = promptBufferCells - _console.CursorLeft; + int newX = bufferWidth - diffs % bufferWidth; + int newY = _initialY - diffs / bufferWidth - 1; + + // newY could be less than 0 if 'PromptText' is manually set to be a long string. + if (newY >= 0) { - _console.Write(newColor); - activeColor = newColor; + renderErrorPrompt = true; + _console.SetCursorPosition(newX, newY); } } - // TODO: avoid writing everything. + if (renderErrorPrompt) + { + string color = renderData.errorPrompt ? _options._errorColor : defaultColor; + if (renderData.errorPrompt && promptBufferCells != promptText.Length) + { + promptText = promptText.Substring(promptText.Length - promptBufferCells); + } + _console.Write(color); + _console.Write(promptText); + _console.Write("\x1b[0m"); + } - var bufferWidth = _console.BufferWidth; - var bufferHeight = _console.BufferHeight; + return true; + } - // In case the buffer was resized - RecomputeInitialCoords(); - renderData.bufferWidth = bufferWidth; - renderData.bufferHeight = bufferHeight; + /// + /// Given the length of a logical line, calculate the number of physical lines it takes to render + /// the logical line on the console. + /// + private int PhysicalLineCount(int columns, bool isFirstLogicalLine, out int lenLastPhysicalLine) + { + int cnt = 1; + int bufferWidth = _console.BufferWidth; - // Move the cursor to where we started, but make cursor invisible while we're rendering. - _console.CursorVisible = false; - _console.SetCursorPosition(_initialX, _initialY); + if (isFirstLogicalLine) + { + // The first logical line has the user prompt that we don't touch + // (except where we turn part to red, but we've finished that + // before getting here.) + var maxFirstLine = bufferWidth - _initialX; + if (columns > maxFirstLine) + { + cnt += 1; + columns -= maxFirstLine; + } + else + { + lenLastPhysicalLine = columns; + return 1; + } + } - // Possibly need to flip the color on the prompt if the error state changed. - var promptText = _options.PromptText; - if (!string.IsNullOrEmpty(promptText)) + lenLastPhysicalLine = columns % bufferWidth; + if (lenLastPhysicalLine == 0) { - renderData.errorPrompt = (_parseErrors != null && _parseErrors.Length > 0); - if (renderData.errorPrompt != _previousRender.errorPrompt) + // Handle the last column when the columns is equal to n * bufferWidth + // where n >= 1 integers + lenLastPhysicalLine = bufferWidth; + return cnt - 1 + columns / bufferWidth; + } + + return cnt + columns / bufferWidth; + } + + /// + /// We avoid re-rendering everything while editing if it's possible. + /// This method attempts to find the first changed logical line and move the cursor to the right position for the subsequent rendering. + /// + private void CalculateWhereAndWhatToRender(bool cursorMovedToInitialPos, RenderData renderData, out LineInfoForRendering lineInfoForRendering) + { + int bufferWidth = _console.BufferWidth; + int bufferHeight = _console.BufferHeight; + + RenderedLineData[] previousRenderLines = _previousRender.lines; + int previousLogicalLine = 0; + int previousPhysicalLine = 0; + + RenderedLineData[] renderLines = renderData.lines; + int logicalLine = 0; + int physicalLine = 0; + int pseudoPhysicalLineOffset = 0; + + bool hasToWriteAll = true; + + if (renderLines.Length > 1) + { + // There are multiple logical lines, so it's possible the first N logical lines are not affected by the user's editing, + // in which case, we can skip rendering until reaching the first changed logical line. + + int minLinesLength = previousRenderLines.Length; + int linesToCheck = -1; + + if (renderLines.Length < previousRenderLines.Length) { - // We need to update the prompt + minLinesLength = renderLines.Length; - // promptBufferCells is the number of visible characters in the prompt - int promptBufferCells = LengthInBufferCells(promptText); - bool renderErrorPrompt = false; + // When the initial cursor position has been scrolled off the buffer, it's possible the editing deletes some texts and + // potentially causes the final cursor position to be off the buffer as well. In this case, we should start rendering + // from the logical line where the cursor is supposed to be moved to eventually. + // Here we check for this situation, and calculate the physical line count to check later if we are in this situation. - if (_console.CursorLeft >= promptBufferCells) - { - renderErrorPrompt = true; - _console.CursorLeft -= promptBufferCells; - } - else + if (_initialY < 0) { - // The 'CursorLeft' could be less than error-prompt-cell-length in one of the following 3 cases: - // 1. console buffer was resized, which causes the initial cursor to appear on the next line; - // 2. prompt string gets longer (e.g. by 'cd' into nested folders), which causes the line to be wrapped to the next line; - // 3. the prompt function was changed, which causes the new prompt string is shorter than the error prompt. - // Here, we always assume it's the case 1 or 2, and wrap back to the previous line to change the error prompt color. - // In case of case 3, the rendering would be off, but it's more of a user error because the prompt is changed without - // updating 'PromptText' with 'Set-PSReadLineOption'. - - int diffs = promptBufferCells - _console.CursorLeft; - int newX = bufferWidth - diffs % bufferWidth; - int newY = _initialY - diffs / bufferWidth - 1; - - // newY could be less than 0 if 'PromptText' is manually set to be a long string. - if (newY >= 0) + int y = ConvertOffsetToPoint(_current).Y; + if (y < 0) { - renderErrorPrompt = true; - _console.SetCursorPosition(newX, newY); + // Number of physical lines from the initial row to the row where the cursor is supposed to be set at. + linesToCheck = y - _initialY + 1; } } + } - if (renderErrorPrompt) + // Find the first logical line that was changed. + for (; logicalLine < minLinesLength; logicalLine++) + { + // Found the first different logical line? Break out the loop. + if (renderLines[logicalLine].line != previousRenderLines[logicalLine].line) { break; } + + int count = PhysicalLineCount(renderLines[logicalLine].columns, logicalLine == 0, out _); + physicalLine += count; + + if (linesToCheck < 0) { - var color = renderData.errorPrompt ? _options._errorColor : defaultColor; - if (renderData.errorPrompt && promptBufferCells != promptText.Length) - promptText = promptText.Substring(promptText.Length - promptBufferCells); - UpdateColorsIfNecessary(color); - _console.Write(promptText); - _console.Write("\x1b[0m"); + continue; + } + else if (physicalLine >= linesToCheck) + { + physicalLine -= count; + break; } } - } - int PhysicalLineCount(int columns, bool isFirstLogicalLine, out int lenLastPhysicalLine) - { - int cnt = 1; - if (isFirstLogicalLine) + if (logicalLine > 0) { - // The first logical line has the user prompt that we don't touch - // (except where we turn part to red, but we've finished that - // before getting here.) - var maxFirstLine = bufferWidth - _initialX; - if (columns > maxFirstLine) + // Some logical lines at the top were not affected by the editing. + // We only need to write starting from the first changed logical line. + hasToWriteAll = false; + previousLogicalLine = logicalLine; + previousPhysicalLine = physicalLine; + + var newTop = _initialY + physicalLine; + if (newTop == bufferHeight) { - cnt += 1; - columns -= maxFirstLine; + if (logicalLine < renderLines.Length) + { + // This could happen when adding a new line in the end of the very last line. + // In this case, we scroll up by writing out a new line. + _console.SetCursorPosition(left: bufferWidth - 1, top: bufferHeight - 1); + _console.Write("\n"); + } + + // It might happen that 'logicalLine == renderLines.Length'. This means the current + // logical lines to be rendered are exactly the same the the previous logical lines. + // No need to do anything in this case, as we don't need to render anything. } else { - lenLastPhysicalLine = columns; - return 1; + // For the logical line that we will start to re-render from, it's possible that + // 1. the whole logical line had already been scrolled up-off the buffer. This could happen when you backward delete characters + // on the first line in buffer and cause the current line to be folded to the previous line. + // 2. the logical line spans on multiple physical lines and the top a few physical lines had already been scrolled off the buffer. + // This could happen when you edit on the top a few physical lines in the buffer, which belong to a longer logical line. + // Either of them will cause 'newTop' to be less than 0. + if (newTop < 0) + { + // In this case, we will render the whole logical line starting from the upper-left-most point of the window. + // By doing this, we are essentially adding a few pseudo physical lines (the physical lines that belong to the logical line but + // had been scrolled off the buffer would be re-rendered). So, update 'physicalLine'. + pseudoPhysicalLineOffset = 0 - newTop; + physicalLine += pseudoPhysicalLineOffset; + newTop = 0; + } + + _console.SetCursorPosition(left: 0, top: newTop); } } + } - lenLastPhysicalLine = columns % bufferWidth; - if (lenLastPhysicalLine == 0) + if (hasToWriteAll && !cursorMovedToInitialPos) + { + // The editing was in the first logical line. We have to write everything in this case. + // Move the cursor to the initial position if we haven't done so. + if (_initialY < 0) { - // Handle the last column when the columns is equal to n * bufferWidth - // where n >= 1 integers - lenLastPhysicalLine = bufferWidth; - return cnt - 1 + columns / bufferWidth; + // The prompt had been scrolled up-off the buffer. Now we are about to render from the very + // beginning, so we clear the screen and invoke/print the prompt line. + _console.Write("\x1b[2J"); + _console.SetCursorPosition(0, _console.WindowTop); + + string newPrompt = GetPrompt(); + if (!string.IsNullOrEmpty(newPrompt)) + { + _console.Write(newPrompt); + } + + _initialX = _console.CursorLeft; + _initialY = _console.CursorTop; + _previousRender = _initialPrevRender; + } + else + { + _console.SetCursorPosition(_initialX, _initialY); } + } + + lineInfoForRendering = default; + lineInfoForRendering.CurrentLogicalLineIndex = logicalLine; + lineInfoForRendering.CurrentPhysicalLineCount = physicalLine; + lineInfoForRendering.PreviousLogicalLineIndex = previousLogicalLine; + lineInfoForRendering.PreviousPhysicalLineCount = previousPhysicalLine; + lineInfoForRendering.PseudoPhysicalLineOffset = pseudoPhysicalLineOffset; + } + + private void ReallyRender(RenderData renderData, string defaultColor) + { + string activeColor = ""; + int bufferWidth = _console.BufferWidth; + int bufferHeight = _console.BufferHeight; - return cnt + columns / bufferWidth; + void UpdateColorsIfNecessary(string newColor) + { + if (!object.ReferenceEquals(newColor, activeColor)) + { + _console.Write(newColor); + activeColor = newColor; + } } - var previousRenderLines = _previousRender.lines; - var previousLogicalLine = 0; - var previousPhysicalLine = 0; + // In case the buffer was resized + RecomputeInitialCoords(); + renderData.bufferWidth = bufferWidth; + renderData.bufferHeight = bufferHeight; + + // Make cursor invisible while we're rendering. + _console.CursorVisible = false; - var renderLines = renderData.lines; - var logicalLine = 0; - var physicalLine = 0; - var lenPrevLastLine = 0; + // Change the prompt color if the parsing error state changed. + bool cursorMovedToInitialPos = RenderErrorPrompt(renderData, defaultColor); + + // Calculate what to render and where to start the rendering. + LineInfoForRendering lineInfoForRendering; + CalculateWhereAndWhatToRender(cursorMovedToInitialPos, renderData, out lineInfoForRendering); + + RenderedLineData[] previousRenderLines = _previousRender.lines; + int previousLogicalLine = lineInfoForRendering.PreviousLogicalLineIndex; + int previousPhysicalLine = lineInfoForRendering.PreviousPhysicalLineCount; + + RenderedLineData[] renderLines = renderData.lines; + int logicalLine = lineInfoForRendering.CurrentLogicalLineIndex; + int physicalLine = lineInfoForRendering.CurrentPhysicalLineCount; + int pseudoPhysicalLineOffset = lineInfoForRendering.PseudoPhysicalLineOffset; + + int lenPrevLastLine = 0; + int logicalLineStartIndex = logicalLine; + int physicalLineStartCount = physicalLine; for (; logicalLine < renderLines.Length; logicalLine++) { - if (logicalLine != 0) _console.Write("\n"); + if (logicalLine != logicalLineStartIndex) _console.Write("\n"); var lineData = renderLines[logicalLine]; _console.Write(lineData.line); - physicalLine += PhysicalLineCount(lineData.columns, logicalLine == 0, out var lenLastLine); + physicalLine += PhysicalLineCount(lineData.columns, logicalLine == 0, out int lenLastLine); // Find the previous logical line (if any) that would have rendered // the current physical line because we may need to clear it. @@ -503,7 +699,7 @@ int PhysicalLineCount(int columns, bool isFirstLogicalLine, out int lenLastPhysi // need to clear to the end of the line. if (lenLastLine < bufferWidth) { - lenToClear = bufferWidth - (lenLastLine % bufferWidth); + lenToClear = bufferWidth - lenLastLine; if (physicalLine == 1) lenToClear -= _initialX; } @@ -518,27 +714,34 @@ int PhysicalLineCount(int columns, bool isFirstLogicalLine, out int lenLastPhysi UpdateColorsIfNecessary(defaultColor); - while (previousPhysicalLine > physicalLine) + // The last logical line is shorter than our previous render? Clear them. + for (int currentLines = physicalLine; currentLines < previousPhysicalLine;) { - _console.SetCursorPosition(0, _initialY + physicalLine); + _console.SetCursorPosition(0, _initialY + currentLines); - physicalLine += 1; - var lenToClear = physicalLine == previousPhysicalLine ? lenPrevLastLine : bufferWidth; + currentLines++; + var lenToClear = currentLines == previousPhysicalLine ? lenPrevLastLine : bufferWidth; if (lenToClear > 0) { _console.Write(Spaces(lenToClear)); } } - // Fewer lines than our last render? Clear them. + // Fewer logical lines than our previous render? Clear them. for (; previousLogicalLine < previousRenderLines.Length; previousLogicalLine++) { - _console.Write("\n"); + // No need to write new line if all we need is to clear the extra previous render. + if (logicalLineStartIndex < renderLines.Length) { _console.Write("\n"); } _console.Write(Spaces(previousRenderLines[previousLogicalLine].columns)); } + // Preserve the current render data. _previousRender = renderData; + // If we counted pseudo physical lines, deduct them to get the real physical line counts + // before updating '_initialY'. + physicalLine -= pseudoPhysicalLineOffset; + // Reset the colors after we've finished all our rendering. _console.Write("\x1b[0m"); @@ -547,6 +750,26 @@ int PhysicalLineCount(int columns, bool isFirstLogicalLine, out int lenLastPhysi // We had to scroll to render everything, update _initialY _initialY = bufferHeight - physicalLine; } + else if (pseudoPhysicalLineOffset > 0) + { + // When we rewrote a logical line (or part of a logical line) that had previously been scrolled up-off + // the buffer (fully or partially), we need to adjust '_initialY' if the changes to that logical line + // don't result in the same number of physical lines to be scrolled up-off the buffer. + + // Calculate the total number of physical lines starting from the logical line we re-wrote. + int physicalLinesStartingFromTheRewrittenLogicalLine = + physicalLine - (physicalLineStartCount - pseudoPhysicalLineOffset); + + Debug.Assert( + bufferHeight + pseudoPhysicalLineOffset >= physicalLinesStartingFromTheRewrittenLogicalLine, + "number of physical lines starting from the first changed logical line should be no more than the buffer height plus the pseudo lines we added."); + + int offset = physicalLinesStartingFromTheRewrittenLogicalLine > bufferHeight + ? pseudoPhysicalLineOffset - (physicalLinesStartingFromTheRewrittenLogicalLine - bufferHeight) + : pseudoPhysicalLineOffset; + + _initialY += offset; + } // Calculate the coord to place the cursor for the next input. var point = ConvertOffsetToPoint(_current); @@ -562,6 +785,24 @@ int PhysicalLineCount(int columns, bool isFirstLogicalLine, out int lenLastPhysi _initialY -= 1; point.Y -= 1; } + else if (point.Y == -1) + { + // This could only happen in two cases: + // + // 1. when you are adding characters to the first line in the buffer (top = 0) to make the logical line + // wrap to one extra physical line. This would cause the buffer to scroll up and push the line being + // edited up-off the buffer. + // 2. when you are deleting characters backwards from the first line in the buffer without changing the + // number of physical lines (either editing the same logical line or causing the current logical line + // to merge in the previous but still span to the current physical line). The cursor is supposed to + // appear in the previous line (which is off the buffer). + // + // In these case, we move the cursor to the upper-left-most position of the window, where it's closest to + // the previous editing position, and update '_current' appropriately. + + _current += (bufferWidth - point.X); + point.X = point.Y = 0; + } _console.SetCursorPosition(point.X, point.Y); _console.CursorVisible = true; @@ -720,6 +961,12 @@ private void MoveCursor(int newCursor) _previousRender.bufferHeight = _console.BufferHeight; var point = ConvertOffsetToPoint(newCursor); + if (point.Y < 0) + { + Ding(); + return; + } + _console.SetCursorPosition(point.X, point.Y); _current = newCursor; } @@ -827,7 +1074,13 @@ private int ConvertLineAndColumnToOffset(Point point) { x = size; } - y += 1; + + // If the next character is newline, let the next loop + // iteration increment y and adjust x. + if (!(offset + 1 < _buffer.Length && _buffer[offset + 1] == '\n')) + { + y += 1; + } } } }