Skip to content

Commit 9b76da2

Browse files
authored
Merge pull request #2019 from Textualize/ansi-to-win32_pull-request-fixes
Add support for bold, dim, and reverse on legacy Windows consoles
2 parents 28786c7 + 81c4dc4 commit 9b76da2

File tree

4 files changed

+299
-49
lines changed

4 files changed

+299
-49
lines changed

rich/_win32_console.py

Lines changed: 174 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
"""Light wrapper around the win32 Console API - this module should only be imported on Windows"""
1+
"""Light wrapper around the Win32 Console API - this module should only be imported on Windows
2+
3+
The API that this module wraps is documented at https://docs.microsoft.com/en-us/windows/console/console-functions
4+
"""
25
import ctypes
36
import sys
47
from typing import IO, Any, NamedTuple, Type, cast
@@ -14,14 +17,17 @@
1417

1518
from rich.color import ColorSystem
1619
from rich.style import Style
17-
from rich.text import Text
1820

1921
STDOUT = -11
2022
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4
2123

2224
COORD = wintypes._COORD
2325

2426

27+
class LegacyWindowsError(Exception):
28+
pass
29+
30+
2531
class WindowsCoordinates(NamedTuple):
2632
"""Coordinates in the Windows Console API are (y, x), not (x, y).
2733
This class is intended to prevent that confusion.
@@ -34,6 +40,15 @@ class WindowsCoordinates(NamedTuple):
3440

3541
@classmethod
3642
def from_param(cls, value: "WindowsCoordinates") -> COORD:
43+
"""Converts a WindowsCoordinates into a wintypes _COORD structure.
44+
This classmethod is internally called by ctypes to perform the conversion.
45+
46+
Args:
47+
value (WindowsCoordinates): The input coordinates to convert.
48+
49+
Returns:
50+
wintypes._COORD: The converted coordinates struct.
51+
"""
3752
return COORD(value.col, value.row)
3853

3954

@@ -59,6 +74,14 @@ class CONSOLE_CURSOR_INFO(ctypes.Structure):
5974

6075

6176
def GetStdHandle(handle: int = STDOUT) -> wintypes.HANDLE:
77+
"""Retrieves a handle to the specified standard device (standard input, standard output, or standard error).
78+
79+
Args:
80+
handle (int): Integer identifier for the handle. Defaults to -11 (stdout).
81+
82+
Returns:
83+
wintypes.HANDLE: The handle
84+
"""
6285
return cast(wintypes.HANDLE, _GetStdHandle(handle))
6386

6487

@@ -67,8 +90,26 @@ def GetStdHandle(handle: int = STDOUT) -> wintypes.HANDLE:
6790
_GetConsoleMode.restype = wintypes.BOOL
6891

6992

70-
def GetConsoleMode(std_handle: wintypes.HANDLE, console_mode: wintypes.DWORD) -> bool:
71-
return bool(_GetConsoleMode(std_handle, console_mode))
93+
def GetConsoleMode(std_handle: wintypes.HANDLE) -> int:
94+
"""Retrieves the current input mode of a console's input buffer
95+
or the current output mode of a console screen buffer.
96+
97+
Args:
98+
std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer.
99+
100+
Raises:
101+
LegacyWindowsError: If any error occurs while calling the Windows console API.
102+
103+
Returns:
104+
int: Value representing the current console mode as documented at
105+
https://docs.microsoft.com/en-us/windows/console/getconsolemode#parameters
106+
"""
107+
108+
console_mode = wintypes.DWORD()
109+
success = bool(_GetConsoleMode(std_handle, console_mode))
110+
if not success:
111+
raise LegacyWindowsError("Unable to get legacy Windows Console Mode")
112+
return console_mode.value
72113

73114

74115
_FillConsoleOutputCharacterW = windll.kernel32.FillConsoleOutputCharacterW
@@ -88,8 +129,17 @@ def FillConsoleOutputCharacter(
88129
length: int,
89130
start: WindowsCoordinates,
90131
) -> int:
91-
"""Writes a character to the console screen buffer a specified number of times, beginning at the specified coordinates."""
92-
assert len(char) == 1
132+
"""Writes a character to the console screen buffer a specified number of times, beginning at the specified coordinates.
133+
134+
Args:
135+
std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer.
136+
char (str): The character to write. Must be a string of length 1.
137+
length (int): The number of times to write the character.
138+
start (WindowsCoordinates): The coordinates to start writing at.
139+
140+
Returns:
141+
int: The number of characters written.
142+
"""
93143
character = ctypes.c_char(char.encode())
94144
num_characters = wintypes.DWORD(length)
95145
num_written = wintypes.DWORD(0)
@@ -120,6 +170,18 @@ def FillConsoleOutputAttribute(
120170
length: int,
121171
start: WindowsCoordinates,
122172
) -> int:
173+
"""Sets the character attributes for a specified number of character cells,
174+
beginning at the specified coordinates in a screen buffer.
175+
176+
Args:
177+
std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer.
178+
attributes (int): Integer value representing the foreground and background colours of the cells.
179+
length (int): The number of cells to set the output attribute of.
180+
start (WindowsCoordinates): The coordinates of the first cell whose attributes are to be set.
181+
182+
Returns:
183+
int: The number of cells whose attributes were actually set.
184+
"""
123185
num_cells = wintypes.DWORD(length)
124186
style_attrs = wintypes.WORD(attributes)
125187
num_written = wintypes.DWORD(0)
@@ -140,6 +202,16 @@ def FillConsoleOutputAttribute(
140202
def SetConsoleTextAttribute(
141203
std_handle: wintypes.HANDLE, attributes: wintypes.WORD
142204
) -> bool:
205+
"""Set the colour attributes for all text written after this function is called.
206+
207+
Args:
208+
std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer.
209+
attributes (int): Integer value representing the foreground and background colours.
210+
211+
212+
Returns:
213+
bool: True if the attribute was set successfully, otherwise False.
214+
"""
143215
return bool(_SetConsoleTextAttribute(std_handle, attributes))
144216

145217

@@ -154,6 +226,14 @@ def SetConsoleTextAttribute(
154226
def GetConsoleScreenBufferInfo(
155227
std_handle: wintypes.HANDLE,
156228
) -> CONSOLE_SCREEN_BUFFER_INFO:
229+
"""Retrieves information about the specified console screen buffer.
230+
231+
Args:
232+
std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer.
233+
234+
Returns:
235+
CONSOLE_SCREEN_BUFFER_INFO: A CONSOLE_SCREEN_BUFFER_INFO ctype struct contain information about
236+
screen size, cursor position, colour attributes, and more."""
157237
console_screen_buffer_info = CONSOLE_SCREEN_BUFFER_INFO()
158238
_GetConsoleScreenBufferInfo(std_handle, byref(console_screen_buffer_info))
159239
return console_screen_buffer_info
@@ -170,6 +250,15 @@ def GetConsoleScreenBufferInfo(
170250
def SetConsoleCursorPosition(
171251
std_handle: wintypes.HANDLE, coords: WindowsCoordinates
172252
) -> bool:
253+
"""Set the position of the cursor in the console screen
254+
255+
Args:
256+
std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer.
257+
coords (WindowsCoordinates): The coordinates to move the cursor to.
258+
259+
Returns:
260+
bool: True if the function succeeds, otherwise False.
261+
"""
173262
return bool(_SetConsoleCursorPosition(std_handle, coords))
174263

175264

@@ -184,6 +273,15 @@ def SetConsoleCursorPosition(
184273
def SetConsoleCursorInfo(
185274
std_handle: wintypes.HANDLE, cursor_info: CONSOLE_CURSOR_INFO
186275
) -> bool:
276+
"""Set the cursor info - used for adjusting cursor visibility and width
277+
278+
Args:
279+
std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer.
280+
cursor_info (CONSOLE_CURSOR_INFO): CONSOLE_CURSOR_INFO ctype struct containing the new cursor info.
281+
282+
Returns:
283+
bool: True if the function succeeds, otherwise False.
284+
"""
187285
return bool(_SetConsoleCursorInfo(std_handle, byref(cursor_info)))
188286

189287

@@ -193,9 +291,51 @@ def SetConsoleCursorInfo(
193291

194292

195293
def SetConsoleTitle(title: str) -> bool:
294+
"""Sets the title of the current console window
295+
296+
Args:
297+
title (str): The new title of the console window.
298+
299+
Returns:
300+
bool: True if the function succeeds, otherwise False.
301+
"""
196302
return bool(_SetConsoleTitle(title))
197303

198304

305+
_WriteConsole = windll.kernel32.WriteConsoleW
306+
_WriteConsole.argtypes = [
307+
wintypes.HANDLE,
308+
wintypes.LPWSTR,
309+
wintypes.DWORD,
310+
wintypes.LPDWORD,
311+
wintypes.LPVOID,
312+
]
313+
_WriteConsole.restype = wintypes.BOOL
314+
315+
316+
def WriteConsole(std_handle: wintypes.HANDLE, text: str) -> bool:
317+
"""Write a string of text to the console, starting at the current cursor position
318+
319+
Args:
320+
std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer.
321+
text (str): The text to write.
322+
323+
Returns:
324+
bool: True if the function succeeds, otherwise False.
325+
"""
326+
buffer = wintypes.LPWSTR(text)
327+
num_chars_written = wintypes.LPDWORD()
328+
return bool(
329+
_WriteConsole(
330+
std_handle,
331+
buffer,
332+
wintypes.DWORD(len(text)),
333+
num_chars_written,
334+
wintypes.LPVOID(None),
335+
)
336+
)
337+
338+
199339
class LegacyWindowsTerm:
200340
"""This class allows interaction with the legacy Windows Console API. It should only be used in the context
201341
of environments where virtual terminal processing is not available. However, if it is used in a Windows environment,
@@ -205,6 +345,8 @@ class LegacyWindowsTerm:
205345
file (IO[str]): The file which the Windows Console API HANDLE is retrieved from, defaults to sys.stdout.
206346
"""
207347

348+
BRIGHT_BIT = 8
349+
208350
# Indices are ANSI color numbers, values are the corresponding Windows Console API color numbers
209351
ANSI_TO_WINDOWS = [
210352
0, # black The Windows colours are defined in wincon.h as follows:
@@ -225,19 +367,15 @@ class LegacyWindowsTerm:
225367
15, # bright white
226368
]
227369

228-
def __init__(self, file: IO[str] = sys.stdout):
229-
self.file = file
370+
def __init__(self) -> None:
230371
handle = GetStdHandle(STDOUT)
231372
self._handle = handle
232373
default_text = GetConsoleScreenBufferInfo(handle).wAttributes
233374
self._default_text = default_text
234375

235376
self._default_fore = default_text & 7
236377
self._default_back = (default_text >> 4) & 7
237-
self._default_attrs = self._default_fore + self._default_back * 16
238-
239-
self.write = file.write
240-
self.flush = file.flush
378+
self._default_attrs = self._default_fore | (self._default_back << 4)
241379

242380
@property
243381
def cursor_position(self) -> WindowsCoordinates:
@@ -267,25 +405,33 @@ def write_text(self, text: str) -> None:
267405
Args:
268406
text (str): The text to write to the console
269407
"""
270-
self.write(text)
271-
self.flush()
408+
WriteConsole(self._handle, text)
272409

