diff --git a/rich/_win32_console.py b/rich/_win32_console.py index 8a6af2433..c88287564 100644 --- a/rich/_win32_console.py +++ b/rich/_win32_console.py @@ -1,4 +1,7 @@ -"""Light wrapper around the win32 Console API - this module should only be imported on Windows""" +"""Light wrapper around the Win32 Console API - this module should only be imported on Windows + +The API that this module wraps is documented at https://docs.microsoft.com/en-us/windows/console/console-functions +""" import ctypes import sys from typing import IO, Any, NamedTuple, Type, cast @@ -14,7 +17,6 @@ from rich.color import ColorSystem from rich.style import Style -from rich.text import Text STDOUT = -11 ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 @@ -22,6 +24,10 @@ COORD = wintypes._COORD +class LegacyWindowsError(Exception): + pass + + class WindowsCoordinates(NamedTuple): """Coordinates in the Windows Console API are (y, x), not (x, y). This class is intended to prevent that confusion. @@ -34,6 +40,15 @@ class WindowsCoordinates(NamedTuple): @classmethod def from_param(cls, value: "WindowsCoordinates") -> COORD: + """Converts a WindowsCoordinates into a wintypes _COORD structure. + This classmethod is internally called by ctypes to perform the conversion. + + Args: + value (WindowsCoordinates): The input coordinates to convert. + + Returns: + wintypes._COORD: The converted coordinates struct. + """ return COORD(value.col, value.row) @@ -59,6 +74,14 @@ class CONSOLE_CURSOR_INFO(ctypes.Structure): def GetStdHandle(handle: int = STDOUT) -> wintypes.HANDLE: + """Retrieves a handle to the specified standard device (standard input, standard output, or standard error). + + Args: + handle (int): Integer identifier for the handle. Defaults to -11 (stdout). + + Returns: + wintypes.HANDLE: The handle + """ return cast(wintypes.HANDLE, _GetStdHandle(handle)) @@ -67,8 +90,26 @@ def GetStdHandle(handle: int = STDOUT) -> wintypes.HANDLE: _GetConsoleMode.restype = wintypes.BOOL -def GetConsoleMode(std_handle: wintypes.HANDLE, console_mode: wintypes.DWORD) -> bool: - return bool(_GetConsoleMode(std_handle, console_mode)) +def GetConsoleMode(std_handle: wintypes.HANDLE) -> int: + """Retrieves the current input mode of a console's input buffer + or the current output mode of a console screen buffer. + + Args: + std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer. + + Raises: + LegacyWindowsError: If any error occurs while calling the Windows console API. + + Returns: + int: Value representing the current console mode as documented at + https://docs.microsoft.com/en-us/windows/console/getconsolemode#parameters + """ + + console_mode = wintypes.DWORD() + success = bool(_GetConsoleMode(std_handle, console_mode)) + if not success: + raise LegacyWindowsError("Unable to get legacy Windows Console Mode") + return console_mode.value _FillConsoleOutputCharacterW = windll.kernel32.FillConsoleOutputCharacterW @@ -88,8 +129,17 @@ def FillConsoleOutputCharacter( length: int, start: WindowsCoordinates, ) -> int: - """Writes a character to the console screen buffer a specified number of times, beginning at the specified coordinates.""" - assert len(char) == 1 + """Writes a character to the console screen buffer a specified number of times, beginning at the specified coordinates. + + Args: + std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer. + char (str): The character to write. Must be a string of length 1. + length (int): The number of times to write the character. + start (WindowsCoordinates): The coordinates to start writing at. + + Returns: + int: The number of characters written. + """ character = ctypes.c_char(char.encode()) num_characters = wintypes.DWORD(length) num_written = wintypes.DWORD(0) @@ -120,6 +170,18 @@ def FillConsoleOutputAttribute( length: int, start: WindowsCoordinates, ) -> int: + """Sets the character attributes for a specified number of character cells, + beginning at the specified coordinates in a screen buffer. + + Args: + std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer. + attributes (int): Integer value representing the foreground and background colours of the cells. + length (int): The number of cells to set the output attribute of. + start (WindowsCoordinates): The coordinates of the first cell whose attributes are to be set. + + Returns: + int: The number of cells whose attributes were actually set. + """ num_cells = wintypes.DWORD(length) style_attrs = wintypes.WORD(attributes) num_written = wintypes.DWORD(0) @@ -140,6 +202,16 @@ def FillConsoleOutputAttribute( def SetConsoleTextAttribute( std_handle: wintypes.HANDLE, attributes: wintypes.WORD ) -> bool: + """Set the colour attributes for all text written after this function is called. + + Args: + std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer. + attributes (int): Integer value representing the foreground and background colours. + + + Returns: + bool: True if the attribute was set successfully, otherwise False. + """ return bool(_SetConsoleTextAttribute(std_handle, attributes)) @@ -154,6 +226,14 @@ def SetConsoleTextAttribute( def GetConsoleScreenBufferInfo( std_handle: wintypes.HANDLE, ) -> CONSOLE_SCREEN_BUFFER_INFO: + """Retrieves information about the specified console screen buffer. + + Args: + std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer. + + Returns: + CONSOLE_SCREEN_BUFFER_INFO: A CONSOLE_SCREEN_BUFFER_INFO ctype struct contain information about + screen size, cursor position, colour attributes, and more.""" console_screen_buffer_info = CONSOLE_SCREEN_BUFFER_INFO() _GetConsoleScreenBufferInfo(std_handle, byref(console_screen_buffer_info)) return console_screen_buffer_info @@ -170,6 +250,15 @@ def GetConsoleScreenBufferInfo( def SetConsoleCursorPosition( std_handle: wintypes.HANDLE, coords: WindowsCoordinates ) -> bool: + """Set the position of the cursor in the console screen + + Args: + std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer. + coords (WindowsCoordinates): The coordinates to move the cursor to. + + Returns: + bool: True if the function succeeds, otherwise False. + """ return bool(_SetConsoleCursorPosition(std_handle, coords)) @@ -184,6 +273,15 @@ def SetConsoleCursorPosition( def SetConsoleCursorInfo( std_handle: wintypes.HANDLE, cursor_info: CONSOLE_CURSOR_INFO ) -> bool: + """Set the cursor info - used for adjusting cursor visibility and width + + Args: + std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer. + cursor_info (CONSOLE_CURSOR_INFO): CONSOLE_CURSOR_INFO ctype struct containing the new cursor info. + + Returns: + bool: True if the function succeeds, otherwise False. + """ return bool(_SetConsoleCursorInfo(std_handle, byref(cursor_info))) @@ -193,9 +291,51 @@ def SetConsoleCursorInfo( def SetConsoleTitle(title: str) -> bool: + """Sets the title of the current console window + + Args: + title (str): The new title of the console window. + + Returns: + bool: True if the function succeeds, otherwise False. + """ return bool(_SetConsoleTitle(title)) +_WriteConsole = windll.kernel32.WriteConsoleW +_WriteConsole.argtypes = [ + wintypes.HANDLE, + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.LPDWORD, + wintypes.LPVOID, +] +_WriteConsole.restype = wintypes.BOOL + + +def WriteConsole(std_handle: wintypes.HANDLE, text: str) -> bool: + """Write a string of text to the console, starting at the current cursor position + + Args: + std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer. + text (str): The text to write. + + Returns: + bool: True if the function succeeds, otherwise False. + """ + buffer = wintypes.LPWSTR(text) + num_chars_written = wintypes.LPDWORD() + return bool( + _WriteConsole( + std_handle, + buffer, + wintypes.DWORD(len(text)), + num_chars_written, + wintypes.LPVOID(None), + ) + ) + + class LegacyWindowsTerm: """This class allows interaction with the legacy Windows Console API. It should only be used in the context of environments where virtual terminal processing is not available. However, if it is used in a Windows environment, @@ -205,6 +345,8 @@ class LegacyWindowsTerm: file (IO[str]): The file which the Windows Console API HANDLE is retrieved from, defaults to sys.stdout. """ + BRIGHT_BIT = 8 + # Indices are ANSI color numbers, values are the corresponding Windows Console API color numbers ANSI_TO_WINDOWS = [ 0, # black The Windows colours are defined in wincon.h as follows: @@ -225,8 +367,7 @@ class LegacyWindowsTerm: 15, # bright white ] - def __init__(self, file: IO[str] = sys.stdout): - self.file = file + def __init__(self) -> None: handle = GetStdHandle(STDOUT) self._handle = handle default_text = GetConsoleScreenBufferInfo(handle).wAttributes @@ -234,10 +375,7 @@ def __init__(self, file: IO[str] = sys.stdout): self._default_fore = default_text & 7 self._default_back = (default_text >> 4) & 7 - self._default_attrs = self._default_fore + self._default_back * 16 - - self.write = file.write - self.flush = file.flush + self._default_attrs = self._default_fore | (self._default_back << 4) @property def cursor_position(self) -> WindowsCoordinates: @@ -267,25 +405,33 @@ def write_text(self, text: str) -> None: Args: text (str): The text to write to the console """ - self.write(text) - self.flush() + WriteConsole(self._handle, text) def write_styled(self, text: str, style: Style) -> None: - """Write styled text to the terminal + """Write styled text to the terminal. Args: text (str): The text to write style (Style): The style of the text """ - if style.color: - fore = style.color.downgrade(ColorSystem.WINDOWS).number + color = style.color + bgcolor = style.bgcolor + if style.reverse: + color, bgcolor = bgcolor, color + + if color: + fore = color.downgrade(ColorSystem.WINDOWS).number fore = fore if fore is not None else 7 # Default to ANSI 7: White + if style.bold: + fore = fore | self.BRIGHT_BIT + if style.dim: + fore = fore & ~self.BRIGHT_BIT fore = self.ANSI_TO_WINDOWS[fore] else: fore = self._default_fore - if style.bgcolor: - back = style.bgcolor.downgrade(ColorSystem.WINDOWS).number + if bgcolor: + back = bgcolor.downgrade(ColorSystem.WINDOWS).number back = back if back is not None else 0 # Default to ANSI 0: Black back = self.ANSI_TO_WINDOWS[back] else: @@ -295,7 +441,7 @@ def write_styled(self, text: str, style: Style) -> None: assert back is not None SetConsoleTextAttribute( - self._handle, attributes=ctypes.c_ushort(fore + back * 16) + self._handle, attributes=ctypes.c_ushort(fore | (back << 4)) ) self.write_text(text) SetConsoleTextAttribute(self._handle, attributes=self._default_text) @@ -425,17 +571,12 @@ def set_title(self, title: str) -> None: if __name__ == "__main__": handle = GetStdHandle() - console_mode = wintypes.DWORD() - rv = GetConsoleMode(handle, console_mode) - - print(rv) - print(type(rv)) from rich.console import Console console = Console() - term = LegacyWindowsTerm(console.file) + term = LegacyWindowsTerm() term.set_title("Win32 Console Examples") style = Style(color="black", bgcolor="red") @@ -444,12 +585,15 @@ def set_title(self, title: str) -> None: # Check colour output console.rule("Checking colour output") - # console.print("Checking colour output", style=Style.parse("black on green")) - text = Text("Hello world!", style=style) - console.print(text) - console.print("[bold green]bold green!") + console.print("[on red]on red!") + console.print("[blue]blue!") + console.print("[yellow]yellow!") + console.print("[bold yellow]bold yellow!") + console.print("[bright_yellow]bright_yellow!") + console.print("[dim bright_yellow]dim bright_yellow!") console.print("[italic cyan]italic cyan!") console.print("[bold white on blue]bold white on blue!") + console.print("[reverse bold white on blue]reverse bold white on blue!") console.print("[bold black on cyan]bold black on cyan!") console.print("[black on green]black on green!") console.print("[blue on green]blue on green!") diff --git a/rich/_windows.py b/rich/_windows.py index a330cae77..54d834e62 100644 --- a/rich/_windows.py +++ b/rich/_windows.py @@ -26,6 +26,7 @@ class WindowsConsoleFeatures: ENABLE_VIRTUAL_TERMINAL_PROCESSING, GetConsoleMode, GetStdHandle, + LegacyWindowsError, ) except (AttributeError, ImportError, ValueError): @@ -44,9 +45,13 @@ def get_windows_console_features() -> WindowsConsoleFeatures: WindowsConsoleFeatures: An instance of WindowsConsoleFeatures. """ handle = GetStdHandle() - console_mode = wintypes.DWORD() - result = GetConsoleMode(handle, console_mode) - vt = bool(result and console_mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) + try: + console_mode = GetConsoleMode(handle) + success = True + except LegacyWindowsError: + console_mode = 0 + success = False + vt = bool(success and console_mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) truecolor = False if vt: win_version = sys.getwindowsversion() diff --git a/rich/console.py b/rich/console.py index 15a787208..9275a1e7b 100644 --- a/rich/console.py +++ b/rich/console.py @@ -1921,9 +1921,7 @@ def _check_buffer(self) -> None: from rich._win32_console import LegacyWindowsTerm from rich._windows_renderer import legacy_windows_render - legacy_windows_render( - self._buffer[:], LegacyWindowsTerm(self.file) - ) + legacy_windows_render(self._buffer[:], LegacyWindowsTerm()) output_capture_enabled = bool(self._buffer_index) if not legacy_windows_stdout or output_capture_enabled: diff --git a/tests/test_win32_console.py b/tests/test_win32_console.py index d3cc28050..4523600d6 100644 --- a/tests/test_win32_console.py +++ b/tests/test_win32_console.py @@ -1,15 +1,13 @@ import dataclasses import sys -from io import StringIO from unittest import mock -from unittest.mock import call, patch +from unittest.mock import patch import pytest from rich.style import Style if sys.platform == "win32": - from rich import _win32_console from rich._win32_console import COORD, LegacyWindowsTerm, WindowsCoordinates @@ -55,35 +53,39 @@ def test_screen_size(_): row=SCREEN_HEIGHT, col=SCREEN_WIDTH ) + @patch.object(_win32_console, "WriteConsole", return_value=True) @patch.object( _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo ) - def test_write_text(_): - f = StringIO() + def test_write_text(_, WriteConsole, win32_handle): text = "Hello, world!" - term = LegacyWindowsTerm(file=f) + term = LegacyWindowsTerm() term.write_text(text) - assert f.getvalue() == text + WriteConsole.assert_called_once_with(win32_handle, text) + @patch.object(_win32_console, "WriteConsole", return_value=True) @patch.object(_win32_console, "SetConsoleTextAttribute") @patch.object( _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo ) - def test_write_styled(_, SetConsoleTextAttribute, win32_handle): - f = StringIO() + def test_write_styled(_, SetConsoleTextAttribute, WriteConsole, win32_handle): style = Style.parse("black on red") text = "Hello, world!" - term = LegacyWindowsTerm(file=f) + term = LegacyWindowsTerm() term.write_styled(text, style) - call_args = SetConsoleTextAttribute.call_args_list + # Check that we've called the Console API to write the text + call_args = WriteConsole.call_args_list + assert len(call_args) == 1 + args, _ = call_args[0] + assert args == (win32_handle, text) - assert f.getvalue() == text # Ensure we set the text attributes and then reset them after writing styled text - + call_args = SetConsoleTextAttribute.call_args_list + assert len(call_args) == 2 first_args, first_kwargs = call_args[0] second_args, second_kwargs = call_args[1] @@ -92,6 +94,107 @@ def test_write_styled(_, SetConsoleTextAttribute, win32_handle): assert second_args == (win32_handle,) assert second_kwargs["attributes"] == DEFAULT_STYLE_ATTRIBUTE + @patch.object(_win32_console, "WriteConsole", return_value=True) + @patch.object(_win32_console, "SetConsoleTextAttribute") + @patch.object( + _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo + ) + def test_write_styled_bold(_, SetConsoleTextAttribute, __, win32_handle): + style = Style.parse("bold black on red") + text = "Hello, world!" + term = LegacyWindowsTerm() + + term.write_styled(text, style) + + call_args = SetConsoleTextAttribute.call_args_list + first_args, first_kwargs = call_args[0] + + expected_attr = 64 + 8 # 64 for red bg, +8 for bright black + assert first_args == (win32_handle,) + assert first_kwargs["attributes"].value == expected_attr + + @patch.object(_win32_console, "WriteConsole", return_value=True) + @patch.object(_win32_console, "SetConsoleTextAttribute") + @patch.object( + _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo + ) + def test_write_styled_reverse(_, SetConsoleTextAttribute, __, win32_handle): + style = Style.parse("reverse red on blue") + text = "Hello, world!" + term = LegacyWindowsTerm() + + term.write_styled(text, style) + + call_args = SetConsoleTextAttribute.call_args_list + first_args, first_kwargs = call_args[0] + + expected_attr = 64 + 1 # 64 for red bg (after reverse), +1 for blue fg + assert first_args == (win32_handle,) + assert first_kwargs["attributes"].value == expected_attr + + @patch.object(_win32_console, "WriteConsole", return_value=True) + @patch.object(_win32_console, "SetConsoleTextAttribute") + @patch.object( + _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo + ) + def test_write_styled_reverse(_, SetConsoleTextAttribute, __, win32_handle): + style = Style.parse("dim bright_red on blue") + text = "Hello, world!" + term = LegacyWindowsTerm() + + term.write_styled(text, style) + + call_args = SetConsoleTextAttribute.call_args_list + first_args, first_kwargs = call_args[0] + + expected_attr = 4 + 16 # 4 for red text (after dim), +16 for blue bg + assert first_args == (win32_handle,) + assert first_kwargs["attributes"].value == expected_attr + + @patch.object(_win32_console, "WriteConsole", return_value=True) + @patch.object(_win32_console, "SetConsoleTextAttribute") + @patch.object( + _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo + ) + def test_write_styled_no_foreground_color( + _, SetConsoleTextAttribute, __, win32_handle + ): + style = Style.parse("on blue") + text = "Hello, world!" + term = LegacyWindowsTerm() + + term.write_styled(text, style) + + call_args = SetConsoleTextAttribute.call_args_list + first_args, first_kwargs = call_args[0] + + expected_attr = 16 | term._default_fore # 16 for blue bg, plus default fg color + assert first_args == (win32_handle,) + assert first_kwargs["attributes"].value == expected_attr + + @patch.object(_win32_console, "WriteConsole", return_value=True) + @patch.object(_win32_console, "SetConsoleTextAttribute") + @patch.object( + _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo + ) + def test_write_styled_no_background_color( + _, SetConsoleTextAttribute, __, win32_handle + ): + style = Style.parse("blue") + text = "Hello, world!" + term = LegacyWindowsTerm() + + term.write_styled(text, style) + + call_args = SetConsoleTextAttribute.call_args_list + first_args, first_kwargs = call_args[0] + + expected_attr = ( + 16 | term._default_back + ) # 16 for blue foreground, plus default bg color + assert first_args == (win32_handle,) + assert first_kwargs["attributes"].value == expected_attr + @patch.object(_win32_console, "FillConsoleOutputCharacter", return_value=None) @patch.object(_win32_console, "FillConsoleOutputAttribute", return_value=None) @patch.object(