Fixes 4191 - Rewrite WindowsOutput (#4193)

* Consider width2 chars that are not IsBmp

* Apply same fix in WindowsDriver

* Explicitly use type of local variable

* Revert changes to WindowsDriver

* Assume we are running in a terminal that supports true color by default unless user explicitly forces 16

* Switch to SetAttribute and WriteConsole instead of WriteConsoleOutput for 16 color mode

* Fix some cursor issues (WIP)

* Remove concept of 'dirty rows' from v2 as its never actually used

* Remove damageRegion as it does nothing

* Make string builder to console writing simpler

* Radically simplify Write method

* Simplify conditional logic

* Simplify restoring cursor position

* Reference local variable for console buffer

* Reduce calls to ConsoleWrite by accumulating till attribute changes

* When resizing v2 16 color mode on windows, recreate the back buffer to match its size

* Fixes for VTS enabled

* Fix _lastSize never being assigned

* Fixes VTS for Force16Colors

* Fixes force16Colors in VTS

* Fixes escape sequences always echoing in non-VTS

* Force Force16Colors in non-VTS. It have a bug in adding a newline in the last line

* WIP Add base class for NetOutput

* Abstract away how we change attribute

* WIP - Make WindowsOutput use base class

* WIP working to fix set cursor position

* Remove commented out code

* Fixes legacy output mode

* Fixes size with no alt buffer supported on VTS and size restore after maximized.

* Fix set cursor which also fixes the broken surrogate pairs

* Add force parameter

* Fixes an issue that only happens with Windows Terminal when paste surrogate pairs by press Ctrl+V

* In Windows escape sequences must be sent during the lifetime of the console which is created in input handle

* Ensure flush the input buffer before reset the console

* Flush input buffer before reset console in v2win

* Fixes issue in v2net not being refreshing the menu bar at start

* Only force layout and draw on size changed.

* Fix v2net issue not draw first line by forcing set cursor position

* Set _lastCursorPosition nullable and remove bool force from set cursor position

* Remove force parameter

* Cleanup code

---------

Co-authored-by: BDisp <bd.bdisp@gmail.com>
This commit is contained in:
Thomas Nind
2025-08-31 16:41:09 +01:00
committed by GitHub
parent a3399776b7
commit ab49fb8b43
11 changed files with 654 additions and 449 deletions

View File

@@ -96,6 +96,11 @@ internal class NetWinVTConsole
public void Cleanup ()
{
if (!FlushConsoleInputBuffer (_inputHandle))
{
throw new ApplicationException ($"Failed to flush input buffer, error code: {GetLastError ()}.");
}
if (!SetConsoleMode (_inputHandle, _originalInputConsoleMode))
{
throw new ApplicationException ($"Failed to restore input console mode, error code: {GetLastError ()}.");
@@ -123,4 +128,7 @@ internal class NetWinVTConsole
[DllImport ("kernel32.dll")]
private static extern bool SetConsoleMode (nint hConsoleHandle, uint dwMode);
[DllImport ("kernel32.dll", SetLastError = true)]
private static extern bool FlushConsoleInputBuffer (nint hConsoleInput);
}

View File

@@ -9,12 +9,6 @@ namespace Terminal.Gui.Drivers;
/// </summary>
public interface IOutputBuffer
{
/// <summary>
/// As performance is a concern, we keep track of the dirty lines and only refresh those.
/// This is in addition to the dirty flag on each cell.
/// </summary>
public bool [] DirtyLines { get; }
/// <summary>
/// The contents of the application output. The driver outputs this buffer to the terminal when UpdateScreen is called.
/// </summary>

View File

@@ -162,4 +162,43 @@ public abstract class InputProcessor<T> : IInputProcessor
/// </summary>
/// <param name="input"></param>
protected abstract void ProcessAfterParsing (T input);
internal char _highSurrogate = '\0';
internal bool IsValidInput (Key key, out Key result)
{
result = key;
if (char.IsHighSurrogate ((char)key))
{
_highSurrogate = (char)key;
return false;
}
if (_highSurrogate > 0 && char.IsLowSurrogate ((char)key))
{
result = (KeyCode)new Rune (_highSurrogate, (char)key).Value;
_highSurrogate = '\0';
return true;
}
if (char.IsSurrogate ((char)key))
{
return false;
}
if (_highSurrogate > 0)
{
_highSurrogate = '\0';
}
if (key.KeyCode == 0)
{
return false;
}
return true;
}
}

View File

@@ -25,7 +25,6 @@ internal class MainLoopCoordinator<T> : IMainLoopCoordinator
private ConsoleDriverFacade<T> _facade;
private Task _inputTask;
private readonly ITimedEvents _timedEvents;
private readonly bool _isWindowsTerminal;
private readonly SemaphoreSlim _startupSemaphore = new (0, 1);
@@ -61,7 +60,6 @@ internal class MainLoopCoordinator<T> : IMainLoopCoordinator
_inputProcessor = inputProcessor;
_outputFactory = outputFactory;
_loop = loop;
_isWindowsTerminal = Environment.GetEnvironmentVariable ("WT_SESSION") is { } || Environment.GetEnvironmentVariable ("VSAPPIDNAME") != null;
}
/// <summary>
@@ -162,11 +160,6 @@ internal class MainLoopCoordinator<T> : IMainLoopCoordinator
_loop.AnsiRequestScheduler,
_loop.WindowSizeMonitor);
if (!_isWindowsTerminal)
{
Application.Force16Colors = _facade.Force16Colors = true;
}
Application.Driver = _facade;
_startupSemaphore.Release ();

View File