273410
def write_styled(self, text: str, style: Style) -> None:
274-
"""Write styled text to the terminal
411+
"""Write styled text to the terminal.
275412
276413
Args:
277414
text (str): The text to write
278415
style (Style): The style of the text
279416
"""
280-
if style.color:
281-
fore = style.color.downgrade(ColorSystem.WINDOWS).number
417+
color = style.color
418+
bgcolor = style.bgcolor
419+
if style.reverse:
420+
color, bgcolor = bgcolor, color
421+
422+
if color:
423+
fore = color.downgrade(ColorSystem.WINDOWS).number
282424
fore = fore if fore is not None else 7 # Default to ANSI 7: White
425+
if style.bold:
426+
fore = fore | self.BRIGHT_BIT
427+
if style.dim:
428+
fore = fore & ~self.BRIGHT_BIT
283429
fore = self.ANSI_TO_WINDOWS[fore]
284430
else:
285431
fore = self._default_fore
286432

287-
if style.bgcolor:
288-
back = style.bgcolor.downgrade(ColorSystem.WINDOWS).number
433+
if bgcolor:
434+
back = bgcolor.downgrade(ColorSystem.WINDOWS).number
289435
back = back if back is not None else 0 # Default to ANSI 0: Black
290436
back = self.ANSI_TO_WINDOWS[back]
291437
else:
@@ -295,7 +441,7 @@ def write_styled(self, text: str, style: Style) -> None:
295441
assert back is not None
296442

297443
SetConsoleTextAttribute(
298-
self._handle, attributes=ctypes.c_ushort(fore + back * 16)
444+
self._handle, attributes=ctypes.c_ushort(fore | (back << 4))
299445
)
300446
self.write_text(text)
301447
SetConsoleTextAttribute(self._handle, attributes=self._default_text)
@@ -425,17 +571,12 @@ def set_title(self, title: str) -> None:
425571

426572
if __name__ == "__main__":
427573
handle = GetStdHandle()
428-
console_mode = wintypes.DWORD()
429-
rv = GetConsoleMode(handle, console_mode)
430-
431-
print(rv)
432-
print(type(rv))
433574

434575
from rich.console import Console
435576

436577
console = Console()
437578

438-
term = LegacyWindowsTerm(console.file)
579+
term = LegacyWindowsTerm()
439580
term.set_title("Win32 Console Examples")
440581

441582
style = Style(color="black", bgcolor="red")
@@ -444,12 +585,15 @@ def set_title(self, title: str) -> None:
444585

445586
# Check colour output
446587
console.rule("Checking colour output")
447-
# console.print("Checking colour output", style=Style.parse("black on green"))
448-
text = Text("Hello world!", style=style)
449-
console.print(text)
450-
console.print("[bold green]bold green!")
588+
console.print("[on red]on red!")
589+
console.print("[blue]blue!")
590+
console.print("[yellow]yellow!")
591+
console.print("[bold yellow]bold yellow!")
592+
console.print("[bright_yellow]bright_yellow!")
593+
console.print("[dim bright_yellow]dim bright_yellow!")
451594
console.print("[italic cyan]italic cyan!")
452595
console.print("[bold white on blue]bold white on blue!")
596+
console.print("[reverse bold white on blue]reverse bold white on blue!")
453597
console.print("[bold black on cyan]bold black on cyan!")
454598
console.print("[black on green]black on green!")
455599
console.print("[blue on green]blue on green!")

rich/_windows.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class WindowsConsoleFeatures:
2626
ENABLE_VIRTUAL_TERMINAL_PROCESSING,
2727
GetConsoleMode,
2828
GetStdHandle,
29+
LegacyWindowsError,
2930
)
3031

3132
except (AttributeError, ImportError, ValueError):
@@ -44,9 +45,13 @@ def get_windows_console_features() -> WindowsConsoleFeatures:
4445
WindowsConsoleFeatures: An instance of WindowsConsoleFeatures.
4546
"""
4647
handle = GetStdHandle()
47-
console_mode = wintypes.DWORD()
48-
result = GetConsoleMode(handle, console_mode)
49-
vt = bool(result and console_mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING)
48+
try:
49+
console_mode = GetConsoleMode(handle)
50+
success = True
51+
except LegacyWindowsError:
52+
console_mode = 0
53+
success = False
54+
vt = bool(success and console_mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING)
5055
truecolor = False
5156
if vt:
5257
win_version = sys.getwindowsversion()

rich/console.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1921,9 +1921,7 @@ def _check_buffer(self) -> None:
19211921
from rich._win32_console import LegacyWindowsTerm
19221922
from rich._windows_renderer import legacy_windows_render
19231923

1924-
legacy_windows_render(
1925-
self._buffer[:], LegacyWindowsTerm(self.file)
1926-
)
1924+
legacy_windows_render(self._buffer[:], LegacyWindowsTerm())
19271925

19281926
output_capture_enabled = bool(self._buffer_index)
19291927
if not legacy_windows_stdout or output_capture_enabled:

0 commit comments

Comments
 (0)