|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Windows terminal inventory for the reserved-row bottom-toolbar work. |
| 3 | +
|
| 4 | + uv run python scripts/windows_toolbar_inventory.py |
| 5 | +
|
| 6 | +Run this INSIDE each terminal under test (Windows Terminal, mintty/git-bash, |
| 7 | +conhost). It records what the plan requires before manual qualification: backend |
| 8 | +classes actually selected, viewport vs backing-buffer geometry, console-mode |
| 9 | +restoration, and the checks that can be made without a human looking at the screen. |
| 10 | +
|
| 11 | +It changes no cmd2 code and makes no permanent terminal changes: every escape |
| 12 | +sequence it emits is reset before exit. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import contextlib |
| 18 | +import json |
| 19 | +import os |
| 20 | +import platform |
| 21 | +import sys |
| 22 | +from datetime import datetime, timezone |
| 23 | +from typing import TYPE_CHECKING, Any |
| 24 | + |
| 25 | +import prompt_toolkit |
| 26 | +from prompt_toolkit.input.defaults import create_input |
| 27 | +from prompt_toolkit.output.defaults import create_output |
| 28 | + |
| 29 | +if TYPE_CHECKING: # pragma: no cover |
| 30 | + from prompt_toolkit.output import Output |
| 31 | + |
| 32 | + |
| 33 | +def section(title: str) -> None: |
| 34 | + """Print a section heading. |
| 35 | +
|
| 36 | + :param title: heading text |
| 37 | + """ |
| 38 | + print(f"\n=== {title} ===") |
| 39 | + |
| 40 | + |
| 41 | +def inventory() -> tuple[dict[str, Any], Output]: |
| 42 | + """Collect backend, geometry and console-mode facts for the current terminal. |
| 43 | +
|
| 44 | + :return: the collected facts, and the output object they were collected from |
| 45 | + """ |
| 46 | + out = create_output(stdout=sys.stdout) |
| 47 | + try: |
| 48 | + inp = create_input() |
| 49 | + in_cls = f"{type(inp).__module__}.{type(inp).__name__}" |
| 50 | + except (OSError, ValueError, ImportError) as exc: # pragma: no cover - diagnostic |
| 51 | + in_cls = f"<unavailable: {exc!r}>" |
| 52 | + |
| 53 | + size = out.get_size() |
| 54 | + data: dict[str, Any] = { |
| 55 | + "captured_at": datetime.now(timezone.utc).isoformat(), |
| 56 | + "platform": platform.platform(), |
| 57 | + "os_release": platform.release(), |
| 58 | + "python": sys.version, |
| 59 | + "python_executable": sys.executable, |
| 60 | + "prompt_toolkit": prompt_toolkit.__version__, |
| 61 | + "TERM": os.environ.get("TERM"), |
| 62 | + "WT_SESSION": os.environ.get("WT_SESSION"), |
| 63 | + "MSYSTEM": os.environ.get("MSYSTEM"), |
| 64 | + "TERM_PROGRAM": os.environ.get("TERM_PROGRAM"), |
| 65 | + "output_class": f"{type(out).__module__}.{type(out).__name__}", |
| 66 | + "input_class": in_cls, |
| 67 | + "stdout_isatty": sys.stdout.isatty(), |
| 68 | + "stdin_isatty": sys.stdin.isatty(), |
| 69 | + "viewport_rows": size.rows, |
| 70 | + "viewport_columns": size.columns, |
| 71 | + } |
| 72 | + |
| 73 | + # Windows: the backing buffer is usually taller than the viewport, and the |
| 74 | + # delegation whitelist means geometry comes from the native side while |
| 75 | + # rendering goes through VT. |
| 76 | + try: |
| 77 | + info = out.get_win32_screen_buffer_info() # type: ignore[attr-defined] |
| 78 | + data["win32_buffer_size"] = {"X": info.dwSize.X, "Y": info.dwSize.Y} |
| 79 | + data["win32_window"] = { |
| 80 | + "Left": info.srWindow.Left, |
| 81 | + "Top": info.srWindow.Top, |
| 82 | + "Right": info.srWindow.Right, |
| 83 | + "Bottom": info.srWindow.Bottom, |
| 84 | + } |
| 85 | + data["win32_cursor"] = {"X": info.dwCursorPosition.X, "Y": info.dwCursorPosition.Y} |
| 86 | + data["viewport_differs_from_buffer"] = (info.srWindow.Bottom - info.srWindow.Top + 1) != info.dwSize.Y |
| 87 | + except (AttributeError, OSError, NotImplementedError) as exc: |
| 88 | + data["win32_screen_buffer_info"] = f"<not available: {exc!r}>" |
| 89 | + |
| 90 | + try: |
| 91 | + data["rows_below_cursor_position"] = out.get_rows_below_cursor_position() |
| 92 | + except (AttributeError, OSError, NotImplementedError) as exc: |
| 93 | + data["rows_below_cursor_position"] = f"<not available: {exc!r}>" |
| 94 | + |
| 95 | + # Which object actually serves erase_down / get_size on this backend? |
| 96 | + for name in ("erase_down", "erase_screen", "get_size", "get_rows_below_cursor_position", "flush"): |
| 97 | + try: |
| 98 | + bound = getattr(out, name) |
| 99 | + owner = getattr(bound, "__self__", None) |
| 100 | + data[f"delegate::{name}"] = ( |
| 101 | + f"{type(owner).__module__}.{type(owner).__name__}" if owner is not None else "<unbound>" |
| 102 | + ) |
| 103 | + except (AttributeError, OSError, NotImplementedError) as exc: |
| 104 | + data[f"delegate::{name}"] = f"<error: {exc!r}>" |
| 105 | + |
| 106 | + data["console_mode_before"] = _console_mode() |
| 107 | + return data, out |
| 108 | + |
| 109 | + |
| 110 | +def _console_mode() -> str: |
| 111 | + """Read the Win32 console output mode, if this is a Windows console. |
| 112 | +
|
| 113 | + :return: a human-readable description of the mode, or why it is unavailable |
| 114 | + """ |
| 115 | + try: |
| 116 | + from ctypes import byref, windll # type: ignore[attr-defined] |
| 117 | + from ctypes.wintypes import DWORD, HANDLE # type: ignore[attr-defined] |
| 118 | + |
| 119 | + handle = HANDLE(windll.kernel32.GetStdHandle(-11)) |
| 120 | + mode = DWORD() |
| 121 | + if not windll.kernel32.GetConsoleMode(handle, byref(mode)): |
| 122 | + return "<GetConsoleMode failed>" |
| 123 | + value = mode.value |
| 124 | + return ( |
| 125 | + f"0x{value:04X} " |
| 126 | + f"(VIRTUAL_TERMINAL_PROCESSING={'on' if value & 0x0004 else 'off'}, " |
| 127 | + f"WRAP_AT_EOL={'on' if value & 0x0002 else 'off'})" |
| 128 | + ) |
| 129 | + except (ImportError, AttributeError, OSError) as exc: |
| 130 | + return f"<not a Windows console: {exc!r}>" |
| 131 | + |
| 132 | + |
| 133 | +def probe_decstbm(out: Output, rows: int) -> dict[str, Any]: |
| 134 | + """Set and reset a reserved-row region; report whether modes survive it. |
| 135 | +
|
| 136 | + Deliberately conservative: it prints a marker, establishes the region, writes |
| 137 | + enough lines to scroll, then resets. The human confirms what they saw. |
| 138 | +
|
| 139 | + :param out: the output to emit through |
| 140 | + :param rows: the terminal's physical height |
| 141 | + :return: what was emitted, and the console mode afterwards |
| 142 | + """ |
| 143 | + results: dict[str, Any] = {} |
| 144 | + mark = "TOOLBARMARKER" |
| 145 | + try: |
| 146 | + out.write_raw(f"\x1b[{rows};1H{mark}") |
| 147 | + out.write_raw(f"\x1b[1;{rows - 1}r") |
| 148 | + out.write_raw("\x1b[1;1H") |
| 149 | + for i in range(1, rows * 3): |
| 150 | + out.write_raw(f"probe line {i:04d}\r\n") |
| 151 | + out.flush() |
| 152 | + results["region_emitted"] = f"\\x1b[1;{rows - 1}r" |
| 153 | + results["lines_written"] = rows * 3 - 1 |
| 154 | + finally: |
| 155 | + out.write_raw("\x1b[r") # always restore full-screen margins |
| 156 | + out.write_raw("\x1b[0m") |
| 157 | + out.flush() |
| 158 | + results["console_mode_after"] = _console_mode() |
| 159 | + return results |
| 160 | + |
| 161 | + |
| 162 | +def main() -> int: |
| 163 | + """Run the inventory and the visual probe. |
| 164 | +
|
| 165 | + :return: process exit status |
| 166 | + """ |
| 167 | + # Piping or redirecting selects PlainTextOutput, which would record the wrong |
| 168 | + # backend and silently invalidate the whole inventory. |
| 169 | + if not sys.stdout.isatty() or not sys.stdin.isatty(): |
| 170 | + print("REFUSING TO RUN: stdout/stdin is not a terminal.") |
| 171 | + print("Run this directly in the terminal under test -- do not pipe or redirect it,") |
| 172 | + print("or the recorded backend classes will be wrong.") |
| 173 | + return 2 |
| 174 | + |
| 175 | + data, out = inventory() |
| 176 | + section("Environment inventory") |
| 177 | + for k, v in data.items(): |
| 178 | + print(f" {k:38s} {v}") |
| 179 | + |
| 180 | + rows = data["viewport_rows"] |
| 181 | + if rows < 4: |
| 182 | + print("\n viewport too small for the probe; resize and rerun") |
| 183 | + return 2 |
| 184 | + |
| 185 | + section("DECSTBM probe (visual confirmation required)") |
| 186 | + print(" About to reserve the bottom row, scroll past it, then reset.") |
| 187 | + print(" WATCH THE BOTTOM ROW. Press Enter when ready.") |
| 188 | + with contextlib.suppress(EOFError): |
| 189 | + input() |
| 190 | + data["decstbm_probe"] = probe_decstbm(out, rows) |
| 191 | + |
| 192 | + section("Report") |
| 193 | + print(" console mode before :", data["console_mode_before"]) |
| 194 | + print(" console mode after :", data["decstbm_probe"]["console_mode_after"]) |
| 195 | + print(" modes match :", data["console_mode_before"] == data["decstbm_probe"]["console_mode_after"]) |
| 196 | + print("\n Answer in the result template:") |
| 197 | + print(" 1. Did TOOLBARMARKER stay on the bottom row for the whole scroll?") |
| 198 | + print(" 2. Is the scrollback complete (probe line 0001 upward) and in order?") |
| 199 | + print(" 3. Does TOOLBARMARKER appear anywhere in the scrollback? (it must not)") |
| 200 | + print(" 4. After this program exits, does the shell prompt behave normally?") |
| 201 | + |
| 202 | + # Write beside this script rather than into whatever directory the tester |
| 203 | + # happened to be in, so the artifact is easy to find and collect. |
| 204 | + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), f"windows_toolbar_inventory_{platform.node()}.json") |
| 205 | + with open(path, "w") as fh: |
| 206 | + json.dump(data, fh, indent=2, default=str) |
| 207 | + print(f"\n machine-readable record written to: {path}") |
| 208 | + print(" Attach that file to the result template.") |
| 209 | + return 0 |
| 210 | + |
| 211 | + |
| 212 | +if __name__ == "__main__": |
| 213 | + sys.exit(main()) |
0 commit comments