@@ -40,6 +40,12 @@ public class NetInput : ConsoleInput<ConsoleKeyInfo>, INetInput
}
}
//Enable alternative screen buffer.
Console.Out.Write (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll);
//Set cursor key to application.
Console.Out.Write (EscSeqUtils.CSI_HideCursor);
Console.Out.Write (EscSeqUtils.CSI_EnableMouseEvents);
Console.TreatControlCAsInput = true;
}
@@ -68,8 +74,14 @@ public class NetInput : ConsoleInput<ConsoleKeyInfo>, INetInput
public override void Dispose ()
{
base.Dispose ();
_adjustConsole?.Cleanup ();
Console.Out.Write (EscSeqUtils.CSI_DisableMouseEvents);
//Disable alternative screen buffer.
Console.Out.Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll);
//Set cursor key to cursor.
Console.Out.Write (EscSeqUtils.CSI_ShowCursor);
_adjustConsole?.Cleanup ();
}
}

View File

@@ -41,8 +41,13 @@ public class NetInputProcessor : InputProcessor<ConsoleKeyInfo>
protected override void ProcessAfterParsing (ConsoleKeyInfo input)
{
var key = KeyConverter.ToKey (input);
OnKeyDown (key);
OnKeyUp (key);
// If the key is not valid, we don't want to raise any events.
if (IsValidInput (key, out key))
{
OnKeyDown (key);
OnKeyUp (key);
}
}
/* For building test cases */

View File

@@ -6,15 +6,10 @@ namespace Terminal.Gui.Drivers;
/// Implementation of <see cref="IConsoleOutput"/> that uses native dotnet
/// methods e.g. <see cref="System.Console"/>
/// </summary>
public class NetOutput : IConsoleOutput
public class NetOutput : OutputBase, IConsoleOutput
{
private readonly bool _isWinPlatform;
private CursorVisibility? _cachedCursorVisibility;
// Last text style used, for updating style with EscSeqUtils.CSI_AppendTextStyleChange().
private TextStyle _redrawTextStyle = TextStyle.None;
/// <summary>
/// Creates a new instance of the <see cref="NetOutput"/> class.
/// </summary>
@@ -30,176 +25,10 @@ public class NetOutput : IConsoleOutput
{
_isWinPlatform = true;
}
//Enable alternative screen buffer.
Console.Out.Write (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll);
//Set cursor key to application.
Console.Out.Write (EscSeqUtils.CSI_HideCursor);
}
/// <inheritdoc/>
public void Write (ReadOnlySpan<char> text)
{
Console.Out.Write (text);
}
/// <inheritdoc/>
public void Write (IOutputBuffer buffer)
{
if (ConsoleDriver.RunningUnitTests)
{
return;
}
if (Console.WindowHeight < 1
|| buffer.Contents.Length != buffer.Rows * buffer.Cols
|| buffer.Rows != Console.WindowHeight)
{
// return;
}
var top = 0;
var left = 0;
int rows = buffer.Rows;
int cols = buffer.Cols;
var output = new StringBuilder ();
Attribute? redrawAttr = null;
int lastCol = -1;
CursorVisibility? savedVisibility = _cachedCursorVisibility;
SetCursorVisibility (CursorVisibility.Invisible);
const int maxCharsPerRune = 2;
Span<char> runeBuffer = stackalloc char[maxCharsPerRune];
for (int row = top; row < rows; row++)
{
if (Console.WindowHeight < 1)
{
return;
}
if (!buffer.DirtyLines [row])
{
continue;
}
if (!SetCursorPositionImpl (0, row))
{
return;
}
buffer.DirtyLines [row] = false;
output.Clear ();
for (int col = left; col < cols; col++)
{
lastCol = -1;
var outputWidth = 0;
for (; col < cols; col++)
{
if (!buffer.Contents [row, col].IsDirty)
{
if (output.Length > 0)
{
WriteToConsole (output, ref lastCol, row, ref outputWidth);
}
else if (lastCol == -1)
{
lastCol = col;
}
if (lastCol + 1 < cols)
{
lastCol++;
}
continue;
}
if (lastCol == -1)
{
lastCol = col;
}
Attribute attr = buffer.Contents [row, col].Attribute.Value;
// Performance: Only send the escape sequence if the attribute has changed.
if (attr != redrawAttr)
{
redrawAttr = attr;
EscSeqUtils.CSI_AppendForegroundColorRGB (
output,
attr.Foreground.R,
attr.Foreground.G,
attr.Foreground.B
);
EscSeqUtils.CSI_AppendBackgroundColorRGB (
output,
attr.Background.R,
attr.Background.G,
attr.Background.B
);
EscSeqUtils.CSI_AppendTextStyleChange (output, _redrawTextStyle, attr.Style);
_redrawTextStyle = attr.Style;
}
outputWidth++;
// Avoid Rune.ToString() by appending the rune chars.
Rune rune = buffer.Contents [row, col].Rune;
int runeCharsWritten = rune.EncodeToUtf16 (runeBuffer);
ReadOnlySpan<char> runeChars = runeBuffer[..runeCharsWritten];
output.Append (runeChars);
if (buffer.Contents [row, col].CombiningMarks.Count > 0)
{
// AtlasEngine does not support NON-NORMALIZED combining marks in a way
// compatible with the driver architecture. Any CMs (except in the first col)
// are correctly combined with the base char, but are ALSO treated as 1 column
// width codepoints E.g. `echo "[e`u{0301}`u{0301}]"` will output `[é ]`.
//
// For now, we just ignore the list of CMs.
//foreach (var combMark in Contents [row, col].CombiningMarks) {
// output.Append (combMark);
//}
// WriteToConsole (output, ref lastCol, row, ref outputWidth);
}
else if (rune.IsSurrogatePair () && rune.GetColumns () < 2)
{
WriteToConsole (output, ref lastCol, row, ref outputWidth);
SetCursorPositionImpl (col - 1, row);
}
buffer.Contents [row, col].IsDirty = false;
}
}
if (output.Length > 0)
{
SetCursorPositionImpl (lastCol, row);
Console.Out.Write (output);
}
}
foreach (SixelToRender s in Application.Sixel)
{
if (!string.IsNullOrWhiteSpace (s.SixelData))
{
SetCursorPositionImpl (s.ScreenPosition.X, s.ScreenPosition.Y);
Console.Out.Write (s.SixelData);
}
}
SetCursorVisibility (savedVisibility ?? CursorVisibility.Default);
_cachedCursorVisibility = savedVisibility;
}
public void Write (ReadOnlySpan<char> text) { Console.Out.Write (text); }
/// <inheritdoc/>
public Size GetWindowSize ()
@@ -213,23 +42,37 @@ public class NetOutput : IConsoleOutput
return new (Console.WindowWidth, Console.WindowHeight);
}
private void WriteToConsole (StringBuilder output, ref int lastCol, int row, ref int outputWidth)
{
SetCursorPositionImpl (lastCol, row);
Console.Out.Write (output);
output.Clear ();
lastCol += outputWidth;
outputWidth = 0;
}
/// <inheritdoc/>
public void SetCursorPosition (int col, int row) { SetCursorPositionImpl (col, row); }
private Point _lastCursorPosition;
private Point? _lastCursorPosition;
private bool SetCursorPositionImpl (int col, int row)
/// <inheritdoc/>
protected override void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle)
{
if (_lastCursorPosition.X == col && _lastCursorPosition.Y == row)
EscSeqUtils.CSI_AppendForegroundColorRGB (
output,
attr.Foreground.R,
attr.Foreground.G,
attr.Foreground.B
);
EscSeqUtils.CSI_AppendBackgroundColorRGB (
output,
attr.Background.R,
attr.Background.G,
attr.Background.B
);
EscSeqUtils.CSI_AppendTextStyleChange (output, redrawTextStyle, attr.Style);
}
/// <inheritdoc/>
protected override void Write (StringBuilder output) { Console.Out.Write (output); }
protected override bool SetCursorPositionImpl (int col, int row)
{
if (_lastCursorPosition is { } && _lastCursorPosition.Value.X == col && _lastCursorPosition.Value.Y == row)
{
return true;
}
@@ -259,21 +102,10 @@ public class NetOutput : IConsoleOutput
}
/// <inheritdoc/>
public void Dispose ()
{
Console.ResetColor ();
//Disable alternative screen buffer.
Console.Out.Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll);
//Set cursor key to cursor.
Console.Out.Write (EscSeqUtils.CSI_ShowCursor);
Console.Out.Close ();
}
public void Dispose () { }
/// <inheritdoc/>
public void SetCursorVisibility (CursorVisibility visibility)
public override void SetCursorVisibility (CursorVisibility visibility)
{
Console.Out.Write (visibility == CursorVisibility.Default ? EscSeqUtils.CSI_ShowCursor : EscSeqUtils.CSI_HideCursor);
}

View File

@@ -0,0 +1,163 @@
namespace Terminal.Gui.Drivers;
public abstract class OutputBase
{
private CursorVisibility? _cachedCursorVisibility;
// Last text style used, for updating style with EscSeqUtils.CSI_AppendTextStyleChange().
private TextStyle _redrawTextStyle = TextStyle.None;
/// <inheritdoc/>
public virtual void Write (IOutputBuffer buffer)
{
if (ConsoleDriver.RunningUnitTests)
{
return;
}
if (Console.WindowHeight < 1
|| buffer.Contents.Length != buffer.Rows * buffer.Cols
|| buffer.Rows != Console.WindowHeight)
{
// return;
}
var top = 0;
var left = 0;
int rows = buffer.Rows;
int cols = buffer.Cols;
var output = new StringBuilder ();
Attribute? redrawAttr = null;
int lastCol = -1;
CursorVisibility? savedVisibility = _cachedCursorVisibility;
SetCursorVisibility (CursorVisibility.Invisible);
const int maxCharsPerRune = 2;
Span<char> runeBuffer = stackalloc char [maxCharsPerRune];
for (int row = top; row < rows; row++)
{
if (Console.WindowHeight < 1)
{
return;
}
if (!SetCursorPositionImpl (0, row))
{
return;
}
output.Clear ();
for (int col = left; col < cols; col++)
{
lastCol = -1;
var outputWidth = 0;
for (; col < cols; col++)
{
if (!buffer.Contents [row, col].IsDirty)
{
if (output.Length > 0)
{
WriteToConsole (output, ref lastCol, row, ref outputWidth);
}
else if (lastCol == -1)
{
lastCol = col;
}
if (lastCol + 1 < cols)
{
lastCol++;
}
continue;
}
if (lastCol == -1)
{
lastCol = col;
}
Attribute attr = buffer.Contents [row, col].Attribute.Value;
// Performance: Only send the escape sequence if the attribute has changed.
if (attr != redrawAttr)
{
redrawAttr = attr;
AppendOrWriteAttribute (output, attr, _redrawTextStyle);
_redrawTextStyle = attr.Style;
}
outputWidth++;
// Avoid Rune.ToString() by appending the rune chars.
Rune rune = buffer.Contents [row, col].Rune;
int runeCharsWritten = rune.EncodeToUtf16 (runeBuffer);
ReadOnlySpan<char> runeChars = runeBuffer [..runeCharsWritten];
output.Append (runeChars);
if (buffer.Contents [row, col].CombiningMarks.Count > 0)
{
// AtlasEngine does not support NON-NORMALIZED combining marks in a way
// compatible with the driver architecture. Any CMs (except in the first col)
// are correctly combined with the base char, but are ALSO treated as 1 column
// width codepoints E.g. `echo "[e`u{0301}`u{0301}]"` will output `[é ]`.
//
// For now, we just ignore the list of CMs.
//foreach (var combMark in Contents [row, col].CombiningMarks) {
// output.Append (combMark);
//}
// WriteToConsole (output, ref lastCol, row, ref outputWidth);
}
else if (rune.IsSurrogatePair () && rune.GetColumns () < 2)
{
WriteToConsole (output, ref lastCol, row, ref outputWidth);
SetCursorPositionImpl (col - 1, row);
}
buffer.Contents [row, col].IsDirty = false;
}
}
if (output.Length > 0)
{
SetCursorPositionImpl (lastCol, row);
Write (output);
}
}
foreach (SixelToRender s in Application.Sixel)
{
if (!string.IsNullOrWhiteSpace (s.SixelData))
{
SetCursorPositionImpl (s.ScreenPosition.X, s.ScreenPosition.Y);
Console.Out.Write (s.SixelData);
}
}
SetCursorVisibility (savedVisibility ?? CursorVisibility.Default);
_cachedCursorVisibility = savedVisibility;
}
protected abstract void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle);
private void WriteToConsole (StringBuilder output, ref int lastCol, int row, ref int outputWidth)
{
SetCursorPositionImpl (lastCol, row);
Write (output);
output.Clear ();
lastCol += outputWidth;
outputWidth = 0;
}
protected abstract void Write (StringBuilder output);
protected abstract bool SetCursorPositionImpl (int screenPositionX, int screenPositionY);
public abstract void SetCursorVisibility (CursorVisibility visibility);
}

View File

@@ -4,7 +4,7 @@ using static Terminal.Gui.Drivers.WindowsConsole;
namespace Terminal.Gui.Drivers;
internal class WindowsInput : ConsoleInput<WindowsConsole.InputRecord>, IWindowsInput
internal class WindowsInput : ConsoleInput<InputRecord>, IWindowsInput
{
private readonly nint _inputHandle;
@@ -35,6 +35,9 @@ internal class WindowsInput : ConsoleInput<WindowsConsole.InputRecord>, IWindows
private readonly uint _originalConsoleMode;
[DllImport ("kernel32.dll", SetLastError = true)]
private static extern bool FlushConsoleInputBuffer (nint hConsoleInput);
public WindowsInput ()
{
Logging.Logger.LogInformation ($"Creating {nameof (WindowsInput)}");
@@ -50,16 +53,16 @@ internal class WindowsInput : ConsoleInput<WindowsConsole.InputRecord>, IWindows
_originalConsoleMode = v;
uint newConsoleMode = _originalConsoleMode;
newConsoleMode |= (uint)(WindowsConsole.ConsoleModes.EnableMouseInput | WindowsConsole.ConsoleModes.EnableExtendedFlags);
newConsoleMode &= ~(uint)WindowsConsole.ConsoleModes.EnableQuickEditMode;
newConsoleMode &= ~(uint)WindowsConsole.ConsoleModes.EnableProcessedInput;
newConsoleMode |= (uint)(ConsoleModes.EnableMouseInput | ConsoleModes.EnableExtendedFlags);
newConsoleMode &= ~(uint)ConsoleModes.EnableQuickEditMode;
newConsoleMode &= ~(uint)ConsoleModes.EnableProcessedInput;
SetConsoleMode (_inputHandle, newConsoleMode);
}
protected override bool Peek ()
{
const int bufferSize = 1; // We only need to check if there's at least one event
nint pRecord = Marshal.AllocHGlobal (Marshal.SizeOf<WindowsConsole.InputRecord> () * bufferSize);
nint pRecord = Marshal.AllocHGlobal (Marshal.SizeOf<InputRecord> () * bufferSize);
try
{
@@ -89,10 +92,10 @@ internal class WindowsInput : ConsoleInput<WindowsConsole.InputRecord>, IWindows
}
}
protected override IEnumerable<WindowsConsole.InputRecord> Read ()
protected override IEnumerable<InputRecord> Read ()
{
const int bufferSize = 1;
nint pRecord = Marshal.AllocHGlobal (Marshal.SizeOf<WindowsConsole.InputRecord> () * bufferSize);
nint pRecord = Marshal.AllocHGlobal (Marshal.SizeOf<InputRecord> () * bufferSize);
try
{
@@ -104,7 +107,7 @@ internal class WindowsInput : ConsoleInput<WindowsConsole.InputRecord>, IWindows
return numberEventsRead == 0
? []
: new [] { Marshal.PtrToStructure<WindowsConsole.InputRecord> (pRecord) };
: new [] { Marshal.PtrToStructure<InputRecord> (pRecord) };
}
catch (Exception)
{
@@ -123,6 +126,11 @@ internal class WindowsInput : ConsoleInput<WindowsConsole.InputRecord>, IWindows
return;
}
if (!FlushConsoleInputBuffer (_inputHandle))
{
throw new ApplicationException ($"Failed to flush input buffer, error code: {Marshal.GetLastWin32Error ()}.");
}
SetConsoleMode (_inputHandle, _originalConsoleMode);
}
}

View File

@@ -71,7 +71,8 @@ internal class WindowsInputProcessor : InputProcessor<InputRecord>
{
var key = KeyConverter.ToKey (input);
if (key != (Key)0)
// If the key is not valid, we don't want to raise any events.
if (IsValidInput (key, out key))
{
OnKeyDown (key!);
OnKeyUp (key!);
@@ -82,10 +83,29 @@ internal class WindowsInputProcessor : InputProcessor<InputRecord>
{
var mouseFlags = MouseFlags.None;
mouseFlags = UpdateMouseFlags (mouseFlags, e.ButtonState, WindowsConsole.ButtonState.Button1Pressed, MouseFlags.Button1Pressed, MouseFlags.Button1Released, 0);
mouseFlags = UpdateMouseFlags (mouseFlags, e.ButtonState, WindowsConsole.ButtonState.Button2Pressed, MouseFlags.Button2Pressed, MouseFlags.Button2Released, 1);
mouseFlags = UpdateMouseFlags (mouseFlags, e.ButtonState, WindowsConsole.ButtonState.Button4Pressed, MouseFlags.Button4Pressed, MouseFlags.Button4Released, 3);
mouseFlags = UpdateMouseFlags (
mouseFlags,
e.ButtonState,
WindowsConsole.ButtonState.Button1Pressed,
MouseFlags.Button1Pressed,
MouseFlags.Button1Released,
0);
mouseFlags = UpdateMouseFlags (
mouseFlags,
e.ButtonState,
WindowsConsole.ButtonState.Button2Pressed,
MouseFlags.Button2Pressed,
MouseFlags.Button2Released,
1);
mouseFlags = UpdateMouseFlags (
mouseFlags,
e.ButtonState,
WindowsConsole.ButtonState.Button4Pressed,
MouseFlags.Button4Pressed,
MouseFlags.Button4Released,
3);
// Deal with button 3 separately because it is considered same as 'rightmost button'
if (e.ButtonState.HasFlag (WindowsConsole.ButtonState.Button3Pressed) || e.ButtonState.HasFlag (WindowsConsole.ButtonState.RightmostButtonPressed))

View File

@@ -1,13 +1,12 @@
#nullable enable
using System.Buffers;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.Logging;
using static Terminal.Gui.Drivers.WindowsConsole;
namespace Terminal.Gui.Drivers;
internal partial class WindowsOutput : IConsoleOutput
internal partial class WindowsOutput : OutputBase, IConsoleOutput
{
[LibraryImport ("kernel32.dll", EntryPoint = "WriteConsoleW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs (UnmanagedType.Bool)]
@@ -19,11 +18,15 @@ internal partial class WindowsOutput : IConsoleOutput
nint lpReserved
);
[DllImport ("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle (nint handle);
[LibraryImport ("kernel32.dll", SetLastError = true)]
private static partial nint GetStdHandle (int nStdHandle);
[DllImport ("kernel32.dll", SetLastError = true)]
private static extern nint CreateConsoleScreenBuffer (
[LibraryImport ("kernel32.dll", SetLastError = true)]
[return: MarshalAs (UnmanagedType.Bool)]
private static partial bool CloseHandle (nint handle);
[LibraryImport ("kernel32.dll", SetLastError = true)]
private static partial nint CreateConsoleScreenBuffer (
DesiredAccess dwDesiredAccess,
ShareMode dwShareMode,
nint secutiryAttributes,
@@ -32,6 +35,7 @@ internal partial class WindowsOutput : IConsoleOutput
);
[DllImport ("kernel32.dll", SetLastError = true)]
[return: MarshalAs (UnmanagedType.Bool)]
private static extern bool GetConsoleScreenBufferInfoEx (nint hConsoleOutput, ref WindowsConsole.CONSOLE_SCREEN_BUFFER_INFOEX csbi);
[Flags]
@@ -50,19 +54,52 @@ internal partial class WindowsOutput : IConsoleOutput
internal static nint INVALID_HANDLE_VALUE = new (-1);
[DllImport ("kernel32.dll", SetLastError = true)]
private static extern bool SetConsoleActiveScreenBuffer (nint handle);
[LibraryImport ("kernel32.dll", SetLastError = true)]
[return: MarshalAs (UnmanagedType.Bool)]
private static partial bool SetConsoleActiveScreenBuffer (nint handle);
[DllImport ("kernel32.dll")]
private static extern bool SetConsoleCursorPosition (nint hConsoleOutput, WindowsConsole.Coord dwCursorPosition);
[LibraryImport ("kernel32.dll")]
[return: MarshalAs (UnmanagedType.Bool)]
private static partial bool SetConsoleCursorPosition (nint hConsoleOutput, WindowsConsole.Coord dwCursorPosition);
[DllImport ("kernel32.dll", SetLastError = true)]
[return: MarshalAs (UnmanagedType.Bool)]
private static extern bool SetConsoleCursorInfo (nint hConsoleOutput, [In] ref WindowsConsole.ConsoleCursorInfo lpConsoleCursorInfo);
private readonly nint _screenBuffer;
[LibraryImport ("kernel32.dll", SetLastError = true)]
[return: MarshalAs (UnmanagedType.Bool)]
public static partial bool SetConsoleTextAttribute (nint hConsoleOutput, ushort wAttributes);
// Last text style used, for updating style with EscSeqUtils.CSI_AppendTextStyleChange().
private TextStyle _redrawTextStyle = TextStyle.None;
[LibraryImport ("kernel32.dll")]
[return: MarshalAs (UnmanagedType.Bool)]
private static partial bool GetConsoleMode (nint hConsoleHandle, out uint lpMode);
[LibraryImport ("kernel32.dll")]
[return: MarshalAs (UnmanagedType.Bool)]
private static partial bool SetConsoleMode (nint hConsoleHandle, uint dwMode);
[LibraryImport ("kernel32.dll", SetLastError = true)]
private static partial WindowsConsole.Coord GetLargestConsoleWindowSize (
nint hConsoleOutput
);
[DllImport ("kernel32.dll", SetLastError = true)]
[return: MarshalAs (UnmanagedType.Bool)]
private static extern bool SetConsoleScreenBufferInfoEx (nint hConsoleOutput, ref WindowsConsole.CONSOLE_SCREEN_BUFFER_INFOEX consoleScreenBufferInfo);
[DllImport ("kernel32.dll", SetLastError = true)]
[return: MarshalAs (UnmanagedType.Bool)]
private static extern bool SetConsoleWindowInfo (
nint hConsoleOutput,
bool bAbsolute,
[In] ref WindowsConsole.SmallRect lpConsoleWindow
);
private const int STD_OUTPUT_HANDLE = -11;
private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
private readonly nint _outputHandle;
private nint _screenBuffer;
private readonly bool _isVirtualTerminal;
public WindowsOutput ()
{
@@ -73,13 +110,48 @@ internal partial class WindowsOutput : IConsoleOutput
return;
}
// Get the standard output handle which is the current screen buffer.
_outputHandle = GetStdHandle (STD_OUTPUT_HANDLE);
GetConsoleMode (_outputHandle, out uint mode);
_isVirtualTerminal = (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0;
if (_isVirtualTerminal)
{
//Enable alternative screen buffer.
Console.Out.Write (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll);
}
else
{
CreateScreenBuffer ();
if (!GetConsoleMode (_screenBuffer, out mode))
{
throw new ApplicationException ($"Failed to get screenBuffer console mode, error code: {Marshal.GetLastWin32Error ()}.");
}
const uint ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002;
mode &= ~ENABLE_WRAP_AT_EOL_OUTPUT; // Disable wrap
if (!SetConsoleMode (_screenBuffer, mode))
{
throw new ApplicationException ($"Failed to set screenBuffer console mode, error code: {Marshal.GetLastWin32Error ()}.");
}
// Force 16 colors if not in virtual terminal mode.
Application.Force16Colors = true;
}
}
private void CreateScreenBuffer ()
{
_screenBuffer = CreateConsoleScreenBuffer (
DesiredAccess.GenericRead | DesiredAccess.GenericWrite,
ShareMode.FileShareRead | ShareMode.FileShareWrite,
nint.Zero,
1,
nint.Zero
);
DesiredAccess.GenericRead | DesiredAccess.GenericWrite,
ShareMode.FileShareRead | ShareMode.FileShareWrite,
nint.Zero,
1,
nint.Zero
);
if (_screenBuffer == INVALID_HANDLE_VALUE)
{
@@ -99,212 +171,229 @@ internal partial class WindowsOutput : IConsoleOutput
public void Write (ReadOnlySpan<char> str)
{
if (!WriteConsole (_screenBuffer, str, (uint)str.Length, out uint _, nint.Zero))
if (!WriteConsole (_isVirtualTerminal ? _outputHandle : _screenBuffer, str, (uint)str.Length, out uint _, nint.Zero))
{
throw new Win32Exception (Marshal.GetLastWin32Error (), "Failed to write to console screen buffer.");
}
}
public void Write (IOutputBuffer buffer)
public Size ResizeBuffer (Size size)
{
WindowsConsole.ExtendedCharInfo [] outputBuffer = new WindowsConsole.ExtendedCharInfo [buffer.Rows * buffer.Cols];
Size newSize = SetConsoleWindow (
(short)Math.Max (size.Width, 0),
(short)Math.Max (size.Height, 0));
// TODO: probably do need this right?
/*
if (!windowSize.IsEmpty && (windowSize.Width != buffer.Cols || windowSize.Height != buffer.Rows))
{
return;
}*/
var bufferCoords = new WindowsConsole.Coord
{
X = (short)buffer.Cols, //Clip.Width,
Y = (short)buffer.Rows //Clip.Height
};
for (var row = 0; row < buffer.Rows; row++)
{
if (!buffer.DirtyLines [row])
{
continue;
}
buffer.DirtyLines [row] = false;
for (var col = 0; col < buffer.Cols; col++)
{
int position = row * buffer.Cols + col;
outputBuffer [position].Attribute = buffer.Contents [row, col].Attribute.GetValueOrDefault ();
if (buffer.Contents [row, col].IsDirty == false)
{
outputBuffer [position].Empty = true;
outputBuffer [position].Char = (char)Rune.ReplacementChar.Value;
continue;
}
outputBuffer [position].Empty = false;
if (buffer.Contents [row, col].Rune.IsBmp)
{
outputBuffer [position].Char = (char)buffer.Contents [row, col].Rune.Value;
}
else
{
//outputBuffer [position].Empty = true;
outputBuffer [position].Char = (char)Rune.ReplacementChar.Value;
if (buffer.Contents [row, col].Rune.GetColumns () > 1 && col + 1 < buffer.Cols)
{
// TODO: This is a hack to deal with non-BMP and wide characters.
col++;
position = row * buffer.Cols + col;
outputBuffer [position].Empty = false;
outputBuffer [position].Char = ' ';
}
}
}
}
var damageRegion = new WindowsConsole.SmallRect
{
Top = 0,
Left = 0,
Bottom = (short)buffer.Rows,
Right = (short)buffer.Cols
};
//size, ExtendedCharInfo [] charInfoBuffer, Coord , SmallRect window,
if (!ConsoleDriver.RunningUnitTests
&& !WriteToConsole (
new (buffer.Cols, buffer.Rows),
outputBuffer,
bufferCoords,
damageRegion,
Application.Driver!.Force16Colors))
{
int err = Marshal.GetLastWin32Error ();
if (err != 0)
{
throw new Win32Exception (err);
}
}
WindowsConsole.SmallRect.MakeEmpty (ref damageRegion);
return newSize;
}
public bool WriteToConsole (Size size, WindowsConsole.ExtendedCharInfo [] charInfoBuffer, WindowsConsole.Coord bufferSize, WindowsConsole.SmallRect window, bool force16Colors)
{
//Debug.WriteLine ("WriteToConsole");
//if (_screenBuffer == nint.Zero)
//{
// ReadFromConsoleOutput (size, bufferSize, ref window);
//}
var result = false;
if (force16Colors)
{
var i = 0;
WindowsConsole.CharInfo [] ci = new WindowsConsole.CharInfo [charInfoBuffer.Length];
foreach (WindowsConsole.ExtendedCharInfo info in charInfoBuffer)
{
ci [i++] = new ()
{
Char = new () { UnicodeChar = info.Char },
Attributes =
(ushort)((int)info.Attribute.Foreground.GetClosestNamedColor16 () | ((int)info.Attribute.Background.GetClosestNamedColor16 () << 4))
};
}
result = WriteConsoleOutput (_screenBuffer, ci, bufferSize, new () { X = window.Left, Y = window.Top }, ref window);
}
else
{
StringBuilder stringBuilder = new();
stringBuilder.Append (EscSeqUtils.CSI_SaveCursorPosition);
EscSeqUtils.CSI_AppendCursorPosition (stringBuilder, 0, 0);
Attribute? prev = null;
foreach (WindowsConsole.ExtendedCharInfo info in charInfoBuffer)
{
Attribute attr = info.Attribute;
if (attr != prev)
{
prev = attr;
EscSeqUtils.CSI_AppendForegroundColorRGB (stringBuilder, attr.Foreground.R, attr.Foreground.G, attr.Foreground.B);
EscSeqUtils.CSI_AppendBackgroundColorRGB (stringBuilder, attr.Background.R, attr.Background.G, attr.Background.B);
EscSeqUtils.CSI_AppendTextStyleChange (stringBuilder, _redrawTextStyle, attr.Style);
_redrawTextStyle = attr.Style;
}
if (info.Char != '\x1b')
{
if (!info.Empty)
{
stringBuilder.Append (info.Char);
}
}
else
{
stringBuilder.Append (' ');
}
}
stringBuilder.Append (EscSeqUtils.CSI_RestoreCursorPosition);
stringBuilder.Append (EscSeqUtils.CSI_HideCursor);
// TODO: Potentially could stackalloc whenever reasonably small (<= 8 kB?) write buffer is needed.
char [] rentedWriteArray = ArrayPool<char>.Shared.Rent (minimumLength: stringBuilder.Length);
try
{
Span<char> writeBuffer = rentedWriteArray.AsSpan(0, stringBuilder.Length);
stringBuilder.CopyTo (0, writeBuffer, stringBuilder.Length);
// Supply console with the new content.
result = WriteConsole (_screenBuffer, writeBuffer, (uint)writeBuffer.Length, out uint _, nint.Zero);
}
finally
{
ArrayPool<char>.Shared.Return (rentedWriteArray);
}
foreach (SixelToRender sixel in Application.Sixel)
{
SetCursorPosition ((short)sixel.ScreenPosition.X, (short)sixel.ScreenPosition.Y);
WriteConsole (_screenBuffer, sixel.SixelData, (uint)sixel.SixelData.Length, out uint _, nint.Zero);
}
}
if (!result)
{
int err = Marshal.GetLastWin32Error ();
if (err != 0)
{
throw new Win32Exception (err);
}
}
return result;
}
public Size GetWindowSize ()
internal Size SetConsoleWindow (short cols, short rows)
{
var csbi = new WindowsConsole.CONSOLE_SCREEN_BUFFER_INFOEX ();
csbi.cbSize = (uint)Marshal.SizeOf (csbi);
if (!GetConsoleScreenBufferInfoEx (_screenBuffer, ref csbi))
if (!GetConsoleScreenBufferInfoEx (_isVirtualTerminal ? _outputHandle : _screenBuffer, ref csbi))
{
throw new Win32Exception (Marshal.GetLastWin32Error ());
}
WindowsConsole.Coord maxWinSize = GetLargestConsoleWindowSize (_isVirtualTerminal ? _outputHandle : _screenBuffer);
short newCols = Math.Min (cols, maxWinSize.X);
short newRows = Math.Min (rows, maxWinSize.Y);
csbi.dwSize = new (newCols, Math.Max (newRows, (short)1));
csbi.srWindow = new (0, 0, newCols, newRows);
csbi.dwMaximumWindowSize = new (newCols, newRows);
if (!SetConsoleScreenBufferInfoEx (_isVirtualTerminal ? _outputHandle : _screenBuffer, ref csbi))
{
throw new Win32Exception (Marshal.GetLastWin32Error ());
}
var winRect = new WindowsConsole.SmallRect (0, 0, (short)(newCols - 1), (short)Math.Max (newRows - 1, 0));
if (!SetConsoleWindowInfo (_outputHandle, true, ref winRect))
{
//throw new System.ComponentModel.Win32Exception (Marshal.GetLastWin32Error ());
return new (cols, rows);
}
SetConsoleOutputWindow (csbi);
return new (winRect.Right + 1, newRows - 1 < 0 ? 0 : winRect.Bottom + 1);
}
private void SetConsoleOutputWindow (WindowsConsole.CONSOLE_SCREEN_BUFFER_INFOEX csbi)
{
if ((_isVirtualTerminal
? _outputHandle
: _screenBuffer) != nint.Zero && !SetConsoleScreenBufferInfoEx (_isVirtualTerminal ? _outputHandle : _screenBuffer, ref csbi))
{
throw new Win32Exception (Marshal.GetLastWin32Error ());
}
}
public override void Write (IOutputBuffer outputBuffer)
{
_force16Colors = Application.Driver!.Force16Colors;
_everythingStringBuilder = new StringBuilder ();
// for 16 color mode we will write to a backing buffer then flip it to the active one at the end to avoid jitter.
_consoleBuffer = 0;
if (_force16Colors)
{
if (_isVirtualTerminal)
{
_consoleBuffer = _outputHandle;
}
else
{
_consoleBuffer = _screenBuffer;
}
}
else
{
_consoleBuffer = _outputHandle;
}
base.Write (outputBuffer);
try
{
if (_force16Colors && !_isVirtualTerminal)
{
SetConsoleActiveScreenBuffer (_consoleBuffer);
}
else
{
var span = _everythingStringBuilder.ToString ().AsSpan (); // still allocates the string
var result = WriteConsole (_consoleBuffer, span, (uint)span.Length, out _, nint.Zero);
if (!result)
{
int err = Marshal.GetLastWin32Error ();
if (err != 0)
{
throw new Win32Exception (err);
}
}
}
}
catch (Exception e)
{
Logging.Logger.LogError ($"Error: {e.Message} in {nameof (WindowsOutput)}");
if (!ConsoleDriver.RunningUnitTests)
{
throw;
}
}
}
/// <inheritdoc />
protected override void Write (StringBuilder output)
{
if (output.Length == 0)
{
return;
}
var str = output.ToString ();
if (_force16Colors && !_isVirtualTerminal)
{
var a = str.ToCharArray ();
WriteConsole (_screenBuffer,a ,(uint)a.Length, out _, nint.Zero);
}
else
{
_everythingStringBuilder.Append (str);
}
}
/// <inheritdoc />
protected override void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle)
{
var force16Colors = Application.Force16Colors;
if (force16Colors)
{
if (_isVirtualTerminal)
{
output.Append (EscSeqUtils.CSI_SetForegroundColor (attr.Foreground.GetAnsiColorCode ()));
output.Append (EscSeqUtils.CSI_SetBackgroundColor (attr.Background.GetAnsiColorCode ()));
EscSeqUtils.CSI_AppendTextStyleChange (output, redrawTextStyle, attr.Style);
}
else
{
var as16ColorInt = (ushort)((int)attr.Foreground.GetClosestNamedColor16 () | ((int)attr.Background.GetClosestNamedColor16 () << 4));
SetConsoleTextAttribute (_screenBuffer, as16ColorInt);
}
}
else
{
EscSeqUtils.CSI_AppendForegroundColorRGB (output, attr.Foreground.R, attr.Foreground.G, attr.Foreground.B);
EscSeqUtils.CSI_AppendBackgroundColorRGB (output, attr.Background.R, attr.Background.G, attr.Background.B);
EscSeqUtils.CSI_AppendTextStyleChange (output, redrawTextStyle, attr.Style);
}
}
private Size? _lastSize;
private Size? _lastWindowSizeBeforeMaximized;
private bool _lockResize;
public Size GetWindowSize ()
{
if (_lockResize)
{
return _lastSize!.Value;
}
var newSize = GetWindowSize (out _);
Size largestWindowSize = GetLargestConsoleWindowSize ();
if (_lastWindowSizeBeforeMaximized is null && newSize == largestWindowSize)
{
_lastWindowSizeBeforeMaximized = _lastSize;
}
else if (_lastWindowSizeBeforeMaximized is { } && newSize != largestWindowSize)
{
if (newSize != _lastWindowSizeBeforeMaximized)
{
newSize = _lastWindowSizeBeforeMaximized.Value;
}
_lastWindowSizeBeforeMaximized = null;
}
if (_lastSize == null || _lastSize != newSize)
{
// User is resizing the screen, they can only ever resize the active
// buffer since. We now however have issue because background offscreen
// buffer will be wrong size, recreate it to ensure it doesn't result in
// differing active and back buffer sizes (which causes flickering of window size)
Size? bufSize = null;
while (bufSize != newSize)
{
_lockResize = true;
bufSize = ResizeBuffer (newSize);
}
_lockResize = false;
_lastSize = newSize;
}
return newSize;
}
public Size GetWindowSize (out WindowsConsole.Coord cursorPosition)
{
var csbi = new WindowsConsole.CONSOLE_SCREEN_BUFFER_INFOEX ();
csbi.cbSize = (uint)Marshal.SizeOf (csbi);
if (!GetConsoleScreenBufferInfoEx (_isVirtualTerminal ? _outputHandle : _screenBuffer, ref csbi))
{
//throw new System.ComponentModel.Win32Exception (Marshal.GetLastWin32Error ());
cursorPosition = default;
return Size.Empty;
}
@@ -312,18 +401,45 @@ internal partial class WindowsOutput : IConsoleOutput
csbi.srWindow.Right - csbi.srWindow.Left + 1,
csbi.srWindow.Bottom - csbi.srWindow.Top + 1);
cursorPosition = csbi.dwCursorPosition;
return sz;
}
private Size GetLargestConsoleWindowSize ()
{
WindowsConsole.Coord maxWinSize = GetLargestConsoleWindowSize (_isVirtualTerminal ? _outputHandle : _screenBuffer);
return new (maxWinSize.X, maxWinSize.Y);
}
/// <inheritdoc />
protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY)
{
if (_force16Colors && !_isVirtualTerminal)
{
SetConsoleCursorPosition (_screenBuffer, new ((short)screenPositionX, (short)screenPositionY));
}
else
{
// CSI codes are 1 indexed
_everythingStringBuilder.Append (EscSeqUtils.CSI_SaveCursorPosition);
EscSeqUtils.CSI_AppendCursorPosition (_everythingStringBuilder, screenPositionY + 1, screenPositionX + 1);
}
_lastCursorPosition = new (screenPositionX, screenPositionY);
return true;
}
/// <inheritdoc/>
public void SetCursorVisibility (CursorVisibility visibility)
public override void SetCursorVisibility (CursorVisibility visibility)
{
if (ConsoleDriver.RunningUnitTests)
{
return;
}
if (Application.Driver!.Force16Colors)
if (!_isVirtualTerminal)
{
var info = new WindowsConsole.ConsoleCursorInfo
{
@@ -342,22 +458,34 @@ internal partial class WindowsOutput : IConsoleOutput
}
}
private Point _lastCursorPosition;
private Point? _lastCursorPosition;
/// <inheritdoc/>
public void SetCursorPosition (int col, int row)
{
if (_lastCursorPosition.X == col && _lastCursorPosition.Y == row)
if (_lastCursorPosition is { } && _lastCursorPosition.Value.X == col && _lastCursorPosition.Value.Y == row)
{
return;
}
_lastCursorPosition = new (col, row);
SetConsoleCursorPosition (_screenBuffer, new ((short)col, (short)row));
if (_isVirtualTerminal)
{
var sb = new StringBuilder ();
EscSeqUtils.CSI_AppendCursorPosition (sb, row + 1, col + 1);
Write (sb.ToString ());
}
else
{
SetConsoleCursorPosition (_screenBuffer, new ((short)col, (short)row));
}
}
private bool _isDisposed;
private bool _force16Colors;
private nint _consoleBuffer;
private StringBuilder _everythingStringBuilder;
/// <inheritdoc/>
public void Dispose ()
@@ -367,16 +495,19 @@ internal partial class WindowsOutput : IConsoleOutput
return;
}
if (_screenBuffer != nint.Zero)
if (_isVirtualTerminal)
{
try
//Disable alternative screen buffer.
Console.Out.Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll);
}
else
{
if (_screenBuffer != nint.Zero)
{
CloseHandle (_screenBuffer);
}
catch (Exception e)
{
Logging.Logger.LogError (e, "Error trying to close screen buffer handle in WindowsOutput via interop method");
}
_screenBuffer = nint.Zero;
}
_isDisposed = true;