Repository: urwid/urwid (language: python)

Issue:
Add Windows support for raw_display
##### Checklist
- [x] I've ensured that similar functionality has not already been implemented
- [x] I've ensured that similar functionality has not earlier been proposed and declined
- [x] I've branched off the `master` or `python-dual-support` branch
- [x] I've merged fresh upstream into my branch recently
- [x] I've ran `tox` successfully in local environment *(yes, but I do not have 2.7, 3.5, and pypy at hand)*
- [x] I've included docstrings and/or documentation and/or examples for my code (if this is a new feature)

##### Description:

Inspired by @asmith-kepler's fantastic work in #447, I set out to implement Windows support for raw_display. Turns out that this is all working pretty seamlessly with the latest Windows console improvements. 🎉 

Main changes:

 - Use `socket.socketpair()` instead of `os.pipe()` on all OSes as Windows doesn't support non-socket file descriptors in `select()`. This probably has a tiny amount of overhead, but works across all platforms. 
 - Use ctypes to interface with the Windows API, which works without any additional dependencies.
 - Use a dedicated thread to read input from the Windows console and pipe it into another socketpair, which is then processed by raw_display. This is slightly ugly, but works across all events loops.
partial native Windows support via windows-curses
### Description
I have managed to get partial support for urwid on Windows 10 and Python 3 using the windows-curses package. A few things don't work, but it works well enough for my needs.

**What works:**
- curses display
- most widgets
- most special keys
- console resizing

**What doesn't work:**
- unicode (including the default symbols for LineBox, etc.)
- mouse events
- high-colour modes (I don't think these are supported by windows-curses)

I don't know enough about the curses library or ANSI control sequences to debug further. I'm posting this here in the hopes that a) someone else finds it useful, and b) someone more knowledgeable can use it as a starting point to fix the remaining issues (especially unicode).

### Patch
```python
"""
Patch urwid on Windows and WSL.

On Windows, install the package "windows-curses".
"""
import curses
import platform
import re

import urwid
import urwid.curses_display
import urwid.raw_display

IS_WSL = ("Linux" in platform.platform()) and ("Microsoft" in platform.platform())
IS_WINDOWS = ("Windows" in platform.platform()) and ("Linux" not in platform.platform())

if IS_WSL:
    # Filter out SI/SO under WSL. See:
    # https://github.com/mitmproxy/mitmproxy/blob/8dfb8a9a7471e00b0f723de66d3b63bd089e9802/mitmproxy/tools/console/window.py#L316-L323
    wsl_patch_orig_write = urwid.raw_display.Screen.write
    wsl_patch_write_re = re.compile("[\x0e\x0f]")

    def wsl_patch_write(self, data):
        data = wsl_patch_write_re.sub("", data)
        return wsl_patch_orig_write(self, data)

    urwid.raw_display.Screen.write = wsl_patch_write

if IS_WINDOWS:
    def win_patch_tty_signal_keys(self, intr=None, quit=None, start=None, stop=None, susp=None, fileno=None):
        pass  # no signals on Windows; not needed for resize
    urwid.display_common.RealTerminal.tty_signal_keys = win_patch_tty_signal_keys

    win_patch_orig__start = urwid.curses_display.Screen._start
    def win_patch__start(self):
        win_patch_orig__start(self)
        # halfdelay() seems unnecessary and causes everything to slow down a lot.
        curses.nocbreak()  # exits halfdelay mode
        # keypad(1) is needed, or we get no special keys (cursor keys, etc.)
        self.s.keypad(1)
    urwid.curses_display.Screen._start = win_patch__start

    # The getch functions in urwid.curses_display really don't work well on Windows
    # for some reason. You end up with >1s delays on input. It looks like halfdelay()
    # might work differently under windows-curses (which wraps PDCurses), or possibly
    # not support delays and blocking/non-blocking modes exactly correctly. These rewrites
    # simplify urwid's implementation a lot: they ignore most of urwid's delay handling,
    # but they seem to work ok.
    def win_patch__getch(self, wait_tenths):
        if wait_tenths == 0:
            return self._getch_nodelay()

        self.s.nodelay(False)
        return self.s.getch()
    urwid.curses_display.Screen._getch = win_patch__getch

    def win_patch__getch_nodelay(self):
        self.s.nodelay(True)
        return self.s.getch()
    urwid.curses_display.Screen._getch_nodelay = win_patch__getch_nodelay

    # The Windows terminal uses a different colour numbering.
    def win_patch__setup_colour_pairs(self):
        if not self.has_color:
            return

        self.has_default_colors = False
        colour_correct = [
            curses.COLOR_BLACK,
            curses.COLOR_RED,
            curses.COLOR_GREEN,
            curses.COLOR_YELLOW,
            curses.COLOR_BLUE,
            curses.COLOR_MAGENTA,
            curses.COLOR_CYAN,
            curses.COLOR_WHITE,
        ]
        for fg in range(8):
            for bg in range(8):
                if fg == curses.COLOR_WHITE and \
                   bg == curses.COLOR_BLACK:
                    continue

                curses.init_pair(bg * 8 + 7 - fg, colour_correct[fg], colour_correct[bg])
    urwid.curses_display.Screen._setup_colour_pairs = win_patch__setup_colour_pairs

    # Default to curses on Windows
    win_patch_orig_main_loop___init__ = urwid.main_loop.MainLoop.__init__
    def win_patch_main_loop__init__(self, widget, palette=(), screen=None,
            handle_mouse=True, input_filter=None, unhandled_input=None,
            event_loop=None, pop_ups=False):
        if screen == None:
            screen = urwid.curses_display.Screen()
        win_patch_orig_main_loop___init__(self, widget, palette, screen,
            handle_mouse, input_filter, unhandled_input, event_loop, pop_ups)
    urwid.main_loop.MainLoop.__init__ = win_patch_main_loop__init__

    # Some special keys seem to have different key codes under Windows.
    urwid.escape._keyconv[351] = 'shift tab'
    urwid.escape._keyconv[358] = 'end'
    urwid.curses_display.KEY_RESIZE = 546
```

### Usage
Save the above as `urwid_win_patch.py`. To use it:
```python
import urwid
import urwid_win_patch

# use urwid normally
```

For convenience, the above patch also incorporates the change from [issue 264](https://github.com/urwid/urwid/issues/264) when using Windows Subsystem for Linux. 

### Tested With
- Windows 10.0.18363.1256
- python 3.6.5, 32-bit
- windows-curses 2.2.0
- urwid 2.1.2

### Discussion
- One obvious change was getting rid of signals. Windows doesn't support them and doesn't use them for windows resizing events; curses gives a RESIZEKEY code instead, which urwid already knows how to handle.
- Curses delays and timeouts seem horribly broken. It's possible this is a bug in the curses library on Windows. (The windows-curses Python package is a wrapper around the PDcurses C library.) Whatever the cause, timeouts don't seem to work correctly, and halfdelay() mode in particular seemed to cause a lot of problems. I don't know enough about curses programming to understand fully the consequences/subtleties of what the original curses_display code was trying to do. What I have done is a a hack, but it seems to work.
- The Windows console (or perhaps windows-curses) seems to enumerate the 8 standard colours in a different order. I've fixed that, above.
- Some of the special keys seem to have different key codes under curses-windows. I have tested the following: arrow keys, escape, backspace, delete, tab/shift-tab, home, end, and function keys. I did _not_ test with all possible combinations of shift/ctrl/meta, so it's entirely possible I've missed some. But those allow for basic navigation between widgets. If you find other missing/different key codes you care about, add them to the `urwid.escape._keyconv` dictionary, above.
- I originally took a look at the raw_display module, which would have allowed high-colour modes. That would require a lot more work to port to Windows - probably a rewrite using msvcrt.
- I'm not sure why unicode doesn't work: Python 3 and windows-curses do both support Unicode, and `curses.addstr(u"string with unicode characters like é and ├─┤")` does work with windows-curses, so it's possible this is an urwid bug (perhaps to do with default locales?), but that's speculation on my part.

NameError: name 'fcntl' is not defined Windows 10 python 3.6
Using Xonsh + Exofrills editor (xo) which has `urwid` dependency

Getting the following traceback:

```python
$ xo .xonshrc
xonsh: To log full traceback to a file set: $XONSH_TRACEBACK_LOGFILE = <filename>
Traceback (most recent call last):
  File "C:\Users\cpye\Anaconda3\lib\site-packages\xonsh\__amalgam__.py", line 13343, in wait
    r = self.f(self.args, stdin, stdout, stderr, spec)
  File "C:\Users\cpye\Anaconda3\lib\site-packages\xonsh\__amalgam__.py", line 12885, in proxy_two
    return f(args, stdin)
  File "C:\Users\cpye\Anaconda3\lib\site-packages\xontrib\xo.py", line 8, in _xo
    _main(args=args)
  File "C:\Users\cpye\Anaconda3\lib\site-packages\xo.py", line 1005, in main
    main_display.main(line, col)
  File "C:\Users\cpye\Anaconda3\lib\site-packages\xo.py", line 756, in main
    unhandled_input=self.unhandled_keypress)
  File "C:\Users\cpye\Anaconda3\lib\site-packages\urwid\main_loop.py", line 111, in __init__
    screen = raw_display.Screen()
  File "C:\Users\cpye\Anaconda3\lib\site-packages\urwid\raw_display.py", line 89, in __init__
    fcntl.fcntl(self._resize_pipe_rd, fcntl.F_SETFL, os.O_NONBLOCK)
NameError: name 'fcntl' is not defined
```
partial native Windows support via windows-curses
### Description
I have managed to get partial support for urwid on Windows 10 and Python 3 using the windows-curses package. A few things don't work, but it works well enough for my needs.

**What works:**
- curses display
- most widgets
- most special keys
- console resizing

**What doesn't work:**
- unicode (including the default symbols for LineBox, etc.)
- mouse events
- high-colour modes (I don't think these are supported by windows-curses)

I don't know enough about the curses library or ANSI control sequences to debug further. I'm posting this here in the hopes that a) someone else finds it useful, and b) someone more knowledgeable can use it as a starting point to fix the remaining issues (especially unicode).

### Patch
```python
"""
Patch urwid on Windows and WSL.

On Windows, install the package "windows-curses".
"""
import curses
import platform
import re

import urwid
import urwid.curses_display
import urwid.raw_display

IS_WSL = ("Linux" in platform.platform()) and ("Microsoft" in platform.platform())
IS_WINDOWS = ("Windows" in platform.platform()) and ("Linux" not in platform.platform())

if IS_WSL:
    # Filter out SI/SO under WSL. See:
    # https://github.com/mitmproxy/mitmproxy/blob/8dfb8a9a7471e00b0f723de66d3b63bd089e9802/mitmproxy/tools/console/window.py#L316-L323
    wsl_patch_orig_write = urwid.raw_display.Screen.write
    wsl_patch_write_re = re.compile("[\x0e\x0f]")

    def wsl_patch_write(self, data):
        data = wsl_patch_write_re.sub("", data)
        return wsl_patch_orig_write(self, data)

    urwid.raw_display.Screen.write = wsl_patch_write

if IS_WINDOWS:
    def win_patch_tty_signal_keys(self, intr=None, quit=None, start=None, stop=None, susp=None, fileno=None):
        pass  # no signals on Windows; not needed for resize
    urwid.display_common.RealTerminal.tty_signal_keys = win_patch_tty_signal_keys

    win_patch_orig__start = urwid.curses_display.Screen._start
    def win_patch__start(self):
        win_patch_orig__start(self)
        # halfdelay() seems unnecessary and causes everything to slow down a lot.
        curses.nocbreak()  # exits halfdelay mode
        # keypad(1) is needed, or we get no special keys (cursor keys, etc.)
        self.s.keypad(1)
    urwid.curses_display.Screen._start = win_patch__start

    # The getch functions in urwid.curses_display really don't work well on Windows
    # for some reason. You end up with >1s delays on input. It looks like halfdelay()
    # might work differently under windows-curses (which wraps PDCurses), or possibly
    # not support delays and blocking/non-blocking modes exactly correctly. These rewrites
    # simplify urwid's implementation a lot: they ignore most of urwid's delay handling,
    # but they seem to work ok.
    def win_patch__getch(self, wait_tenths):
        if wait_tenths == 0:
            return self._getch_nodelay()

        self.s.nodelay(False)
        return self.s.getch()
    urwid.curses_display.Screen._getch = win_patch__getch

    def win_patch__getch_nodelay(self):
        self.s.nodelay(True)
        return self.s.getch()
    urwid.curses_display.Screen._getch_nodelay = win_patch__getch_nodelay

    # The Windows terminal uses a different colour numbering.
    def win_patch__setup_colour_pairs(self):
        if not self.has_color:
            return

        self.has_default_colors = False
        colour_correct = [
            curses.COLOR_BLACK,
            curses.COLOR_RED,
            curses.COLOR_GREEN,
            curses.COLOR_YELLOW,
            curses.COLOR_BLUE,
            curses.COLOR_MAGENTA,
            curses.COLOR_CYAN,
            curses.COLOR_WHITE,
        ]
        for fg in range(8):
            for bg in range(8):
                if fg == curses.COLOR_WHITE and \
                   bg == curses.COLOR_BLACK:
                    continue

                curses.init_pair(bg * 8 + 7 - fg, colour_correct[fg], colour_correct[bg])
    urwid.curses_display.Screen._setup_colour_pairs = win_patch__setup_colour_pairs

    # Default to curses on Windows
    win_patch_orig_main_loop___init__ = urwid.main_loop.MainLoop.__init__
    def win_patch_main_loop__init__(self, widget, palette=(), screen=None,
            handle_mouse=True, input_filter=None, unhandled_input=None,
            event_loop=None, pop_ups=False):
        if screen == None:
            screen = urwid.curses_display.Screen()
        win_patch_orig_main_loop___init__(self, widget, palette, screen,
            handle_mouse, input_filter, unhandled_input, event_loop, pop_ups)
    urwid.main_loop.MainLoop.__init__ = win_patch_main_loop__init__

    # Some special keys seem to have different key codes under Windows.
    urwid.escape._keyconv[351] = 'shift tab'
    urwid.escape._keyconv[358] = 'end'
    urwid.curses_display.KEY_RESIZE = 546
```

### Usage
Save the above as `urwid_win_patch.py`. To use it:
```python
import urwid
import urwid_win_patch

# use urwid normally
```

For convenience, the above patch also incorporates the change from [issue 264](https://github.com/urwid/urwid/issues/264) when using Windows Subsystem for Linux. 

### Tested With
- Windows 10.0.18363.1256
- python 3.6.5, 32-bit
- windows-curses 2.2.0
- urwid 2.1.2

### Discussion
- One obvious change was getting rid of signals. Windows doesn't support them and doesn't use them for windows resizing events; curses gives a RESIZEKEY code instead, which urwid already knows how to handle.
- Curses delays and timeouts seem horribly broken. It's possible this is a bug in the curses library on Windows. (The windows-curses Python package is a wrapper around the PDcurses C library.) Whatever the cause, timeouts don't seem to work correctly, and halfdelay() mode in particular seemed to cause a lot of problems. I don't know enough about curses programming to understand fully the consequences/subtleties of what the original curses_display code was trying to do. What I have done is a a hack, but it seems to work.
- The Windows console (or perhaps windows-curses) seems to enumerate the 8 standard colours in a different order. I've fixed that, above.
- Some of the special keys seem to have different key codes under curses-windows. I have tested the following: arrow keys, escape, backspace, delete, tab/shift-tab, home, end, and function keys. I did _not_ test with all possible combinations of shift/ctrl/meta, so it's entirely possible I've missed some. But those allow for basic navigation between widgets. If you find other missing/different key codes you care about, add them to the `urwid.escape._keyconv` dictionary, above.
- I originally took a look at the raw_display module, which would have allowed high-colour modes. That would require a lot more work to port to Windows - probably a rewrite using msvcrt.
- I'm not sure why unicode doesn't work: Python 3 and windows-curses do both support Unicode, and `curses.addstr(u"string with unicode characters like é and ├─┤")` does work with windows-curses, so it's possible this is an urwid bug (perhaps to do with default locales?), but that's speculation on my part.



Discussion / hints:

[![Coverage Status](https://coveralls.io/builds/36085418/badge)](https://coveralls.io/builds/36085418)

Coverage decreased (-0.8%) to 77.084% when pulling **f4fd66951b347e7ac83e2fe2f9ff801ceefbbb35 on mhils:win32-rawdisplay** into **4c739b6be21b0c98324e7b7780d8e712f3ad6db3 on urwid:master**.

@asmith-kepler 👍 thanks for working on this and sharing. Your existing implementation was enough for me to get https://github.com/insanum/sncli/issues/119 (SimpleNote command line tool) working :-) With only:

* the import hack 
* python -m pip install windows-curses

This was with Windows 10 and CPython 3.10 and:

* urwid version 2.1.2
* windows-curses version 2.3.0
* sncli version 0.4.1 (from git)

The lack of Unicode support is not an issue for my current experiments as I'm using an original CMD.exe window which itself has no Unicode support 😆. I **might** look into alternative consoles.
The new Python3.12, windows-curses 2.3.2 and urwid 2.2.3/2.1.2 are incompatible with this patch.
I tested on my Windows 10 workstation and the patch here. It failed the sample code of pop_up.py.
> The new Python3.12, windows-curses 2.3.2 and urwid 2.2.3/2.1.2 are incompatible with this patch. I tested on my Windows 10 workstation and the patch here. It failed the sample code of pop_up.py.

The python process quit silently on my machine. Is there any plan to fix this?
`fcntl` does not exist on Windows IIRC
Are there plans to remedy this? I'm interested in trying exofrills out but am unable to use it in my current environment...
_*bump*_
Hey, is there any solution to this problem?
**Issue remains** on Python 3.11.5, Windows 10 LTSC, and Urwid 2.2.2
PR for windows support need to be rebased and fixed.

Op za 30 sep. 2023 14:43 schreef Nikita Beloglazov ***@***.***
>:

> *bump*
> Hey, is there any solution to this problem?
> Issue remains on Python 3.11.5 and Windows 10 LTSC
> Urwid 2.2.2
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/urwid/urwid/issues/240#issuecomment-1741758086>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AD2KMMYEZLJTKMOLA2YHIFTX5AHYLANCNFSM4DQTMMPQ>
> .
> You are receiving this because you are subscribed to this thread.Message
> ID: ***@***.***>
>

@asmith-kepler 👍 thanks for working on this and sharing. Your existing implementation was enough for me to get https://github.com/insanum/sncli/issues/119 (SimpleNote command line tool) working :-) With only:

* the import hack 
* python -m pip install windows-curses

This was with Windows 10 and CPython 3.10 and:

* urwid version 2.1.2
* windows-curses version 2.3.0
* sncli version 0.4.1 (from git)

The lack of Unicode support is not an issue for my current experiments as I'm using an original CMD.exe window which itself has no Unicode support 😆. I **might** look into alternative consoles.
The new Python3.12, windows-curses 2.3.2 and urwid 2.2.3/2.1.2 are incompatible with this patch.
I tested on my Windows 10 workstation and the patch here. It failed the sample code of pop_up.py.
> The new Python3.12, windows-curses 2.3.2 and urwid 2.2.3/2.1.2 are incompatible with this patch. I tested on my Windows 10 workstation and the patch here. It failed the sample code of pop_up.py.

The python process quit silently on my machine. Is there any plan to fix this?

Fix patch:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index e17c373e7..525405393 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -32,10 +32,9 @@ jobs:
     strategy:
       max-parallel: 6
       matrix:
-        os: [ "ubuntu-latest" ]
-        # , "macos-latest", "windows-latest"
+        os: [ "ubuntu-latest", "windows-latest" ]
+        # , "macos-latest"
         # enable macOS only if OS specific branch will be added
-        # enable windows after Windows driver will be added
         python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11", "3.12" ]
 
     steps:
@@ -155,7 +154,10 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        os: [ "ubuntu-latest", "macos-latest" ]
+        os:
+          - ubuntu-latest
+          - macos-latest
+          - windows-latest
 
     steps:
       - uses: actions/checkout@v4
@@ -180,6 +182,7 @@ jobs:
           # emulated ones
           CIBW_ARCHS_LINUX: auto aarch64
           CIBW_ARCHS_MACOS: x86_64 universal2 arm64
+          CIBW_ARCHS_WINDOWS: auto
 
       - uses: actions/upload-artifact@v3
         with:
diff --git a/urwid/__init__.py b/urwid/__init__.py
index 80dfa6b8a..f59a782d8 100644
--- a/urwid/__init__.py
+++ b/urwid/__init__.py
@@ -21,6 +21,8 @@
 
 from __future__ import annotations
 
+import os
+
 from urwid import raw_display
 from urwid.canvas import (
     BlankCanvas,
@@ -121,7 +123,6 @@
 )
 from urwid.version import version as __version__
 from urwid.version import version_tuple as __version_tuple__
-from urwid.vterm import TermCanvas, TermCharset, Terminal, TermModes, TermScroller
 from urwid.widget import (
     ANY,
     BOTTOM,
@@ -229,10 +230,15 @@
 except ImportError:
     pass
 
-try:
-    from urwid.event_loop import ZMQEventLoop
-except ImportError:
-    pass
+# OS Specific
+if os.name != "nt":
+    from urwid.vterm import TermCanvas, TermCharset, Terminal, TermModes, TermScroller
+
+    # ZMQEventLoop cause interpreter crash on windows
+    try:
+        from urwid.event_loop import ZMQEventLoop
+    except ImportError:
+        pass
 
 # Backward compatibility
 VERSION = __version_tuple__
diff --git a/urwid/_posix_raw_display.py b/urwid/_posix_raw_display.py
new file mode 100644
index 000000000..97cf159aa
--- /dev/null
+++ b/urwid/_posix_raw_display.py
@@ -0,0 +1,486 @@
+# Urwid raw display module
+#    Copyright (C) 2004-2009  Ian Ward
+#
+#    This library is free software; you can redistribute it and/or
+#    modify it under the terms of the GNU Lesser General Public
+#    License as published by the Free Software Foundation; either
+#    version 2.1 of the License, or (at your option) any later version.
+#
+#    This library is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+#    Lesser General Public License for more details.
+#
+#    You should have received a copy of the GNU Lesser General Public
+#    License along with this library; if not, write to the Free Software
+#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+# Urwid web site: https://urwid.org/
+
+
+"""
+Direct terminal UI implementation
+"""
+
+from __future__ import annotations
+
+import contextlib
+import fcntl
+import functools
+import os
+import signal
+import struct
+import sys
+import termios
+import tty
+import typing
+from subprocess import PIPE, Popen
+
+from urwid import escape, signals
+from urwid.display_common import INPUT_DESCRIPTORS_CHANGED
+
+from . import _raw_display_base
+
+if typing.TYPE_CHECKING:
+    import io
+    from types import FrameType
+
+    from typing_extensions import Literal
+
+
+class Screen(_raw_display_base.Screen):
+    def __init__(
+        self,
+        input: io.TextIOBase = sys.stdin,  # noqa: A002
+        output: io.TextIOBase = sys.stdout,
+        bracketed_paste_mode=False,
+    ) -> None:
+        """Initialize a screen that directly prints escape codes to an output
+        terminal.
+
+        bracketed_paste_mode -- enable bracketed paste mode in the host terminal.
+            If the host terminal supports it, the application will receive `begin paste`
+            and `end paste` keystrokes when the user pastes text.
+        """
+        super().__init__(input, output)
+        self.gpm_mev: Popen | None = None
+        self.gpm_event_pending: bool = False
+        self.bracketed_paste_mode = bracketed_paste_mode
+
+        # These store the previous signal handlers after setting ours
+        self._prev_sigcont_handler = None
+        self._prev_sigtstp_handler = None
+        self._prev_sigwinch_handler = None
+
+    def _sigwinch_handler(self, signum: int, frame: FrameType | None = None) -> None:
+        """
+        frame -- will always be None when the GLib event loop is being used.
+        """
+        super()._sigwinch_handler(signum, frame)
+
+        if callable(self._prev_sigwinch_handler):
+            self._prev_sigwinch_handler(signum, frame)
+
+    def _sigtstp_handler(self, signum: int, frame: FrameType | None = None) -> None:
+        self.stop()  # Restores the previous signal handlers
+        self._prev_sigcont_handler = self.signal_handler_setter(signal.SIGCONT, self._sigcont_handler)
+        # Handled by the previous handler.
+        # If non-default, it may set its own SIGCONT handler which should hopefully call our own.
+        os.kill(os.getpid(), signal.SIGTSTP)
+
+    def _sigcont_handler(self, signum: int, frame: FrameType | None = None) -> None:
+        """
+        frame -- will always be None when the GLib event loop is being used.
+        """
+        self.signal_restore()
+
+        if callable(self._prev_sigcont_handler):
+            # May set its own SIGTSTP handler which would be stored and replaced in
+            # `signal_init()` (via `start()`).
+            self._prev_sigcont_handler(signum, frame)
+
+        self.start()
+        self._sigwinch_handler(None, None)
+
+    def signal_init(self):
+        """
+        Called in the startup of run wrapper to set the SIGWINCH
+        and SIGTSTP signal handlers.
+
+        Override this function to call from main thread in threaded
+        applications.
+        """
+        self._prev_sigwinch_handler = self.signal_handler_setter(signal.SIGWINCH, self._sigwinch_handler)
+        self._prev_sigtstp_handler = self.signal_handler_setter(signal.SIGTSTP, self._sigtstp_handler)
+
+    def signal_restore(self):
+        """
+        Called in the finally block of run wrapper to restore the
+        SIGTSTP, SIGCONT and SIGWINCH signal handlers.
+
+        Override this function to call from main thread in threaded
+        applications.
+        """
+        self.signal_handler_setter(signal.SIGTSTP, self._prev_sigtstp_handler or signal.SIG_DFL)
+        self.signal_handler_setter(signal.SIGCONT, self._prev_sigcont_handler or signal.SIG_DFL)
+        self.signal_handler_setter(signal.SIGWINCH, self._prev_sigwinch_handler or signal.SIG_DFL)
+
+    def _mouse_tracking(self, enable: bool) -> None:
+        super()._mouse_tracking(enable)
+        if enable:
+            self._start_gpm_tracking()
+        else:
+            self._stop_gpm_tracking()
+
+    def _start_gpm_tracking(self):
+        if not os.path.isfile("/usr/bin/mev"):
+            return
+        if not os.environ.get("TERM", "").lower().startswith("linux"):
+            return
+
+        m = Popen(
+            ["/usr/bin/mev", "-e", "158"],  # noqa: S603
+            stdin=PIPE,
+            stdout=PIPE,
+            close_fds=True,
+            encoding="ascii",
+        )
+        fcntl.fcntl(m.stdout.fileno(), fcntl.F_SETFL, os.O_NONBLOCK)
+        self.gpm_mev = m
+
+    def _stop_gpm_tracking(self):
+        if not self.gpm_mev:
+            return
+        os.kill(self.gpm_mev.pid, signal.SIGINT)
+        os.waitpid(self.gpm_mev.pid, 0)
+        self.gpm_mev = None
+
+    def _start(self, alternate_buffer=True):
+        """
+        Initialize the screen and input mode.
+
+        alternate_buffer -- use alternate screen buffer
+        """
+        if alternate_buffer:
+            self.write(escape.SWITCH_TO_ALTERNATE_BUFFER)
+            self._rows_used = None
+        else:
+            self._rows_used = 0
+
+        if self.bracketed_paste_mode:
+            self.write(escape.ENABLE_BRACKETED_PASTE_MODE)
+
+        fd = self._input_fileno()
+        if fd is not None and os.isatty(fd):
+            self._old_termios_settings = termios.tcgetattr(fd)
+            tty.setcbreak(fd)
+
+        self.signal_init()
+        self._alternate_buffer = alternate_buffer
+        self._next_timeout = self.max_wait
+
+        if not self._signal_keys_set:
+            self._old_signal_keys = self.tty_signal_keys(fileno=fd)
+
+        signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
+        # restore mouse tracking to previous state
+        self._mouse_tracking(self._mouse_tracking_enabled)
+
+        return super()._start()
+
+    def _stop(self):
+        """
+        Restore the screen.
+        """
+        self.clear()
+
+        if self.bracketed_paste_mode:
+            self.write(escape.DISABLE_BRACKETED_PASTE_MODE)
+
+        signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
+
+        self.signal_restore()
+
+        fd = self._input_fileno()
+        if fd is not None and os.isatty(fd):
+            termios.tcsetattr(fd, termios.TCSADRAIN, self._old_termios_settings)
+
+        self._stop_mouse_restore_buffer()
+
+        if self._old_signal_keys:
+            self.tty_signal_keys(*self._old_signal_keys, fd)
+
+        super()._stop()
+
+    @typing.overload
+    def get_input(self, raw_keys: Literal[False]) -> list[str]:
+        ...
+
+    @typing.overload
+    def get_input(self, raw_keys: Literal[True]) -> tuple[list[str], list[int]]:
+        ...
+
+    def get_input(self, raw_keys: bool = False) -> list[str] | tuple[list[str], list[int]]:
+        """Return pending input as a list.
+
+        raw_keys -- return raw keycodes as well as translated versions
+
+        This function will immediately return all the input since the
+        last time it was called.  If there is no input pending it will
+        wait before returning an empty list.  The wait time may be
+        configured with the set_input_timeouts function.
+
+        If raw_keys is False (default) this function will return a list
+        of keys pressed.  If raw_keys is True this function will return
+        a ( keys pressed, raw keycodes ) tuple instead.
+
+        Examples of keys returned:
+
+        * ASCII printable characters:  " ", "a", "0", "A", "-", "/"
+        * ASCII control characters:  "tab", "enter"
+        * Escape sequences:  "up", "page up", "home", "insert", "f1"
+        * Key combinations:  "shift f1", "meta a", "ctrl b"
+        * Window events:  "window resize"
+
+        When a narrow encoding is not enabled:
+
+        * "Extended ASCII" characters:  "\\xa1", "\\xb2", "\\xfe"
+
+        When a wide encoding is enabled:
+
+        * Double-byte characters:  "\\xa1\\xea", "\\xb2\\xd4"
+
+        When utf8 encoding is enabled:
+
+        * Unicode characters: u"\\u00a5", u'\\u253c"
+
+        Examples of mouse events returned:
+
+        * Mouse button press: ('mouse press', 1, 15, 13),
+                              ('meta mouse press', 2, 17, 23)
+        * Mouse drag: ('mouse drag', 1, 16, 13),
+                      ('mouse drag', 1, 17, 13),
+                      ('ctrl mouse drag', 1, 18, 13)
+        * Mouse button release: ('mouse release', 0, 18, 13),
+                                ('ctrl mouse release', 0, 17, 23)
+        """
+        if not self._started:
+            raise RuntimeError
+
+        self._wait_for_input_ready(self._next_timeout)
+        keys, raw = self.parse_input(None, None, self.get_available_raw_input())
+
+        # Avoid pegging CPU at 100% when slowly resizing
+        if keys == ["window resize"] and self.prev_input_resize:
+            while True:
+                self._wait_for_input_ready(self.resize_wait)
+                keys, raw2 = self.parse_input(None, None, self.get_available_raw_input())
+                raw += raw2
+                # if not keys:
+                #     keys, raw2 = self._get_input(
+                #         self.resize_wait)
+                #     raw += raw2
+                if keys != ["window resize"]:
+                    break
+            if keys[-1:] != ["window resize"]:
+                keys.append("window resize")
+
+        if keys == ["window resize"]:
+            self.prev_input_resize = 2
+        elif self.prev_input_resize == 2 and not keys:
+            self.prev_input_resize = 1
+        else:
+            self.prev_input_resize = 0
+
+        if raw_keys:
+            return keys, raw
+        return keys
+
+    def get_input_descriptors(self) -> list[int]:
+        """
+        Return a list of integer file descriptors that should be
+        polled in external event loops to check for user input.
+
+        Use this method if you are implementing your own event loop.
+
+        This method is only called by `hook_event_loop`, so if you override
+        that, you can safely ignore this.
+        """
+        if not self._started:
+            return []
+
+        fd_list = [self._resize_pipe_rd]
+        fd = self._input_fileno()
+        if fd is not None:
+            fd_list.append(fd)
+        if self.gpm_mev is not None:
+            fd_list.append(self.gpm_mev.stdout.fileno())
+        return fd_list
+
+    def unhook_event_loop(self, event_loop):
+        """
+        Remove any hooks added by hook_event_loop.
+        """
+        for handle in self._current_event_loop_handles:
+            event_loop.remove_watch_file(handle)
+
+        if self._input_timeout:
+            event_loop.remove_alarm(self._input_timeout)
+            self._input_timeout = None
+
+    def hook_event_loop(self, event_loop, callback):
+        """
+        Register the given callback with the event loop, to be called with new
+        input whenever it's available.  The callback should be passed a list of
+        processed keys and a list of unprocessed keycodes.
+
+        Subclasses may wish to use parse_input to wrap the callback.
+        """
+        if hasattr(self, "get_input_nonblocking"):
+            wrapper = self._make_legacy_input_wrapper(event_loop, callback)
+        else:
+
+            @functools.wraps(callback)
+            def wrapper() -> tuple[list[str], typing.Any] | None:
+                return self.parse_input(event_loop, callback, self.get_available_raw_input())
+
+        fds = self.get_input_descriptors()
+        handles = [event_loop.watch_file(fd, wrapper) for fd in fds]
+        self._current_event_loop_handles = handles
+
+    def _get_input_codes(self) -> list[int]:
+        return super()._get_input_codes() + self._get_gpm_codes()
+
+    def _get_gpm_codes(self):
+        codes = []
+        try:
+            while self.gpm_mev is not None and self.gpm_event_pending:
+                codes.extend(self._encode_gpm_event())
+        except OSError as e:
+            if e.args[0] != 11:
+                raise
+        return codes
+
+    def _getch(self, timeout: int) -> int:
+        ready = self._wait_for_input_ready(timeout)
+        if self.gpm_mev is not None and self.gpm_mev.stdout.fileno() in ready:
+            self.gpm_event_pending = True
+        fd = self._input_fileno()
+        if fd is not None and fd in ready:
+            return ord(os.read(fd, 1))
+        return -1
+
+    def _encode_gpm_event(self) -> list[int]:
+        self.gpm_event_pending = False
+        s = self.gpm_mev.stdout.readline()
+        result = s.split(", ")
+        if len(result) != 6:
+            # unexpected output, stop tracking
+            self._stop_gpm_tracking()
+            signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
+            return []
+        ev, x, y, _ign, b, m = s.split(",")
+        ev = int(ev.split("x")[-1], 16)
+        x = int(x.split(" ")[-1])
+        y = int(y.lstrip().split(" ")[0])
+        b = int(b.split(" ")[-1])
+        m = int(m.split("x")[-1].rstrip(), 16)
+
+        # convert to xterm-like escape sequence
+
+        last_state = next_state = self.last_bstate
+        result = []
+
+        mod = 0
+        if m & 1:
+            mod |= 4  # shift
+        if m & 10:
+            mod |= 8  # alt
+        if m & 4:
+            mod |= 16  # ctrl
+
+        def append_button(b):
+            b |= mod
+            result.extend([27, ord("["), ord("M"), b + 32, x + 32, y + 32])
+
+        def determine_button_release(flag: int) -> None:
+            nonlocal next_state
+            if b & 4 and last_state & 1:
+                append_button(0 + flag)
+                next_state |= 1
+            if b & 2 and last_state & 2:
+                append_button(1 + flag)
+                next_state |= 2
+            if b & 1 and last_state & 4:
+                append_button(2 + flag)
+                next_state |= 4
+
+        if ev in {20, 36, 52}:  # press
+            if b & 4 and last_state & 1 == 0:
+                append_button(0)
+                next_state |= 1
+            if b & 2 and last_state & 2 == 0:
+                append_button(1)
+                next_state |= 2
+            if b & 1 and last_state & 4 == 0:
+                append_button(2)
+                next_state |= 4
+        elif ev == 146:  # drag
+            if b & 4:
+                append_button(0 + escape.MOUSE_DRAG_FLAG)
+            elif b & 2:
+                append_button(1 + escape.MOUSE_DRAG_FLAG)
+            elif b & 1:
+                append_button(2 + escape.MOUSE_DRAG_FLAG)
+        else:  # release
+            if b & 4 and last_state & 1:
+                append_button(0 + escape.MOUSE_RELEASE_FLAG)
+                next_state &= ~1
+            if b & 2 and last_state & 2:
+                append_button(1 + escape.MOUSE_RELEASE_FLAG)
+                next_state &= ~2
+            if b & 1 and last_state & 4:
+                append_button(2 + escape.MOUSE_RELEASE_FLAG)
+                next_state &= ~4
+        if ev == 40:  # double click (release)
+            if b & 4 and last_state & 1:
+                append_button(0 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
+            if b & 2 and last_state & 2:
+                append_button(1 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
+            if b & 1 and last_state & 4:
+                append_button(2 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
+        elif ev == 52:
+            if b & 4 and last_state & 1:
+                append_button(0 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
+            if b & 2 and last_state & 2:
+                append_button(1 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
+            if b & 1 and last_state & 4:
+                append_button(2 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
+
+        self.last_bstate = next_state
+        return result
+
+    def get_cols_rows(self) -> tuple[int, int]:
+        """Return the terminal dimensions (num columns, num rows)."""
+        y, x = super().get_cols_rows()
+        with contextlib.suppress(OSError):  # Term size could not be determined
+            if hasattr(self._term_output_file, "fileno"):
+                buf = fcntl.ioctl(self._term_output_file.fileno(), termios.TIOCGWINSZ, " " * 4)
+                y, x = struct.unpack("hh", buf)
+
+        # Provide some lightweight fallbacks in case the TIOCWINSZ doesn't
+        # give sane answers
+        if (x <= 0 or y <= 0) and self.term in {"ansi", "vt100"}:
+            y, x = 24, 80
+        self.maxrow = y
+        return x, y
+
+
+def _test():
+    import doctest
+
+    doctest.testmod()
+
+
+if __name__ == "__main__":
+    _test()
diff --git a/urwid/_raw_display_base.py b/urwid/_raw_display_base.py
new file mode 100644
index 000000000..71098f4f9
--- /dev/null
+++ b/urwid/_raw_display_base.py
@@ -0,0 +1,828 @@
+# Urwid raw display module
+#    Copyright (C) 2004-2009  Ian Ward
+#
+#    This library is free software; you can redistribute it and/or
+#    modify it under the terms of the GNU Lesser General Public
+#    License as published by the Free Software Foundation; either
+#    version 2.1 of the License, or (at your option) any later version.
+#
+#    This library is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+#    Lesser General Public License for more details.
+#
+#    You should have received a copy of the GNU Lesser General Public
+#    License along with this library; if not, write to the Free Software
+#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+# Urwid web site: https://urwid.org/
+
+
+"""
+Direct terminal UI implementation
+"""
+
+from __future__ import annotations
+
+import abc
+import contextlib
+import functools
+import os
+import selectors
+import signal
+import socket
+import typing
+
+from urwid import escape, signals, util
+from urwid.display_common import UNPRINTABLE_TRANS_TABLE, UPDATE_PALETTE_ENTRY, AttrSpec, BaseScreen, RealTerminal
+
+if typing.TYPE_CHECKING:
+    from types import FrameType
+
+    from typing_extensions import Literal
+
+    class FileLikeObj(typing.Protocol):
+        def fileno(self) -> int:
+            ...
+
+
+class Screen(BaseScreen, RealTerminal):
+    def __init__(self, input: FileLikeObj, output: FileLikeObj):  # noqa: A002
+        """Initialize a screen that directly prints escape codes to an output
+        terminal.
+        """
+        super().__init__()
+        self._pal_escape = {}
+        self._pal_attrspec = {}
+        signals.connect_signal(self, UPDATE_PALETTE_ENTRY, self._on_update_palette_entry)
+        self.colors: Literal[1, 16, 88, 256, 16777216] = 16  # FIXME: detect this
+        self.has_underline = True  # FIXME: detect this
+        self._keyqueue = []
+        self.prev_input_resize = 0
+        self.set_input_timeouts()
+        self.screen_buf = None
+        self._screen_buf_canvas = None
+        self._resized = False
+        self.maxrow = None
+        self._mouse_tracking_enabled = False
+        self.last_bstate = 0
+        self._setup_G1_done = False
+        self._rows_used = None
+        self._cy = 0
+        self.term = os.environ.get("TERM", "")
+        self.fg_bright_is_bold = not self.term.startswith("xterm")
+        self.bg_bright_is_blink = self.term == "linux"
+        self.back_color_erase = not self.term.startswith("screen")
+        self.register_palette_entry(None, "default", "default")
+        self._next_timeout = None
+        self.signal_handler_setter = signal.signal
+
+        # Our connections to the world
+        self._term_output_file = output
+        self._term_input_file = input
+
+        # pipe for signalling external event loops about resize events
+        self._resize_pipe_rd, self._resize_pipe_wr = socket.socketpair()
+        self._resize_pipe_rd.setblocking(False)
+
+    def _sigwinch_handler(self, signum: int, frame: FrameType | None = None) -> None:
+        """
+        frame -- will always be None when the GLib event loop is being used.
+        """
+
+        if not self._resized:
+            self._resize_pipe_wr.send(b"R")
+        self._resized = True
+        self.screen_buf = None
+
+    def _input_fileno(self):
+        """Returns the fileno of the input stream, or None if it doesn't have one.
+
+        A stream without a fileno can't participate in whatever.
+        """
+        if hasattr(self._term_input_file, "fileno"):
+            return self._term_input_file.fileno()
+
+        return None
+
+    def _on_update_palette_entry(self, name, *attrspecs):
+        # copy the attribute to a dictionary containing the escape seqences
+        a = attrspecs[{16: 0, 1: 1, 88: 2, 256: 3, 2**24: 4}[self.colors]]
+        self._pal_attrspec[name] = a
+        self._pal_escape[name] = self._attrspec_to_escape(a)
+
+    def set_input_timeouts(
+        self,
+        max_wait: float | None = None,
+        complete_wait: float = 0.125,
+        resize_wait: float = 0.125,
+    ) -> None:
+        """
+        Set the get_input timeout values.  All values are in floating
+        point numbers of seconds.
+
+        max_wait -- amount of time in seconds to wait for input when
+            there is no input pending, wait forever if None
+        complete_wait -- amount of time in seconds to wait when
+            get_input detects an incomplete escape sequence at the
+            end of the available input
+        resize_wait -- amount of time in seconds to wait for more input
+            after receiving two screen resize requests in a row to
+            stop Urwid from consuming 100% cpu during a gradual
+            window resize operation
+        """
+        self.max_wait = max_wait
+        if max_wait is not None:
+            if self._next_timeout is None:
+                self._next_timeout = max_wait
+            else:
+                self._next_timeout = min(self._next_timeout, self.max_wait)
+        self.complete_wait = complete_wait
+        self.resize_wait = resize_wait
+
+    def set_mouse_tracking(self, enable: bool = True) -> None:
+        """
+        Enable (or disable) mouse tracking.
+
+        After calling this function get_input will include mouse
+        click events along with keystrokes.
+        """
+        enable = bool(enable)
+        if enable == self._mouse_tracking_enabled:
+            return
+
+        self._mouse_tracking(enable)
+        self._mouse_tracking_enabled = enable
+
+    def _mouse_tracking(self, enable: bool) -> None:
+        if enable:
+            self.write(escape.MOUSE_TRACKING_ON)
+        else:
+            self.write(escape.MOUSE_TRACKING_OFF)
+
+    @abc.abstractmethod
+    def _start(self, alternate_buffer=True):
+        """
+        Initialize the screen and input mode.
+
+        alternate_buffer -- use alternate screen buffer
+        """
+
+    def _stop_mouse_restore_buffer(self) -> None:
+        """Stop mouse tracking and restore the screen."""
+        self._mouse_tracking(False)
+
+        move_cursor = ""
+        if self._alternate_buffer:
+            move_cursor = escape.RESTORE_NORMAL_BUFFER
+        elif self.maxrow is not None:
+            move_cursor = escape.set_cursor_position(0, self.maxrow)
+        self.write(self._attrspec_to_escape(AttrSpec("", "")) + escape.SI + move_cursor + escape.SHOW_CURSOR)
+        self.flush()
+
+    @abc.abstractmethod
+    def _stop(self):
+        """
+        Restore the screen.
+        """
+
+    def write(self, data):
+        """Write some data to the terminal.
+
+        You may wish to override this if you're using something other than
+        regular files for input and output.
+        """
+        self._term_output_file.write(data)
+
+    def flush(self):
+        """Flush the output buffer.
+
+        You may wish to override this if you're using something other than
+        regular files for input and output.
+        """
+        self._term_output_file.flush()
+
+    @typing.overload
+    def get_input(self, raw_keys: Literal[False]) -> list[str]:
+        ...
+
+    @typing.overload
+    def get_input(self, raw_keys: Literal[True]) -> tuple[list[str], list[int]]:
+        ...
+
+    def get_input(self, raw_keys: bool = False) -> list[str] | tuple[list[str], list[int]]:
+        """Return pending input as a list.
+
+        raw_keys -- return raw keycodes as well as translated versions
+
+        This function will immediately return all the input since the
+        last time it was called.  If there is no input pending it will
+        wait before returning an empty list.  The wait time may be
+        configured with the set_input_timeouts function.
+
+        If raw_keys is False (default) this function will return a list
+        of keys pressed.  If raw_keys is True this function will return
+        a ( keys pressed, raw keycodes ) tuple instead.
+
+        Examples of keys returned:
+
+        * ASCII printable characters:  " ", "a", "0", "A", "-", "/"
+        * ASCII control characters:  "tab", "enter"
+        * Escape sequences:  "up", "page up", "home", "insert", "f1"
+        * Key combinations:  "shift f1", "meta a", "ctrl b"
+        * Window events:  "window resize"
+
+        When a narrow encoding is not enabled:
+
+        * "Extended ASCII" characters:  "\\xa1", "\\xb2", "\\xfe"
+
+        When a wide encoding is enabled:
+
+        * Double-byte characters:  "\\xa1\\xea", "\\xb2\\xd4"
+
+        When utf8 encoding is enabled:
+
+        * Unicode characters: u"\\u00a5", u'\\u253c"
+
+        Examples of mouse events returned:
+
+        * Mouse button press: ('mouse press', 1, 15, 13),
+                              ('meta mouse press', 2, 17, 23)
+        * Mouse drag: ('mouse drag', 1, 16, 13),
+                      ('mouse drag', 1, 17, 13),
+                      ('ctrl mouse drag', 1, 18, 13)
+        * Mouse button release: ('mouse release', 0, 18, 13),
+                                ('ctrl mouse release', 0, 17, 23)
+        """
+        if not self._started:
+            raise RuntimeError
+
+        self._wait_for_input_ready(self._next_timeout)
+        keys, raw = self.parse_input(None, None, self.get_available_raw_input())
+
+        # Avoid pegging CPU at 100% when slowly resizing
+        if keys == ["window resize"] and self.prev_input_resize:
+            while True:
+                self._wait_for_input_ready(self.resize_wait)
+                keys, raw2 = self.parse_input(None, None, self.get_available_raw_input())
+                raw += raw2
+                # if not keys:
+                #     keys, raw2 = self._get_input(
+                #         self.resize_wait)
+                #     raw += raw2
+                if keys != ["window resize"]:
+                    break
+            if keys[-1:] != ["window resize"]:
+                keys.append("window resize")
+
+        if keys == ["window resize"]:
+            self.prev_input_resize = 2
+        elif self.prev_input_resize == 2 and not keys:
+            self.prev_input_resize = 1
+        else:
+            self.prev_input_resize = 0
+
+        if raw_keys:
+            return keys, raw
+        return keys
+
+    @abc.abstractmethod
+    def get_input_descriptors(self) -> list[int]:
+        """
+        Return a list of integer file descriptors that should be
+        polled in external event loops to check for user input.
+
+        Use this method if you are implementing your own event loop.
+
+        This method is only called by `hook_event_loop`, so if you override
+        that, you can safely ignore this.
+        """
+
+    _current_event_loop_handles = ()
+
+    @abc.abstractmethod
+    def unhook_event_loop(self, event_loop):
+        """
+        Remove any hooks added by hook_event_loop.
+        """
+
+    @abc.abstractmethod
+    def hook_event_loop(self, event_loop, callback):
+        """
+        Register the given callback with the event loop, to be called with new
+        input whenever it's available.  The callback should be passed a list of
+        processed keys and a list of unprocessed keycodes.
+
+        Subclasses may wish to use parse_input to wrap the callback.
+        """
+
+    _input_timeout = None
+    _partial_codes = None
+
+    def _make_legacy_input_wrapper(self, event_loop, callback):
+        """
+        Support old Screen classes that still have a get_input_nonblocking and
+        expect it to work.
+        """
+
+        @functools.wraps(callback)
+        def wrapper():
+            if self._input_timeout:
+                event_loop.remove_alarm(self._input_timeout)
+                self._input_timeout = None
+            timeout, keys, raw = self.get_input_nonblocking()
+            if timeout is not None:
+                self._input_timeout = event_loop.alarm(timeout, wrapper)
+
+            callback(keys, raw)
+
+        return wrapper
+
+    def _get_input_codes(self) -> list[int]:
+        return self._get_keyboard_codes()
+
+    def get_available_raw_input(self):
+        """
+        Return any currently-available input.  Does not block.
+
+        This method is only used by the default `hook_event_loop`
+        implementation; you can safely ignore it if you implement your own.
+        """
+        codes = self._get_input_codes()
+
+        if self._partial_codes:
+            codes = self._partial_codes + codes
+            self._partial_codes = None
+
+        # clean out the pipe used to signal external event loops
+        # that a resize has occurred
+        with contextlib.suppress(OSError):
+            while True:
+                self._resize_pipe_rd.recv(1)
+
+        return codes
+
+    def parse_input(self, event_loop, callback, codes, wait_for_more=True):
+        """
+        Read any available input from get_available_raw_input, parses it into
+        keys, and calls the given callback.
+
+        The current implementation tries to avoid any assumptions about what
+        the screen or event loop look like; it only deals with parsing keycodes
+        and setting a timeout when an incomplete one is detected.
+
+        `codes` should be a sequence of keycodes, i.e. bytes.  A bytearray is
+        appropriate, but beware of using bytes, which only iterates as integers
+        on Python 3.
+        """
+        # Note: event_loop may be None for 100% synchronous support, only used
+        # by get_input.  Not documented because you shouldn't be doing it.
+        if self._input_timeout and event_loop:
+            event_loop.remove_alarm(self._input_timeout)
+            self._input_timeout = None
+
+        original_codes = codes
+        processed = []
+        try:
+            while codes:
+                run, codes = escape.process_keyqueue(codes, wait_for_more)
+                processed.extend(run)
+        except escape.MoreInputRequired:
+            # Set a timer to wait for the rest of the input; if it goes off
+            # without any new input having come in, use the partial input
+            k = len(original_codes) - len(codes)
+            processed_codes = original_codes[:k]
+            self._partial_codes = codes
+
+            def _parse_incomplete_input():
+                self._input_timeout = None
+                self._partial_codes = None
+                self.parse_input(event_loop, callback, codes, wait_for_more=False)
+
+            if event_loop:
+                self._input_timeout = event_loop.alarm(self.complete_wait, _parse_incomplete_input)
+
+        else:
+            processed_codes = original_codes
+            self._partial_codes = None
+
+        if self._resized:
+            processed.append("window resize")
+            self._resized = False
+
+        if callback:
+            callback(processed, processed_codes)
+            return None
+
+        # For get_input
+        return processed, processed_codes
+
+    def _get_keyboard_codes(self):
+        codes = []
+        while True:
+            code = self._getch_nodelay()
+            if code < 0:
+                break
+            codes.append(code)
+        return codes
+
+    def _wait_for_input_ready(self, timeout: float | None) -> list[int]:
+        fd_list = self.get_input_descriptors()
+
+        with selectors.DefaultSelector() as selector:
+            for fd in fd_list:
+                selector.register(fd, selectors.EVENT_READ)
+            while True:
+                try:
+                    ready = [event.fd for event, _ in selector.select(timeout)]
+                    break
+                except InterruptedError:
+                    if self._resized:
+                        ready = []
+                        break
+
+        return ready
+
+    @abc.abstractmethod
+    def _getch(self, timeout: int) -> int:
+        ...
+
+    def _getch_nodelay(self):
+        return self._getch(0)
+
+    def get_cols_rows(self) -> tuple[int, int]:
+        """Return the terminal dimensions (num columns, num rows)."""
+        y, x = 24, 80
+        return x, y
+
+    def _setup_G1(self):
+        """
+        Initialize the G1 character set to graphics mode if required.
+        """
+        if self._setup_G1_done:
+            return
+
+        while True:
+            with contextlib.suppress(OSError):
+                self.write(escape.DESIGNATE_G1_SPECIAL)
+                self.flush()
+                break
+        self._setup_G1_done = True
+
+    def draw_screen(self, maxres, r):
+        """Paint screen with rendered canvas."""
+
+        (maxcol, maxrow) = maxres
+
+        if not self._started:
+            raise RuntimeError
+
+        if maxrow != r.rows():
+            raise ValueError(maxrow)
+
+        # quick return if nothing has changed
+        if self.screen_buf and r is self._screen_buf_canvas:
+            return
+
+        self._setup_G1()
+
+        if self._resized:
+            # handle resize before trying to draw screen
+            return
+
+        o = [escape.HIDE_CURSOR, self._attrspec_to_escape(AttrSpec("", ""))]
+
+        def partial_display():
+            # returns True if the screen is in partial display mode
+            # ie. only some rows belong to the display
+            return self._rows_used is not None
+
+        if not partial_display():
+            o.append(escape.CURSOR_HOME)
+
+        if self.screen_buf:
+            osb = self.screen_buf
+        else:
+            osb = []
+        sb = []
+        cy = self._cy
+        y = -1
+
+        def set_cursor_home():
+            if not partial_display():
+                return escape.set_cursor_position(0, 0)
+            return escape.CURSOR_HOME_COL + escape.move_cursor_up(cy)
+
+        def set_cursor_row(y):
+            if not partial_display():
+                return escape.set_cursor_position(0, y)
+            return escape.move_cursor_down(y - cy)
+
+        def set_cursor_position(x, y):
+            if not partial_display():
+                return escape.set_cursor_position(x, y)
+            if cy > y:
+                return "\b" + escape.CURSOR_HOME_COL + escape.move_cursor_up(cy - y) + escape.move_cursor_right(x)
+            return "\b" + escape.CURSOR_HOME_COL + escape.move_cursor_down(y - cy) + escape.move_cursor_right(x)
+
+        def is_blank_row(row):
+            if len(row) > 1:
+                return False
+            if row[0][2].strip():
+                return False
+            return True
+
+        def attr_to_escape(a):
+            if a in self._pal_escape:
+                return self._pal_escape[a]
+            if isinstance(a, AttrSpec):
+                return self._attrspec_to_escape(a)
+            # undefined attributes use default/default
+            # TODO: track and report these
+            return self._attrspec_to_escape(AttrSpec("default", "default"))
+
+        def using_standout_or_underline(a):
+            a = self._pal_attrspec.get(a, a)
+            return isinstance(a, AttrSpec) and (a.standout or a.underline)
+
+        ins = None
+        o.append(set_cursor_home())
+        cy = 0
+        for row in r.content():
+            y += 1
+            if osb and y < len(osb) and osb[y] == row:
+                # this row of the screen buffer matches what is
+                # currently displayed, so we can skip this line
+                sb.append(osb[y])
+                continue
+
+            sb.append(row)
+
+            # leave blank lines off display when we are using
+            # the default screen buffer (allows partial screen)
+            if partial_display() and y > self._rows_used:
+                if is_blank_row(row):
+                    continue
+                self._rows_used = y
+
+            if y or partial_display():
+                o.append(set_cursor_position(0, y))
+            # after updating the line we will be just over the
+            # edge, but terminals still treat this as being
+            # on the same line
+            cy = y
+
+            whitespace_at_end = False
+            if row:
+                a, cs, run = row[-1]
+                if run[-1:] == b" " and self.back_color_erase and not using_standout_or_underline(a):
+                    whitespace_at_end = True
+                    row = row[:-1] + [(a, cs, run.rstrip(b" "))]  # noqa: PLW2901
+                elif y == maxrow - 1 and maxcol > 1:
+                    row, back, ins = self._last_row(row)  # noqa: PLW2901
+
+            first = True
+            lasta = lastcs = None
+            for a, cs, run in row:
+                if not isinstance(run, bytes):  # canvases should render with bytes
+                    raise TypeError(run)
+                if cs != "U":
+                    run = run.translate(UNPRINTABLE_TRANS_TABLE)  # noqa: PLW2901
+                if first or lasta != a:
+                    o.append(attr_to_escape(a))
+                    lasta = a
+                if first or lastcs != cs:
+                    if cs not in {None, "0", "U"}:
+                        raise ValueError(cs)
+                    if lastcs == "U":
+                        o.append(escape.IBMPC_OFF)
+
+                    if cs is None:
+                        o.append(escape.SI)
+                    elif cs == "U":
+                        o.append(escape.IBMPC_ON)
+                    else:
+                        o.append(escape.SO)
+                    lastcs = cs
+                o.append(run)
+                first = False
+            if ins:
+                (inserta, insertcs, inserttext) = ins
+                ias = attr_to_escape(inserta)
+                if insertcs not in {None, "0", "U"}:
+                    raise ValueError(insertcs)
+                if cs is None:
+                    icss = escape.SI
+                elif cs == "U":
+                    icss = escape.IBMPC_ON
+                else:
+                    icss = escape.SO
+                o += ["\x08" * back, ias, icss, escape.INSERT_ON, inserttext, escape.INSERT_OFF]
+
+                if cs == "U":
+                    o.append(escape.IBMPC_OFF)
+            if whitespace_at_end:
+                o.append(escape.ERASE_IN_LINE_RIGHT)
+
+        if r.cursor is not None:
+            x, y = r.cursor
+            o += [set_cursor_position(x, y), escape.SHOW_CURSOR]
+            self._cy = y
+
+        if self._resized:
+            # handle resize before trying to draw screen
+            return
+        try:
+            for line in o:
+                if isinstance(line, bytes):
+                    line = line.decode("utf-8", "replace")  # noqa: PLW2901
+                self.write(line)
+            self.flush()
+        except OSError as e:
+            # ignore interrupted syscall
+            if e.args[0] != 4:
+                raise
+
+        self.screen_buf = sb
+        self._screen_buf_canvas = r
+
+    def _last_row(self, row):
+        """On the last row we need to slide the bottom right character
+        into place. Calculate the new line, attr and an insert sequence
+        to do that.
+
+        eg. last row:
+        XXXXXXXXXXXXXXXXXXXXYZ
+
+        Y will be drawn after Z, shifting Z into position.
+        """
+
+        new_row = row[:-1]
+        z_attr, z_cs, last_text = row[-1]
+        last_cols = util.calc_width(last_text, 0, len(last_text))
+        last_offs, z_col = util.calc_text_pos(last_text, 0, len(last_text), last_cols - 1)
+        if last_offs == 0:
+            z_text = last_text
+            del new_row[-1]
+            # we need another segment
+            y_attr, y_cs, nlast_text = row[-2]
+            nlast_cols = util.calc_width(nlast_text, 0, len(nlast_text))
+            z_col += nlast_cols
+            nlast_offs, y_col = util.calc_text_pos(nlast_text, 0, len(nlast_text), nlast_cols - 1)
+            y_text = nlast_text[nlast_offs:]
+            if nlast_offs:
+                new_row.append((y_attr, y_cs, nlast_text[:nlast_offs]))
+        else:
+            z_text = last_text[last_offs:]
+            y_attr, y_cs = z_attr, z_cs
+            nlast_cols = util.calc_width(last_text, 0, last_offs)
+            nlast_offs, y_col = util.calc_text_pos(last_text, 0, last_offs, nlast_cols - 1)
+            y_text = last_text[nlast_offs:last_offs]
+            if nlast_offs:
+                new_row.append((y_attr, y_cs, last_text[:nlast_offs]))
+
+        new_row.append((z_attr, z_cs, z_text))
+        return new_row, z_col - y_col, (y_attr, y_cs, y_text)
+
+    def clear(self):
+        """
+        Force the screen to be completely repainted on the next
+        call to draw_screen().
+        """
+        self.screen_buf = None
+        self.setup_G1 = True
+
+    def _attrspec_to_escape(self, a):
+        """
+        Convert AttrSpec instance a to an escape sequence for the terminal
+
+        >>> s = Screen()
+        >>> s.set_terminal_properties(colors=256)
+        >>> a2e = s._attrspec_to_escape
+        >>> a2e(s.AttrSpec('brown', 'dark green'))
+        '\\x1b[0;33;42m'
+        >>> a2e(s.AttrSpec('#fea,underline', '#d0d'))
+        '\\x1b[0;38;5;229;4;48;5;164m'
+        """
+        if self.term == "fbterm":
+            fg = escape.ESC + f"[1;{a.foreground_number:d}}}"
+            bg = escape.ESC + f"[2;{a.background_number:d}}}"
+            return fg + bg
+
+        if a.foreground_true:
+            fg = f"38;2;{';'.join(str(part) for part in a.get_rgb_values()[0:3])}"
+        elif a.foreground_high:
+            fg = f"38;5;{a.foreground_number:d}"
+        elif a.foreground_basic:
+            if a.foreground_number > 7:
+                if self.fg_bright_is_bold:
+                    fg = f"1;{a.foreground_number - 8 + 30:d}"
+                else:
+                    fg = f"{a.foreground_number - 8 + 90:d}"
+            else:
+                fg = f"{a.foreground_number + 30:d}"
+        else:
+            fg = "39"
+        st = (
+            "1;" * a.bold
+            + "3;" * a.italics
+            + "4;" * a.underline
+            + "5;" * a.blink
+            + "7;" * a.standout
+            + "9;" * a.strikethrough
+        )
+        if a.background_true:
+            bg = f"48;2;{';'.join(str(part) for part in a.get_rgb_values()[3:6])}"
+        elif a.background_high:
+            bg = f"48;5;{a.background_number:d}"
+        elif a.background_basic:
+            if a.background_number > 7:
+                if self.bg_bright_is_blink:
+                    bg = f"5;{a.background_number - 8 + 40:d}"
+                else:
+                    # this doesn't work on most terminals
+                    bg = f"{a.background_number - 8 + 100:d}"
+            else:
+                bg = f"{a.background_number + 40:d}"
+        else:
+            bg = "49"
+        return f"{escape.ESC}[0;{fg};{st}{bg}m"
+
+    def set_terminal_properties(self, colors=None, bright_is_bold=None, has_underline=None):
+        """
+        colors -- number of colors terminal supports (1, 16, 88, 256, or 2**24)
+            or None to leave unchanged
+        bright_is_bold -- set to True if this terminal uses the bold
+            setting to create bright colors (numbers 8-15), set to False
+            if this Terminal can create bright colors without bold or
+            None to leave unchanged
+        has_underline -- set to True if this terminal can use the
+            underline setting, False if it cannot or None to leave
+            unchanged
+        """
+        if colors is None:
+            colors = self.colors
+        if bright_is_bold is None:
+            bright_is_bold = self.fg_bright_is_bold
+        if has_underline is None:
+            has_underline = self.has_underline
+
+        if colors == self.colors and bright_is_bold == self.fg_bright_is_bold and has_underline == self.has_underline:
+            return
+
+        self.colors = colors
+        self.fg_bright_is_bold = bright_is_bold
+        self.has_underline = has_underline
+
+        self.clear()
+        self._pal_escape = {}
+        for p, v in self._palette.items():
+            self._on_update_palette_entry(p, *v)
+
+    def reset_default_terminal_palette(self):
+        """
+        Attempt to set the terminal palette to default values as taken
+        from xterm.  Uses number of colors from current
+        set_terminal_properties() screen setting.
+        """
+        if self.colors == 1:
+            return
+        if self.colors == 2**24:
+            colors = 256
+        else:
+            colors = self.colors
+
+        def rgb_values(n):
+            if colors == 16:
+                aspec = AttrSpec(f"h{n:d}", "", 256)
+            else:
+                aspec = AttrSpec(f"h{n:d}", "", colors)
+            return aspec.get_rgb_values()[:3]
+
+        entries = [(n, *rgb_values(n)) for n in range(min(colors, 256))]
+        self.modify_terminal_palette(entries)
+
+    def modify_terminal_palette(self, entries):
+        """
+        entries - list of (index, red, green, blue) tuples.
+
+        Attempt to set part of the terminal palette (this does not work
+        on all terminals.)  The changes are sent as a single escape
+        sequence so they should all take effect at the same time.
+
+        0 <= index < 256 (some terminals will only have 16 or 88 colors)
+        0 <= red, green, blue < 256
+        """
+
+        if self.term == "fbterm":
+            modify = [f"{index:d};{red:d};{green:d};{blue:d}" for index, red, green, blue in entries]
+            self.write(f"\x1b[3;{';'.join(modify)}}}")
+        else:
+            modify = [f"{index:d};rgb:{red:02x}/{green:02x}/{blue:02x}" for index, red, green, blue in entries]
+            self.write(f"\x1b]4;{';'.join(modify)}\x1b\\")
+        self.flush()
+
+    # shortcut for creating an AttrSpec with this screen object's
+    # number of colors
+    def AttrSpec(self, fg, bg) -> AttrSpec:
+        return AttrSpec(fg, bg, self.colors)
diff --git a/urwid/_win32.py b/urwid/_win32.py
new file mode 100644
index 000000000..480dd5c43
--- /dev/null
+++ b/urwid/_win32.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+import typing
+from ctypes import POINTER, Structure, Union, windll
+from ctypes.wintypes import BOOL, CHAR, DWORD, HANDLE, LPDWORD, SHORT, UINT, WCHAR, WORD
+
+# https://docs.microsoft.com/de-de/windows/console/getstdhandle
+STD_INPUT_HANDLE = -10
+STD_OUTPUT_HANDLE = -11
+
+# https://docs.microsoft.com/de-de/windows/console/setconsolemode
+ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
+DISABLE_NEWLINE_AUTO_RETURN = 0x0008
+ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
+ENABLE_WINDOW_INPUT = 0x0008
+
+
+class COORD(Structure):
+    """https://docs.microsoft.com/en-us/windows/console/coord-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [
+        ("X", SHORT),
+        ("Y", SHORT),
+    ]
+
+
+class SMALL_RECT(Structure):
+    """https://docs.microsoft.com/en-us/windows/console/small-rect-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [
+        ("Left", SHORT),
+        ("Top", SHORT),
+        ("Right", SHORT),
+        ("Bottom", SHORT),
+    ]
+
+
+class CONSOLE_SCREEN_BUFFER_INFO(Structure):
+    """https://docs.microsoft.com/en-us/windows/console/console-screen-buffer-info-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [
+        ("dwSize", COORD),
+        ("dwCursorPosition", COORD),
+        ("wAttributes", WORD),
+        ("srWindow", SMALL_RECT),
+        ("dwMaximumWindowSize", COORD),
+    ]
+
+
+class uChar(Union):
+    """https://docs.microsoft.com/en-us/windows/console/key-event-record-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [
+        ("AsciiChar", CHAR),
+        ("UnicodeChar", WCHAR),
+    ]
+
+
+class KEY_EVENT_RECORD(Structure):
+    """https://docs.microsoft.com/en-us/windows/console/key-event-record-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [
+        ("bKeyDown", BOOL),
+        ("wRepeatCount", WORD),
+        ("wVirtualKeyCode", WORD),
+        ("wVirtualScanCode", WORD),
+        ("uChar", uChar),
+        ("dwControlKeyState", DWORD),
+    ]
+
+
+class MOUSE_EVENT_RECORD(Structure):
+    """https://docs.microsoft.com/en-us/windows/console/mouse-event-record-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [
+        ("dwMousePosition", COORD),
+        ("dwButtonState", DWORD),
+        ("dwControlKeyState", DWORD),
+        ("dwEventFlags", DWORD),
+    ]
+
+
+class WINDOW_BUFFER_SIZE_RECORD(Structure):
+    """https://docs.microsoft.com/en-us/windows/console/window-buffer-size-record-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [("dwSize", COORD)]
+
+
+class MENU_EVENT_RECORD(Structure):
+    """https://docs.microsoft.com/en-us/windows/console/menu-event-record-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [("dwCommandId", UINT)]
+
+
+class FOCUS_EVENT_RECORD(Structure):
+    """https://docs.microsoft.com/en-us/windows/console/focus-event-record-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [("bSetFocus", BOOL)]
+
+
+class Event(Union):
+    """https://docs.microsoft.com/en-us/windows/console/input-record-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [
+        ("KeyEvent", KEY_EVENT_RECORD),
+        ("MouseEvent", MOUSE_EVENT_RECORD),
+        ("WindowBufferSizeEvent", WINDOW_BUFFER_SIZE_RECORD),
+        ("MenuEvent", MENU_EVENT_RECORD),
+        ("FocusEvent", FOCUS_EVENT_RECORD),
+    ]
+
+
+class INPUT_RECORD(Structure):
+    """https://docs.microsoft.com/en-us/windows/console/input-record-str"""
+
+    _fields_: typing.ClassVar[list[tuple[str, type]]] = [("EventType", WORD), ("Event", Event)]
+
+
+class EventType:
+    FOCUS_EVENT = 0x0010
+    KEY_EVENT = 0x0001
+    MENU_EVENT = 0x0008
+    MOUSE_EVENT = 0x0002
+    WINDOW_BUFFER_SIZE_EVENT = 0x0004
+
+
+# https://docs.microsoft.com/de-de/windows/console/getstdhandle
+GetStdHandle = windll.kernel32.GetStdHandle
+GetStdHandle.argtypes = [DWORD]
+GetStdHandle.restype = HANDLE
+
+# https://docs.microsoft.com/de-de/windows/console/getconsolemode
+GetConsoleMode = windll.kernel32.GetConsoleMode
+GetConsoleMode.argtypes = [HANDLE, LPDWORD]
+GetConsoleMode.restype = BOOL
+
+# https://docs.microsoft.com/de-de/windows/console/setconsolemode
+SetConsoleMode = windll.kernel32.SetConsoleMode
+SetConsoleMode.argtypes = [HANDLE, DWORD]
+SetConsoleMode.restype = BOOL
+
+# https://docs.microsoft.com/de-de/windows/console/readconsoleinput
+ReadConsoleInputW = windll.kernel32.ReadConsoleInputW
+# ReadConsoleInputW.argtypes = [HANDLE, POINTER(INPUT_RECORD), DWORD, LPDWORD]
+ReadConsoleInputW.restype = BOOL
+
+# https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo
+GetConsoleScreenBufferInfo = windll.kernel32.GetConsoleScreenBufferInfo
+GetConsoleScreenBufferInfo.argtypes = [HANDLE, POINTER(CONSOLE_SCREEN_BUFFER_INFO)]
+GetConsoleScreenBufferInfo.restype = BOOL
diff --git a/urwid/_win32_raw_display.py b/urwid/_win32_raw_display.py
new file mode 100644
index 000000000..1848c06a2
--- /dev/null
+++ b/urwid/_win32_raw_display.py
@@ -0,0 +1,264 @@
+# Urwid raw display module
+#    Copyright (C) 2004-2009  Ian Ward
+#
+#    This library is free software; you can redistribute it and/or
+#    modify it under the terms of the GNU Lesser General Public
+#    License as published by the Free Software Foundation; either
+#    version 2.1 of the License, or (at your option) any later version.
+#
+#    This library is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+#    Lesser General Public License for more details.
+#
+#    You should have received a copy of the GNU Lesser General Public
+#    License along with this library; if not, write to the Free Software
+#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+# Urwid web site: https://urwid.org/
+
+
+"""
+Direct terminal UI implementation
+"""
+
+from __future__ import annotations
+
+import contextlib
+import functools
+import socket
+import sys
+import threading
+import typing
+from ctypes import byref
+from ctypes.wintypes import DWORD
+
+from urwid import escape, signals
+from urwid.display_common import INPUT_DESCRIPTORS_CHANGED
+
+from . import _raw_display_base, _win32
+
+if typing.TYPE_CHECKING:
+    import io
+    from collections.abc import Callable
+
+
+class Screen(_raw_display_base.Screen):
+    _term_input_file: socket.socket
+
+    def __init__(
+        self,
+        input: socket.socket | None = None,  # noqa: A002
+        output: io.TextIOBase = sys.stdout,
+    ) -> None:
+        """Initialize a screen that directly prints escape codes to an output
+        terminal.
+        """
+        if input is None:
+            input, self._send_input = socket.socketpair()  # noqa: A001
+
+        super().__init__(input, output)
+
+    _dwOriginalOutMode = None
+    _dwOriginalInMode = None
+
+    def _start(self, alternate_buffer=True):
+        """
+        Initialize the screen and input mode.
+
+        alternate_buffer -- use alternate screen buffer
+        """
+        if alternate_buffer:
+            self.write(escape.SWITCH_TO_ALTERNATE_BUFFER)
+            self._rows_used = None
+        else:
+            self._rows_used = 0
+
+        handle_out = _win32.GetStdHandle(_win32.STD_OUTPUT_HANDLE)
+        handle_in = _win32.GetStdHandle(_win32.STD_INPUT_HANDLE)
+        self._dwOriginalOutMode = DWORD()
+        self._dwOriginalInMode = DWORD()
+        _win32.GetConsoleMode(handle_out, byref(self._dwOriginalOutMode))
+        _win32.GetConsoleMode(handle_in, byref(self._dwOriginalInMode))
+        # TODO: Restore on exit
+
+        dword_out_mode = DWORD(
+            self._dwOriginalOutMode.value
+            | _win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING
+            | _win32.DISABLE_NEWLINE_AUTO_RETURN
+        )
+        dword_in_mode = DWORD(
+            self._dwOriginalInMode.value | _win32.ENABLE_WINDOW_INPUT | _win32.ENABLE_VIRTUAL_TERMINAL_INPUT
+        )
+
+        ok = _win32.SetConsoleMode(handle_out, dword_out_mode)
+        if not ok:
+            raise RuntimeError(f"ConsoleMode set failed for output. Err: {ok!r}")
+        ok = _win32.SetConsoleMode(handle_in, dword_in_mode)
+        if not ok:
+            raise RuntimeError(f"ConsoleMode set failed for input. Err: {ok!r}")
+        self._alternate_buffer = alternate_buffer
+        self._next_timeout = self.max_wait
+
+        signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
+        # restore mouse tracking to previous state
+        self._mouse_tracking(self._mouse_tracking_enabled)
+
+        return super()._start()
+
+    def _stop(self):
+        """
+        Restore the screen.
+        """
+        self.clear()
+
+        signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
+
+        self._stop_mouse_restore_buffer()
+
+        handle_out = _win32.GetStdHandle(_win32.STD_OUTPUT_HANDLE)
+        handle_in = _win32.GetStdHandle(_win32.STD_INPUT_HANDLE)
+        ok = _win32.SetConsoleMode(handle_out, self._dwOriginalOutMode)
+        if not ok:
+            raise RuntimeError(f"ConsoleMode set failed for output. Err: {ok!r}")
+        ok = _win32.SetConsoleMode(handle_in, self._dwOriginalInMode)
+        if not ok:
+            raise RuntimeError(f"ConsoleMode set failed for input. Err: {ok!r}")
+
+        super()._stop()
+
+    def get_input_descriptors(self) -> list[int]:
+        """
+        Return a list of integer file descriptors that should be
+        polled in external event loops to check for user input.
+
+        Use this method if you are implementing your own event loop.
+
+        This method is only called by `hook_event_loop`, so if you override
+        that, you can safely ignore this.
+        """
+        if not self._started:
+            return []
+
+        fd_list = [self._resize_pipe_rd]
+        fd = self._input_fileno()
+        if fd is not None:
+            fd_list.append(fd)
+
+        return fd_list
+
+    def unhook_event_loop(self, event_loop):
+        """
+        Remove any hooks added by hook_event_loop.
+        """
+        if self._input_thread is not None:
+            self._input_thread.should_exit = True
+
+            with contextlib.suppress(RuntimeError):
+                self._input_thread.join(5)
+
+            self._input_thread = None
+
+        for handle in self._current_event_loop_handles:
+            event_loop.remove_watch_file(handle)
+
+        if self._input_timeout:
+            event_loop.remove_alarm(self._input_timeout)
+            self._input_timeout = None
+
+    def hook_event_loop(self, event_loop, callback):
+        """
+        Register the given callback with the event loop, to be called with new
+        input whenever it's available.  The callback should be passed a list of
+        processed keys and a list of unprocessed keycodes.
+
+        Subclasses may wish to use parse_input to wrap the callback.
+        """
+        self._input_thread = ReadInputThread(self._send_input, lambda: self._sigwinch_handler(0))
+        self._input_thread.start()
+        if hasattr(self, "get_input_nonblocking"):
+            wrapper = self._make_legacy_input_wrapper(event_loop, callback)
+        else:
+
+            @functools.wraps(callback)
+            def wrapper() -> tuple[list[str], typing.Any] | None:
+                return self.parse_input(event_loop, callback, self.get_available_raw_input())
+
+        fds = self.get_input_descriptors()
+        handles = [event_loop.watch_file(fd, wrapper) for fd in fds]
+        self._current_event_loop_handles = handles
+
+    _input_thread: ReadInputThread | None = None
+
+    def _getch(self, timeout: int) -> int:
+        ready = self._wait_for_input_ready(timeout)
+
+        fd = self._input_fileno()
+        if fd is not None and fd in ready:
+            return ord(self._term_input_file.recv(1))
+        return -1
+
+    def get_cols_rows(self) -> tuple[int, int]:
+        """Return the terminal dimensions (num columns, num rows)."""
+        y, x = super().get_cols_rows()
+        with contextlib.suppress(OSError):  # Term size could not be determined
+            if hasattr(self._term_output_file, "fileno"):
+                if self._term_output_file != sys.stdout:
+                    raise RuntimeError("Unexpected terminal output file")
+                handle = _win32.GetStdHandle(_win32.STD_OUTPUT_HANDLE)
+                info = _win32.CONSOLE_SCREEN_BUFFER_INFO()
+                ok = _win32.GetConsoleScreenBufferInfo(handle, byref(info))
+                if ok:
+                    # Fallback will be used in case of term size could not be determined
+                    y, x = info.dwSize.Y, info.dwSize.X
+
+        self.maxrow = y
+        return x, y
+
+
+class ReadInputThread(threading.Thread):
+    name = "urwid Windows input reader"
+    daemon = True
+    should_exit: bool = False
+
+    def __init__(
+        self,
+        input_socket: socket.socket,
+        resize: Callable[[], typing.Any],
+    ) -> None:
+        self._input = input_socket
+        self._resize = resize
+        super().__init__()
+
+    def run(self) -> None:
+        hIn = _win32.GetStdHandle(_win32.STD_INPUT_HANDLE)
+        MAX = 2048
+
+        read = DWORD(0)
+        arrtype = _win32.INPUT_RECORD * MAX
+        input_records = arrtype()
+
+        while True:
+            _win32.ReadConsoleInputW(hIn, byref(input_records), MAX, byref(read))
+            if self.should_exit:
+                return
+            for i in range(read.value):
+                inp = input_records[i]
+                if inp.EventType == _win32.EventType.KEY_EVENT:
+                    if not inp.Event.KeyEvent.bKeyDown:
+                        continue
+                    self._input.send(inp.Event.KeyEvent.uChar.AsciiChar)
+                elif inp.EventType == _win32.EventType.WINDOW_BUFFER_SIZE_EVENT:
+                    self._resize()
+                else:
+                    pass  # TODO: handle mouse events
+
+
+def _test():
+    import doctest
+
+    doctest.testmod()
+
+
+if __name__ == "__main__":
+    _test()
diff --git a/urwid/display_common.py b/urwid/display_common.py
index 251712c8d..3d15293ec 100644
--- a/urwid/display_common.py
+++ b/urwid/display_common.py
@@ -20,16 +20,12 @@
 
 from __future__ import annotations
 
+import abc
 import os
 import sys
 import typing
 import warnings
 
-try:  # noqa: SIM105
-    import termios
-except ImportError:
-    pass  # windows
-
 from urwid import signals
 from urwid.util import StoppingContext, int_scale
 
@@ -38,6 +34,11 @@
 
     from typing_extensions import Literal, Self
 
+IS_WINDOWS = os.name == "nt"
+
+if not IS_WINDOWS:
+    import termios
+
 # for replacing unprintable bytes with '?'
 UNPRINTABLE_TRANS_TABLE = b"?" * 32 + bytes(range(32, 256))
 
@@ -886,77 +887,110 @@ def __init__(self) -> None:
         self._signal_keys_set = False
         self._old_signal_keys = None
 
-    def tty_signal_keys(
-        self,
-        intr: Literal["undefined"] | int | None = None,
-        quit: Literal["undefined"] | int | None = None,  # noqa: A002
-        start: Literal["undefined"] | int | None = None,
-        stop: Literal["undefined"] | int | None = None,
-        susp: Literal["undefined"] | int | None = None,
-        fileno: int | None = None,
-    ):
-        """
-        Read and/or set the tty's signal character settings.
-        This function returns the current settings as a tuple.
-
-        Use the string 'undefined' to unmap keys from their signals.
-        The value None is used when no change is being made.
-        Setting signal keys is done using the integer ascii
-        code for the key, eg.  3 for CTRL+C.
-
-        If this function is called after start() has been called
-        then the original settings will be restored when stop()
-        is called.
-        """
-        if fileno is None:
-            fileno = sys.stdin.fileno()
-        if not os.isatty(fileno):
-            return None
+    if IS_WINDOWS:
+
+        def tty_signal_keys(
+            self,
+            intr: Literal["undefined"] | int | None = None,
+            quit: Literal["undefined"] | int | None = None,  # noqa: A002
+            start: Literal["undefined"] | int | None = None,
+            stop: Literal["undefined"] | int | None = None,
+            susp: Literal["undefined"] | int | None = None,
+            fileno: int | None = None,
+        ):
+            """
+            Read and/or set the tty's signal character settings.
+            This function returns the current settings as a tuple.
+
+            Use the string 'undefined' to unmap keys from their signals.
+            The value None is used when no change is being made.
+            Setting signal keys is done using the integer ascii
+            code for the key, eg.  3 for CTRL+C.
+
+            If this function is called after start() has been called
+            then the original settings will be restored when stop()
+            is called.
+            """
+            return ()
+
+    else:
+
+        def tty_signal_keys(
+            self,
+            intr: Literal["undefined"] | int | None = None,
+            quit: Literal["undefined"] | int | None = None,  # noqa: A002
+            start: Literal["undefined"] | int | None = None,
+            stop: Literal["undefined"] | int | None = None,
+            susp: Literal["undefined"] | int | None = None,
+            fileno: int | None = None,
+        ):
+            """
+            Read and/or set the tty's signal character settings.
+            This function returns the current settings as a tuple.
+
+            Use the string 'undefined' to unmap keys from their signals.
+            The value None is used when no change is being made.
+            Setting signal keys is done using the integer ascii
+            code for the key, eg.  3 for CTRL+C.
+
+            If this function is called after start() has been called
+            then the original settings will be restored when stop()
+            is called.
+            """
+
+            if fileno is None:
+                fileno = sys.stdin.fileno()
+            if not os.isatty(fileno):
+                return None
 
-        tattr = termios.tcgetattr(fileno)
-        sattr = tattr[6]
-        skeys = (
-            sattr[termios.VINTR],
-            sattr[termios.VQUIT],
-            sattr[termios.VSTART],
-            sattr[termios.VSTOP],
-            sattr[termios.VSUSP],
-        )
+            tattr = termios.tcgetattr(fileno)
+            sattr = tattr[6]
+            skeys = (
+                sattr[termios.VINTR],
+                sattr[termios.VQUIT],
+                sattr[termios.VSTART],
+                sattr[termios.VSTOP],
+                sattr[termios.VSUSP],
+            )
 
-        if intr == "undefined":
-            intr = 0
-        if quit == "undefined":
-            quit = 0  # noqa: A001
-        if start == "undefined":
-            start = 0
-        if stop == "undefined":
-            stop = 0
-        if susp == "undefined":
-            susp = 0
-
-        if intr is not None:
-            tattr[6][termios.VINTR] = intr
-        if quit is not None:
-            tattr[6][termios.VQUIT] = quit
-        if start is not None:
-            tattr[6][termios.VSTART] = start
-        if stop is not None:
-            tattr[6][termios.VSTOP] = stop
-        if susp is not None:
-            tattr[6][termios.VSUSP] = susp
-
-        if any(item is not None for item in (intr, quit, start, stop, susp)):
-            termios.tcsetattr(fileno, termios.TCSADRAIN, tattr)
-            self._signal_keys_set = True
-
-        return skeys
+            if intr == "undefined":
+                intr = 0
+            if quit == "undefined":
+                quit = 0  # noqa: A001
+            if start == "undefined":
+                start = 0
+            if stop == "undefined":
+                stop = 0
+            if susp == "undefined":
+                susp = 0
+
+            if intr is not None:
+                tattr[6][termios.VINTR] = intr
+            if quit is not None:
+                tattr[6][termios.VQUIT] = quit
+            if start is not None:
+                tattr[6][termios.VSTART] = start
+            if stop is not None:
+                tattr[6][termios.VSTOP] = stop
+            if susp is not None:
+                tattr[6][termios.VSUSP] = susp
+
+            if any(item is not None for item in (intr, quit, start, stop, susp)):
+                termios.tcsetattr(fileno, termios.TCSADRAIN, tattr)
+                self._signal_keys_set = True
+
+            return skeys
 
 
 class ScreenError(Exception):
     pass
 
 
-class BaseScreen(metaclass=signals.MetaSignals):
+class BaseMeta(signals.MetaSignals, abc.ABCMeta):
+    """Base metaclass for abstra"""
+
+
+class BaseScreen(metaclass=BaseMeta):
     """
     Base class for Screen classes (raw_display.Screen, .. etc)
     """
diff --git a/urwid/event_loop/__init__.py b/urwid/event_loop/__init__.py
index 2e3c93dfc..8d1ade9c6 100644
--- a/urwid/event_loop/__init__.py
+++ b/urwid/event_loop/__init__.py
@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+import os
+
 from .abstract_loop import EventLoop, ExitMainLoop
 from .asyncio_loop import AsyncioEventLoop
 from .main_loop import MainLoop
@@ -37,9 +39,11 @@
 except ImportError:
     pass
 
-try:
-    from .zmq_loop import ZMQEventLoop
+if os.name != "nt":
+    # ZMQEventLoop cause interpreter crash on windows
+    try:
+        from .zmq_loop import ZMQEventLoop
 
-    __all__ += ("ZMQEventLoop",)
-except ImportError:
-    pass
+        __all__ += ("ZMQEventLoop",)
+    except ImportError:
+        pass
diff --git a/urwid/event_loop/select_loop.py b/urwid/event_loop/select_loop.py
index 51e90d896..eda2b313b 100644
--- a/urwid/event_loop/select_loop.py
+++ b/urwid/event_loop/select_loop.py
@@ -157,9 +157,10 @@ def _loop(self) -> None:
         A single iteration of the event loop
         """
         fds = list(self._watch_files)
+        tm: float | Literal["idle"] | None = None
+
         if self._alarms or self._did_something:
             timeout = 0.0
-            tm: float | Literal["idle"] | None = None
 
             if self._alarms:
                 timeout_ = self._alarms[0][0]
@@ -170,11 +171,15 @@ def _loop(self) -> None:
                 timeout = 0.0
                 tm = "idle"
 
-            ready, _w, _err = select.select(fds, [], fds, timeout)
+            if fds:
+                ready, _w, _err = select.select(fds, [], fds, timeout)
+            else:
+                ready = []
 
-        else:
-            tm = None
+        elif fds:
             ready, _w, _err = select.select(fds, [], fds)
+        else:
+            ready = []
 
         if not ready:
             if tm == "idle":
diff --git a/urwid/raw_display.py b/urwid/raw_display.py
index 0fa84e97c..917de5840 100644
--- a/urwid/raw_display.py
+++ b/urwid/raw_display.py
@@ -24,1094 +24,14 @@
 
 from __future__ import annotations
 
-import contextlib
 import os
-import platform
-import select
-import signal
-import struct
-import sys
-import typing
 
-try:
-    import fcntl
-    import termios
-    import tty
-except ImportError:
-    pass  # windows
+__all__ = ("Screen",)
 
-from subprocess import PIPE, Popen
+IS_WINDOWS = os.name == "nt"
 
-from urwid import escape, signals, util
-from urwid.display_common import (
-    INPUT_DESCRIPTORS_CHANGED,
-    UNPRINTABLE_TRANS_TABLE,
-    UPDATE_PALETTE_ENTRY,
-    AttrSpec,
-    BaseScreen,
-    RealTerminal,
-)
+if IS_WINDOWS:
+    from ._win32_raw_display import Screen
 
-if typing.TYPE_CHECKING:
-    from typing_extensions import Literal
-
-
-IS_WSL = (sys.platform == "linux") and ("wsl" in platform.platform().lower())
-
-
-class Screen(BaseScreen, RealTerminal):
-    def __init__(
-        self,
-        input=sys.stdin,  # noqa: A002
-        output=sys.stdout,
-        bracketed_paste_mode=False,
-    ):
-        """Initialize a screen that directly prints escape codes to an output
-        terminal.
-
-        bracketed_paste_mode -- enable bracketed paste mode in the host terminal.
-            If the host terminal supports it, the application will receive `begin paste`
-            and `end paste` keystrokes when the user pastes text.
-        """
-        super().__init__()
-        self._pal_escape = {}
-        self._pal_attrspec = {}
-        signals.connect_signal(self, UPDATE_PALETTE_ENTRY, self._on_update_palette_entry)
-        self.colors = 16  # FIXME: detect this
-        self.has_underline = True  # FIXME: detect this
-        self._keyqueue = []
-        self.prev_input_resize = 0
-        self.set_input_timeouts()
-        self.screen_buf = None
-        self._screen_buf_canvas = None
-        self._resized = False
-        self.maxrow = None
-        self.gpm_mev: Popen | None = None
-        self.gpm_event_pending: bool = False
-        self._mouse_tracking_enabled = False
-        self.last_bstate = 0
-        self._setup_G1_done = False
-        self._rows_used = None
-        self._cy = 0
-        self.term = os.environ.get("TERM", "")
-        self.fg_bright_is_bold = not self.term.startswith("xterm")
-        self.bg_bright_is_blink = self.term == "linux"
-        self.back_color_erase = not self.term.startswith("screen")
-        self.register_palette_entry(None, "default", "default")
-        self._next_timeout = None
-        self.signal_handler_setter = signal.signal
-        self.bracketed_paste_mode = bracketed_paste_mode
-
-        # Our connections to the world
-        self._term_output_file = output
-        self._term_input_file = input
-
-        # pipe for signalling external event loops about resize events
-        self._resize_pipe_rd, self._resize_pipe_wr = os.pipe()
-        fcntl.fcntl(self._resize_pipe_rd, fcntl.F_SETFL, os.O_NONBLOCK)
-
-        # These store the previous signal handlers after setting ours
-        self._prev_sigcont_handler = None
-        self._prev_sigtstp_handler = None
-        self._prev_sigwinch_handler = None
-
-    def _input_fileno(self):
-        """Returns the fileno of the input stream, or None if it doesn't have one.
-
-        A stream without a fileno can't participate in whatever.
-        """
-        if hasattr(self._term_input_file, "fileno"):
-            return self._term_input_file.fileno()
-
-        return None
-
-    def _on_update_palette_entry(self, name, *attrspecs):
-        # copy the attribute to a dictionary containing the escape seqences
-        a = attrspecs[{16: 0, 1: 1, 88: 2, 256: 3, 2**24: 4}[self.colors]]
-        self._pal_attrspec[name] = a
-        self._pal_escape[name] = self._attrspec_to_escape(a)
-
-    def set_input_timeouts(self, max_wait=None, complete_wait=0.125, resize_wait=0.125):
-        """
-        Set the get_input timeout values.  All values are in floating
-        point numbers of seconds.
-
-        max_wait -- amount of time in seconds to wait for input when
-            there is no input pending, wait forever if None
-        complete_wait -- amount of time in seconds to wait when
-            get_input detects an incomplete escape sequence at the
-            end of the available input
-        resize_wait -- amount of time in seconds to wait for more input
-            after receiving two screen resize requests in a row to
-            stop Urwid from consuming 100% cpu during a gradual
-            window resize operation
-        """
-        self.max_wait = max_wait
-        if max_wait is not None:
-            if self._next_timeout is None:
-                self._next_timeout = max_wait
-            else:
-                self._next_timeout = min(self._next_timeout, self.max_wait)
-        self.complete_wait = complete_wait
-        self.resize_wait = resize_wait
-
-    def _sigwinch_handler(self, signum, frame=None):
-        """
-        frame -- will always be None when the GLib event loop is being used.
-        """
-
-        if not self._resized:
-            os.write(self._resize_pipe_wr, b"R")
-        self._resized = True
-        self.screen_buf = None
-
-        if callable(self._prev_sigwinch_handler):
-            self._prev_sigwinch_handler(signum, frame)
-
-    def _sigtstp_handler(self, signum, frame=None):
-        self.stop()  # Restores the previous signal handlers
-        self._prev_sigcont_handler = self.signal_handler_setter(signal.SIGCONT, self._sigcont_handler)
-        # Handled by the previous handler.
-        # If non-default, it may set its own SIGCONT handler which should hopefully call our own.
-        os.kill(os.getpid(), signal.SIGTSTP)
-
-    def _sigcont_handler(self, signum, frame=None):
-        """
-        frame -- will always be None when the GLib event loop is being used.
-        """
-        self.signal_restore()
-
-        if callable(self._prev_sigcont_handler):
-            # May set its own SIGTSTP handler which would be stored and replaced in
-            # `signal_init()` (via `start()`).
-            self._prev_sigcont_handler(signum, frame)
-
-        self.start()
-        self._sigwinch_handler(None, None)
-
-    def signal_init(self):
-        """
-        Called in the startup of run wrapper to set the SIGWINCH
-        and SIGTSTP signal handlers.
-
-        Override this function to call from main thread in threaded
-        applications.
-        """
-        self._prev_sigwinch_handler = self.signal_handler_setter(signal.SIGWINCH, self._sigwinch_handler)
-        self._prev_sigtstp_handler = self.signal_handler_setter(signal.SIGTSTP, self._sigtstp_handler)
-
-    def signal_restore(self):
-        """
-        Called in the finally block of run wrapper to restore the
-        SIGTSTP, SIGCONT and SIGWINCH signal handlers.
-
-        Override this function to call from main thread in threaded
-        applications.
-        """
-        self.signal_handler_setter(signal.SIGTSTP, self._prev_sigtstp_handler or signal.SIG_DFL)
-        self.signal_handler_setter(signal.SIGCONT, self._prev_sigcont_handler or signal.SIG_DFL)
-        self.signal_handler_setter(signal.SIGWINCH, self._prev_sigwinch_handler or signal.SIG_DFL)
-
-    def set_mouse_tracking(self, enable=True):
-        """
-        Enable (or disable) mouse tracking.
-
-        After calling this function get_input will include mouse
-        click events along with keystrokes.
-        """
-        enable = bool(enable)
-        if enable == self._mouse_tracking_enabled:
-            return
-
-        self._mouse_tracking(enable)
-        self._mouse_tracking_enabled = enable
-
-    def _mouse_tracking(self, enable):
-        if enable:
-            self.write(escape.MOUSE_TRACKING_ON)
-            self._start_gpm_tracking()
-        else:
-            self.write(escape.MOUSE_TRACKING_OFF)
-            self._stop_gpm_tracking()
-
-    def _start_gpm_tracking(self):
-        if not os.path.isfile("/usr/bin/mev"):
-            return
-        if not os.environ.get("TERM", "").lower().startswith("linux"):
-            return
-
-        m = Popen(
-            ["/usr/bin/mev", "-e", "158"],  # noqa: S603
-            stdin=PIPE,
-            stdout=PIPE,
-            close_fds=True,
-            encoding="ascii",
-        )
-        fcntl.fcntl(m.stdout.fileno(), fcntl.F_SETFL, os.O_NONBLOCK)
-        self.gpm_mev = m
-
-    def _stop_gpm_tracking(self):
-        if not self.gpm_mev:
-            return
-        os.kill(self.gpm_mev.pid, signal.SIGINT)
-        os.waitpid(self.gpm_mev.pid, 0)
-        self.gpm_mev = None
-
-    def _start(self, alternate_buffer=True):
-        """
-        Initialize the screen and input mode.
-
-        alternate_buffer -- use alternate screen buffer
-        """
-        if alternate_buffer:
-            self.write(escape.SWITCH_TO_ALTERNATE_BUFFER)
-            self._rows_used = None
-        else:
-            self._rows_used = 0
-
-        if self.bracketed_paste_mode:
-            self.write(escape.ENABLE_BRACKETED_PASTE_MODE)
-
-        fd = self._input_fileno()
-        if fd is not None and os.isatty(fd):
-            self._old_termios_settings = termios.tcgetattr(fd)
-            tty.setcbreak(fd)
-
-        self.signal_init()
-        self._alternate_buffer = alternate_buffer
-        self._next_timeout = self.max_wait
-
-        if not self._signal_keys_set:
-            self._old_signal_keys = self.tty_signal_keys(fileno=fd)
-
-        signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
-        # restore mouse tracking to previous state
-        self._mouse_tracking(self._mouse_tracking_enabled)
-
-        return super()._start()
-
-    def _stop(self):
-        """
-        Restore the screen.
-        """
-        self.clear()
-
-        if self.bracketed_paste_mode:
-            self.write(escape.DISABLE_BRACKETED_PASTE_MODE)
-
-        signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
-
-        self.signal_restore()
-
-        fd = self._input_fileno()
-        if fd is not None and os.isatty(fd):
-            termios.tcsetattr(fd, termios.TCSADRAIN, self._old_termios_settings)
-
-        self._mouse_tracking(False)
-
-        move_cursor = ""
-        if self._alternate_buffer:
-            move_cursor = escape.RESTORE_NORMAL_BUFFER
-        elif self.maxrow is not None:
-            move_cursor = escape.set_cursor_position(0, self.maxrow)
-        self.write(self._attrspec_to_escape(AttrSpec("", "")) + escape.SI + move_cursor + escape.SHOW_CURSOR)
-        self.flush()
-
-        if self._old_signal_keys:
-            self.tty_signal_keys(*self._old_signal_keys, fd)
-
-        super()._stop()
-
-    def write(self, data):
-        """Write some data to the terminal.
-
-        You may wish to override this if you're using something other than
-        regular files for input and output.
-        """
-        if IS_WSL:
-            # Filter out SI/SO under WSL.
-            data.replace(escape.SI, "").replace(escape.SO, "")
-        self._term_output_file.write(data)
-
-    def flush(self):
-        """Flush the output buffer.
-
-        You may wish to override this if you're using something other than
-        regular files for input and output.
-        """
-        self._term_output_file.flush()
-
-    @typing.overload
-    def get_input(self, raw_keys: Literal[False]) -> list[str]:
-        ...
-
-    @typing.overload
-    def get_input(self, raw_keys: Literal[True]) -> tuple[list[str], list[int]]:
-        ...
-
-    def get_input(self, raw_keys: bool = False) -> list[str] | tuple[list[str], list[int]]:
-        """Return pending input as a list.
-
-        raw_keys -- return raw keycodes as well as translated versions
-
-        This function will immediately return all the input since the
-        last time it was called.  If there is no input pending it will
-        wait before returning an empty list.  The wait time may be
-        configured with the set_input_timeouts function.
-
-        If raw_keys is False (default) this function will return a list
-        of keys pressed.  If raw_keys is True this function will return
-        a ( keys pressed, raw keycodes ) tuple instead.
-
-        Examples of keys returned:
-
-        * ASCII printable characters:  " ", "a", "0", "A", "-", "/"
-        * ASCII control characters:  "tab", "enter"
-        * Escape sequences:  "up", "page up", "home", "insert", "f1"
-        * Key combinations:  "shift f1", "meta a", "ctrl b"
-        * Window events:  "window resize"
-
-        When a narrow encoding is not enabled:
-
-        * "Extended ASCII" characters:  "\\xa1", "\\xb2", "\\xfe"
-
-        When a wide encoding is enabled:
-
-        * Double-byte characters:  "\\xa1\\xea", "\\xb2\\xd4"
-
-        When utf8 encoding is enabled:
-
-        * Unicode characters: u"\\u00a5", u'\\u253c"
-
-        Examples of mouse events returned:
-
-        * Mouse button press: ('mouse press', 1, 15, 13),
-                              ('meta mouse press', 2, 17, 23)
-        * Mouse drag: ('mouse drag', 1, 16, 13),
-                      ('mouse drag', 1, 17, 13),
-                      ('ctrl mouse drag', 1, 18, 13)
-        * Mouse button release: ('mouse release', 0, 18, 13),
-                                ('ctrl mouse release', 0, 17, 23)
-        """
-        if not self._started:
-            raise RuntimeError
-
-        self._wait_for_input_ready(self._next_timeout)
-        keys, raw = self.parse_input(None, None, self.get_available_raw_input())
-
-        # Avoid pegging CPU at 100% when slowly resizing
-        if keys == ["window resize"] and self.prev_input_resize:
-            while True:
-                self._wait_for_input_ready(self.resize_wait)
-                keys, raw2 = self.parse_input(None, None, self.get_available_raw_input())
-                raw += raw2
-                # if not keys:
-                #     keys, raw2 = self._get_input(
-                #         self.resize_wait)
-                #     raw += raw2
-                if keys != ["window resize"]:
-                    break
-            if keys[-1:] != ["window resize"]:
-                keys.append("window resize")
-
-        if keys == ["window resize"]:
-            self.prev_input_resize = 2
-        elif self.prev_input_resize == 2 and not keys:
-            self.prev_input_resize = 1
-        else:
-            self.prev_input_resize = 0
-
-        if raw_keys:
-            return keys, raw
-        return keys
-
-    def get_input_descriptors(self) -> list[int]:
-        """
-        Return a list of integer file descriptors that should be
-        polled in external event loops to check for user input.
-
-        Use this method if you are implementing your own event loop.
-
-        This method is only called by `hook_event_loop`, so if you override
-        that, you can safely ignore this.
-        """
-        if not self._started:
-            return []
-
-        fd_list = [self._resize_pipe_rd]
-        fd = self._input_fileno()
-        if fd is not None:
-            fd_list.append(fd)
-        if self.gpm_mev is not None:
-            fd_list.append(self.gpm_mev.stdout.fileno())
-        return fd_list
-
-    _current_event_loop_handles = ()
-
-    def unhook_event_loop(self, event_loop):
-        """
-        Remove any hooks added by hook_event_loop.
-        """
-        for handle in self._current_event_loop_handles:
-            event_loop.remove_watch_file(handle)
-
-        if self._input_timeout:
-            event_loop.remove_alarm(self._input_timeout)
-            self._input_timeout = None
-
-    def hook_event_loop(self, event_loop, callback):
-        """
-        Register the given callback with the event loop, to be called with new
-        input whenever it's available.  The callback should be passed a list of
-        processed keys and a list of unprocessed keycodes.
-
-        Subclasses may wish to use parse_input to wrap the callback.
-        """
-        if hasattr(self, "get_input_nonblocking"):
-            wrapper = self._make_legacy_input_wrapper(event_loop, callback)
-        else:
-
-            def wrapper() -> tuple[list[str], typing.Any] | None:
-                return self.parse_input(event_loop, callback, self.get_available_raw_input())
-
-        fds = self.get_input_descriptors()
-        handles = [event_loop.watch_file(fd, wrapper) for fd in fds]
-        self._current_event_loop_handles = handles
-
-    _input_timeout = None
-    _partial_codes = None
-
-    def _make_legacy_input_wrapper(self, event_loop, callback):
-        """
-        Support old Screen classes that still have a get_input_nonblocking and
-        expect it to work.
-        """
-
-        def wrapper():
-            if self._input_timeout:
-                event_loop.remove_alarm(self._input_timeout)
-                self._input_timeout = None
-            timeout, keys, raw = self.get_input_nonblocking()
-            if timeout is not None:
-                self._input_timeout = event_loop.alarm(timeout, wrapper)
-
-            callback(keys, raw)
-
-        return wrapper
-
-    def get_available_raw_input(self):
-        """
-        Return any currently-available input.  Does not block.
-
-        This method is only used by the default `hook_event_loop`
-        implementation; you can safely ignore it if you implement your own.
-        """
-        codes = self._get_gpm_codes() + self._get_keyboard_codes()
-
-        if self._partial_codes:
-            codes = self._partial_codes + codes
-            self._partial_codes = None
-
-        # clean out the pipe used to signal external event loops
-        # that a resize has occurred
-        with contextlib.suppress(OSError):
-            while True:
-                os.read(self._resize_pipe_rd, 1)
-
-        return codes
-
-    def parse_input(self, event_loop, callback, codes, wait_for_more=True):
-        """
-        Read any available input from get_available_raw_input, parses it into
-        keys, and calls the given callback.
-
-        The current implementation tries to avoid any assumptions about what
-        the screen or event loop look like; it only deals with parsing keycodes
-        and setting a timeout when an incomplete one is detected.
-
-        `codes` should be a sequence of keycodes, i.e. bytes.  A bytearray is
-        appropriate, but beware of using bytes, which only iterates as integers
-        on Python 3.
-        """
-        # Note: event_loop may be None for 100% synchronous support, only used
-        # by get_input.  Not documented because you shouldn't be doing it.
-        if self._input_timeout and event_loop:
-            event_loop.remove_alarm(self._input_timeout)
-            self._input_timeout = None
-
-        original_codes = codes
-        processed = []
-        try:
-            while codes:
-                run, codes = escape.process_keyqueue(codes, wait_for_more)
-                processed.extend(run)
-        except escape.MoreInputRequired:
-            # Set a timer to wait for the rest of the input; if it goes off
-            # without any new input having come in, use the partial input
-            k = len(original_codes) - len(codes)
-            processed_codes = original_codes[:k]
-            self._partial_codes = codes
-
-            def _parse_incomplete_input():
-                self._input_timeout = None
-                self._partial_codes = None
-                self.parse_input(event_loop, callback, codes, wait_for_more=False)
-
-            if event_loop:
-                self._input_timeout = event_loop.alarm(self.complete_wait, _parse_incomplete_input)
-
-        else:
-            processed_codes = original_codes
-            self._partial_codes = None
-
-        if self._resized:
-            processed.append("window resize")
-            self._resized = False
-
-        if callback:
-            callback(processed, processed_codes)
-            return None
-
-        # For get_input
-        return processed, processed_codes
-
-    def _get_keyboard_codes(self):
-        codes = []
-        while True:
-            code = self._getch_nodelay()
-            if code < 0:
-                break
-            codes.append(code)
-        return codes
-
-    def _get_gpm_codes(self):
-        codes = []
-        try:
-            while self.gpm_mev is not None and self.gpm_event_pending:
-                codes.extend(self._encode_gpm_event())
-        except OSError as e:
-            if e.args[0] != 11:
-                raise
-        return codes
-
-    def _wait_for_input_ready(self, timeout):
-        ready = None
-        fd_list = [self._resize_pipe_rd]
-        fd = self._input_fileno()
-        if fd is not None:
-            fd_list.append(fd)
-        if self.gpm_mev is not None:
-            fd_list.append(self.gpm_mev.stdout.fileno())
-        while True:
-            try:
-                if timeout is None:
-                    ready, _w, _err = select.select(fd_list, [], fd_list)
-                else:
-                    ready, _w, _err = select.select(fd_list, [], fd_list, timeout)
-                break
-            except OSError as e:
-                if e.args[0] != 4:
-                    raise
-                if self._resized:
-                    ready = []
-                    break
-        return ready
-
-    def _getch(self, timeout: int) -> int:
-        ready = self._wait_for_input_ready(timeout)
-        if self.gpm_mev is not None and self.gpm_mev.stdout.fileno() in ready:
-            self.gpm_event_pending = True
-        fd = self._input_fileno()
-        if fd is not None and fd in ready:
-            return ord(os.read(fd, 1))
-        return -1
-
-    def _encode_gpm_event(self) -> list[int]:
-        self.gpm_event_pending = False
-        s = self.gpm_mev.stdout.readline()
-        result = s.split(", ")
-        if len(result) != 6:
-            # unexpected output, stop tracking
-            self._stop_gpm_tracking()
-            signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED)
-            return []
-        ev, x, y, _ign, b, m = s.split(",")
-        ev = int(ev.split("x")[-1], 16)
-        x = int(x.split(" ")[-1])
-        y = int(y.lstrip().split(" ")[0])
-        b = int(b.split(" ")[-1])
-        m = int(m.split("x")[-1].rstrip(), 16)
-
-        # convert to xterm-like escape sequence
-
-        last_state = next_state = self.last_bstate
-        result = []
-
-        mod = 0
-        if m & 1:
-            mod |= 4  # shift
-        if m & 10:
-            mod |= 8  # alt
-        if m & 4:
-            mod |= 16  # ctrl
-
-        def append_button(b):
-            b |= mod
-            result.extend([27, ord("["), ord("M"), b + 32, x + 32, y + 32])
-
-        def determine_button_release(flag: int) -> None:
-            nonlocal next_state
-            if b & 4 and last_state & 1:
-                append_button(0 + flag)
-                next_state |= 1
-            if b & 2 and last_state & 2:
-                append_button(1 + flag)
-                next_state |= 2
-            if b & 1 and last_state & 4:
-                append_button(2 + flag)
-                next_state |= 4
-
-        if ev in {20, 36, 52}:  # press
-            if b & 4 and last_state & 1 == 0:
-                append_button(0)
-                next_state |= 1
-            if b & 2 and last_state & 2 == 0:
-                append_button(1)
-                next_state |= 2
-            if b & 1 and last_state & 4 == 0:
-                append_button(2)
-                next_state |= 4
-        elif ev == 146:  # drag
-            if b & 4:
-                append_button(0 + escape.MOUSE_DRAG_FLAG)
-            elif b & 2:
-                append_button(1 + escape.MOUSE_DRAG_FLAG)
-            elif b & 1:
-                append_button(2 + escape.MOUSE_DRAG_FLAG)
-        else:  # release
-            if b & 4 and last_state & 1:
-                append_button(0 + escape.MOUSE_RELEASE_FLAG)
-                next_state &= ~1
-            if b & 2 and last_state & 2:
-                append_button(1 + escape.MOUSE_RELEASE_FLAG)
-                next_state &= ~2
-            if b & 1 and last_state & 4:
-                append_button(2 + escape.MOUSE_RELEASE_FLAG)
-                next_state &= ~4
-        if ev == 40:  # double click (release)
-            if b & 4 and last_state & 1:
-                append_button(0 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
-            if b & 2 and last_state & 2:
-                append_button(1 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
-            if b & 1 and last_state & 4:
-                append_button(2 + escape.MOUSE_MULTIPLE_CLICK_FLAG)
-        elif ev == 52:
-            if b & 4 and last_state & 1:
-                append_button(0 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
-            if b & 2 and last_state & 2:
-                append_button(1 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
-            if b & 1 and last_state & 4:
-                append_button(2 + escape.MOUSE_MULTIPLE_CLICK_FLAG * 2)
-
-        self.last_bstate = next_state
-        return result
-
-    def _getch_nodelay(self):
-        return self._getch(0)
-
-    def get_cols_rows(self):
-        """Return the terminal dimensions (num columns, num rows)."""
-        y, x = 24, 80
-        with contextlib.suppress(OSError):  # Term size could not be determined
-            if hasattr(self._term_output_file, "fileno"):
-                buf = fcntl.ioctl(self._term_output_file.fileno(), termios.TIOCGWINSZ, " " * 4)
-                y, x = struct.unpack("hh", buf)
-
-        # Provide some lightweight fallbacks in case the TIOCWINSZ doesn't
-        # give sane answers
-        if (x <= 0 or y <= 0) and self.term in {"ansi", "vt100"}:
-            y, x = 24, 80
-        self.maxrow = y
-        return x, y
-
-    def _setup_G1(self):
-        """
-        Initialize the G1 character set to graphics mode if required.
-        """
-        if self._setup_G1_done:
-            return
-
-        while True:
-            with contextlib.suppress(OSError):
-                self.write(escape.DESIGNATE_G1_SPECIAL)
-                self.flush()
-                break
-        self._setup_G1_done = True
-
-    def draw_screen(self, maxres, r):
-        """Paint screen with rendered canvas."""
-
-        (maxcol, maxrow) = maxres
-
-        if not self._started:
-            raise RuntimeError
-
-        if maxrow != r.rows():
-            raise ValueError(maxrow)
-
-        # quick return if nothing has changed
-        if self.screen_buf and r is self._screen_buf_canvas:
-            return
-
-        self._setup_G1()
-
-        if self._resized:
-            # handle resize before trying to draw screen
-            return
-
-        o = [escape.HIDE_CURSOR, self._attrspec_to_escape(AttrSpec("", ""))]
-
-        def partial_display():
-            # returns True if the screen is in partial display mode
-            # ie. only some rows belong to the display
-            return self._rows_used is not None
-
-        if not partial_display():
-            o.append(escape.CURSOR_HOME)
-
-        if self.screen_buf:
-            osb = self.screen_buf
-        else:
-            osb = []
-        sb = []
-        cy = self._cy
-        y = -1
-
-        def set_cursor_home():
-            if not partial_display():
-                return escape.set_cursor_position(0, 0)
-            return escape.CURSOR_HOME_COL + escape.move_cursor_up(cy)
-
-        def set_cursor_row(y):
-            if not partial_display():
-                return escape.set_cursor_position(0, y)
-            return escape.move_cursor_down(y - cy)
-
-        def set_cursor_position(x, y):
-            if not partial_display():
-                return escape.set_cursor_position(x, y)
-            if cy > y:
-                return "\b" + escape.CURSOR_HOME_COL + escape.move_cursor_up(cy - y) + escape.move_cursor_right(x)
-            return "\b" + escape.CURSOR_HOME_COL + escape.move_cursor_down(y - cy) + escape.move_cursor_right(x)
-
-        def is_blank_row(row):
-            if len(row) > 1:
-                return False
-            if row[0][2].strip():
-                return False
-            return True
-
-        def attr_to_escape(a):
-            if a in self._pal_escape:
-                return self._pal_escape[a]
-            if isinstance(a, AttrSpec):
-                return self._attrspec_to_escape(a)
-            # undefined attributes use default/default
-            # TODO: track and report these
-            return self._attrspec_to_escape(AttrSpec("default", "default"))
-
-        def using_standout_or_underline(a):
-            a = self._pal_attrspec.get(a, a)
-            return isinstance(a, AttrSpec) and (a.standout or a.underline)
-
-        ins = None
-        o.append(set_cursor_home())
-        cy = 0
-        for row in r.content():
-            y += 1
-            if osb and y < len(osb) and osb[y] == row:
-                # this row of the screen buffer matches what is
-                # currently displayed, so we can skip this line
-                sb.append(osb[y])
-                continue
-
-            sb.append(row)
-
-            # leave blank lines off display when we are using
-            # the default screen buffer (allows partial screen)
-            if partial_display() and y > self._rows_used:
-                if is_blank_row(row):
-                    continue
-                self._rows_used = y
-
-            if y or partial_display():
-                o.append(set_cursor_position(0, y))
-            # after updating the line we will be just over the
-            # edge, but terminals still treat this as being
-            # on the same line
-            cy = y
-
-            whitespace_at_end = False
-            if row:
-                a, cs, run = row[-1]
-                if run[-1:] == b" " and self.back_color_erase and not using_standout_or_underline(a):
-                    whitespace_at_end = True
-                    row = row[:-1] + [(a, cs, run.rstrip(b" "))]  # noqa: PLW2901
-                elif y == maxrow - 1 and maxcol > 1:
-                    row, back, ins = self._last_row(row)  # noqa: PLW2901
-
-            first = True
-            lasta = lastcs = None
-            for a, cs, run in row:
-                if not isinstance(run, bytes):  # canvases should render with bytes
-                    raise TypeError(run)
-                if cs != "U":
-                    run = run.translate(UNPRINTABLE_TRANS_TABLE)  # noqa: PLW2901
-                if first or lasta != a:
-                    o.append(attr_to_escape(a))
-                    lasta = a
-                if first or lastcs != cs:
-                    if cs not in {None, "0", "U"}:
-                        raise ValueError(cs)
-                    if lastcs == "U":
-                        o.append(escape.IBMPC_OFF)
-
-                    if cs is None:
-                        o.append(escape.SI)
-                    elif cs == "U":
-                        o.append(escape.IBMPC_ON)
-                    else:
-                        o.append(escape.SO)
-                    lastcs = cs
-                o.append(run)
-                first = False
-            if ins:
-                (inserta, insertcs, inserttext) = ins
-                ias = attr_to_escape(inserta)
-                if insertcs not in {None, "0", "U"}:
-                    raise ValueError(insertcs)
-                if cs is None:
-                    icss = escape.SI
-                elif cs == "U":
-                    icss = escape.IBMPC_ON
-                else:
-                    icss = escape.SO
-                o += ["\x08" * back, ias, icss, escape.INSERT_ON, inserttext, escape.INSERT_OFF]
-
-                if cs == "U":
-                    o.append(escape.IBMPC_OFF)
-            if whitespace_at_end:
-                o.append(escape.ERASE_IN_LINE_RIGHT)
-
-        if r.cursor is not None:
-            x, y = r.cursor
-            o += [set_cursor_position(x, y), escape.SHOW_CURSOR]
-            self._cy = y
-
-        if self._resized:
-            # handle resize before trying to draw screen
-            return
-        try:
-            for line in o:
-                if isinstance(line, bytes):
-                    line = line.decode("utf-8", "replace")  # noqa: PLW2901
-                self.write(line)
-            self.flush()
-        except OSError as e:
-            # ignore interrupted syscall
-            if e.args[0] != 4:
-                raise
-
-        self.screen_buf = sb
-        self._screen_buf_canvas = r
-
-    def _last_row(self, row):
-        """On the last row we need to slide the bottom right character
-        into place. Calculate the new line, attr and an insert sequence
-        to do that.
-
-        eg. last row:
-        XXXXXXXXXXXXXXXXXXXXYZ
-
-        Y will be drawn after Z, shifting Z into position.
-        """
-
-        new_row = row[:-1]
-        z_attr, z_cs, last_text = row[-1]
-        last_cols = util.calc_width(last_text, 0, len(last_text))
-        last_offs, z_col = util.calc_text_pos(last_text, 0, len(last_text), last_cols - 1)
-        if last_offs == 0:
-            z_text = last_text
-            del new_row[-1]
-            # we need another segment
-            y_attr, y_cs, nlast_text = row[-2]
-            nlast_cols = util.calc_width(nlast_text, 0, len(nlast_text))
-            z_col += nlast_cols
-            nlast_offs, y_col = util.calc_text_pos(nlast_text, 0, len(nlast_text), nlast_cols - 1)
-            y_text = nlast_text[nlast_offs:]
-            if nlast_offs:
-                new_row.append((y_attr, y_cs, nlast_text[:nlast_offs]))
-        else:
-            z_text = last_text[last_offs:]
-            y_attr, y_cs = z_attr, z_cs
-            nlast_cols = util.calc_width(last_text, 0, last_offs)
-            nlast_offs, y_col = util.calc_text_pos(last_text, 0, last_offs, nlast_cols - 1)
-            y_text = last_text[nlast_offs:last_offs]
-            if nlast_offs:
-                new_row.append((y_attr, y_cs, last_text[:nlast_offs]))
-
-        new_row.append((z_attr, z_cs, z_text))
-        return new_row, z_col - y_col, (y_attr, y_cs, y_text)
-
-    def clear(self):
-        """
-        Force the screen to be completely repainted on the next
-        call to draw_screen().
-        """
-        self.screen_buf = None
-        self.setup_G1 = True
-
-    def _attrspec_to_escape(self, a):
-        """
-        Convert AttrSpec instance a to an escape sequence for the terminal
-
-        >>> s = Screen()
-        >>> s.set_terminal_properties(colors=256)
-        >>> a2e = s._attrspec_to_escape
-        >>> a2e(s.AttrSpec('brown', 'dark green'))
-        '\\x1b[0;33;42m'
-        >>> a2e(s.AttrSpec('#fea,underline', '#d0d'))
-        '\\x1b[0;38;5;229;4;48;5;164m'
-        """
-        if self.term == "fbterm":
-            fg = escape.ESC + "[1;%d}" % (a.foreground_number,)
-            bg = escape.ESC + "[2;%d}" % (a.background_number,)
-            return fg + bg
-
-        if a.foreground_true:
-            fg = "38;2;%d;%d;%d" % (a.get_rgb_values()[0:3])
-        elif a.foreground_high:
-            fg = "38;5;%d" % a.foreground_number
-        elif a.foreground_basic:
-            if a.foreground_number > 7:
-                if self.fg_bright_is_bold:
-                    fg = "1;%d" % (a.foreground_number - 8 + 30)
-                else:
-                    fg = "%d" % (a.foreground_number - 8 + 90)
-            else:
-                fg = "%d" % (a.foreground_number + 30)
-        else:
-            fg = "39"
-        st = (
-            "1;" * a.bold
-            + "3;" * a.italics
-            + "4;" * a.underline
-            + "5;" * a.blink
-            + "7;" * a.standout
-            + "9;" * a.strikethrough
-        )
-        if a.background_true:
-            bg = "48;2;%d;%d;%d" % (a.get_rgb_values()[3:6])
-        elif a.background_high:
-            bg = "48;5;%d" % a.background_number
-        elif a.background_basic:
-            if a.background_number > 7:
-                if self.bg_bright_is_blink:
-                    bg = "5;%d" % (a.background_number - 8 + 40)
-                else:
-                    # this doesn't work on most terminals
-                    bg = "%d" % (a.background_number - 8 + 100)
-            else:
-                bg = "%d" % (a.background_number + 40)
-        else:
-            bg = "49"
-        return f"{escape.ESC}[0;{fg};{st}{bg}m"
-
-    def set_terminal_properties(self, colors=None, bright_is_bold=None, has_underline=None):
-        """
-        colors -- number of colors terminal supports (1, 16, 88, 256, or 2**24)
-            or None to leave unchanged
-        bright_is_bold -- set to True if this terminal uses the bold
-            setting to create bright colors (numbers 8-15), set to False
-            if this Terminal can create bright colors without bold or
-            None to leave unchanged
-        has_underline -- set to True if this terminal can use the
-            underline setting, False if it cannot or None to leave
-            unchanged
-        """
-        if colors is None:
-            colors = self.colors
-        if bright_is_bold is None:
-            bright_is_bold = self.fg_bright_is_bold
-        if has_underline is None:
-            has_underline = self.has_underline
-
-        if colors == self.colors and bright_is_bold == self.fg_bright_is_bold and has_underline == self.has_underline:
-            return
-
-        self.colors = colors
-        self.fg_bright_is_bold = bright_is_bold
-        self.has_underline = has_underline
-
-        self.clear()
-        self._pal_escape = {}
-        for p, v in self._palette.items():
-            self._on_update_palette_entry(p, *v)
-
-    def reset_default_terminal_palette(self):
-        """
-        Attempt to set the terminal palette to default values as taken
-        from xterm.  Uses number of colors from current
-        set_terminal_properties() screen setting.
-        """
-        if self.colors == 1:
-            return
-        if self.colors == 2**24:
-            colors = 256
-        else:
-            colors = self.colors
-
-        def rgb_values(n):
-            if colors == 16:
-                aspec = AttrSpec("h%d" % n, "", 256)
-            else:
-                aspec = AttrSpec("h%d" % n, "", colors)
-            return aspec.get_rgb_values()[:3]
-
-        entries = [(n, *rgb_values(n)) for n in range(min(colors, 256))]
-        self.modify_terminal_palette(entries)
-
-    def modify_terminal_palette(self, entries):
-        """
-        entries - list of (index, red, green, blue) tuples.
-
-        Attempt to set part of the terminal palette (this does not work
-        on all terminals.)  The changes are sent as a single escape
-        sequence so they should all take effect at the same time.
-
-        0 <= index < 256 (some terminals will only have 16 or 88 colors)
-        0 <= red, green, blue < 256
-        """
-
-        if self.term == "fbterm":
-            modify = ["%d;%d;%d;%d" % (index, red, green, blue) for index, red, green, blue in entries]
-            self.write(f"\x1b[3;{';'.join(modify)}}}")
-        else:
-            modify = ["%d;rgb:%02x/%02x/%02x" % (index, red, green, blue) for index, red, green, blue in entries]
-            self.write(f"\x1b]4;{';'.join(modify)}\x1b\\")
-        self.flush()
-
-    # shortcut for creating an AttrSpec with this screen object's
-    # number of colors
-    def AttrSpec(self, fg, bg) -> AttrSpec:
-        return AttrSpec(fg, bg, self.colors)
-
-
-def _test():
-    import doctest
-
-    doctest.testmod()
-
-
-if __name__ == "__main__":
-    _test()
+else:
+    from ._posix_raw_display import Screen
diff --git a/urwid/vterm.py b/urwid/vterm.py
index b2e7bc82c..72c958332 100644
--- a/urwid/vterm.py
+++ b/urwid/vterm.py
@@ -24,11 +24,14 @@
 import atexit
 import copy
 import errno
+import fcntl
 import os
+import pty
 import select
 import signal
 import struct
 import sys
+import termios
 import time
 import traceback
 import typing
@@ -37,13 +40,6 @@
 from contextlib import suppress
 from dataclasses import dataclass
 
-try:
-    import fcntl
-    import pty
-    import termios
-except ImportError:
-    pass  # windows
-
 from urwid import event_loop, util
 from urwid.canvas import Canvas
 from urwid.display_common import _BASIC_COLORS, AttrSpec, RealTerminal, _color_desc_256, _color_desc_true
diff --git a/urwid/widget/wimp.py b/urwid/widget/wimp.py
index 084db9c0e..638b91363 100644
--- a/urwid/widget/wimp.py
+++ b/urwid/widget/wimp.py
@@ -667,6 +667,7 @@ def __init__(
 
           urwid.disconnect_signal(button, 'click', callback, user_data)
 
+        >>> from urwid.util import set_temporary_encoding
         >>> Button(u"Ok")
         <Button selectable flow widget 'Ok'>
         >>> b = Button("Cancel")
@@ -676,7 +677,8 @@ def __init__(
         >>> aligned_button.render((10,), focus=True).text
         [b'<  Test  >']
         >>> wrapped_button = Button("Long label", wrap=WrapMode.ELLIPSIS)
-        >>> wrapped_button.render((7,), focus=False).text[0].decode('utf-8')
+        >>> with set_temporary_encoding("utf-8"):
+        ...     wrapped_button.render((7,), focus=False).text[0].decode('utf-8')
         '< Lo… >'
         """
         self._label = SelectableIcon(label, 0, align=align, wrap=wrap, layout=layout)


Test patch:
diff --git a/tests/test_doctests.py b/tests/test_doctests.py
index f25c7013b..b28deced2 100644
--- a/tests/test_doctests.py
+++ b/tests/test_doctests.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import doctest
+import os
 import unittest
 
 import urwid
@@ -43,6 +44,13 @@ def load_tests(loader: unittest.TestLoader, tests: unittest.BaseTestSuite, ignor
         urwid.util,
         urwid.signals,
     ]
+
+    option_flags = doctest.ELLIPSIS | doctest.DONT_ACCEPT_TRUE_FOR_1 | doctest.IGNORE_EXCEPTION_DETAIL
     for m in module_doctests:
-        tests.addTests(doctest.DocTestSuite(m, optionflags=doctest.ELLIPSIS | doctest.IGNORE_EXCEPTION_DETAIL))
+        tests.addTests(doctest.DocTestSuite(m, optionflags=option_flags))
+
+    if os.name == "nt":
+        tests.addTests(doctest.DocTestSuite("urwid._win32_raw_display", optionflags=option_flags))
+    else:
+        tests.addTests(doctest.DocTestSuite("urwid._posix_raw_display", optionflags=option_flags))
     return tests
diff --git a/tests/test_event_loops.py b/tests/test_event_loops.py
index 009f99689..aa222c401 100644
--- a/tests/test_event_loops.py
+++ b/tests/test_event_loops.py
@@ -2,31 +2,63 @@
 
 import asyncio
 import os
+import socket
 import sys
 import typing
 import unittest
 
 import urwid
 
+if typing.TYPE_CHECKING:
+    from types import TracebackType
+
+IS_WINDOWS = os.name == "nt"
+
+
+class ClosingSocketPair(typing.ContextManager[typing.Tuple[socket.socket, socket.socket]]):
+    __slots__ = ("rd_s", "wr_s")
+
+    def __init__(self) -> None:
+        self.rd_s: socket.socket | None = None
+        self.wr_s: socket.socket | None = None
+
+    def __enter__(self) -> tuple[socket.socket, socket.socket]:
+        self.rd_s, self.wr_s = socket.socketpair()
+        return self.rd_s, self.wr_s
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_val: BaseException | None,
+        exc_tb: TracebackType | None,
+    ) -> None:
+        if self.rd_s is not None:
+            self.rd_s.close()
+        if self.wr_s is not None:
+            self.wr_s.close()
+
 
 class EventLoopTestMixin:
     def test_event_loop(self):
-        rd, wr = os.pipe()
         evl: urwid.EventLoop = self.evl
         out = []
+        rd: socket.socket
+        wr: socket.socket
 
         def step1():
             out.append("writing")
-            os.write(wr, b"hi")
+            wr.send(b"hi")
 
         def step2():
-            out.append(os.read(rd, 2).decode('ascii'))
+            out.append(rd.recv(2).decode("ascii"))
             raise urwid.ExitMainLoop
 
-        _handle = evl.alarm(0, step1)
-        _handle = evl.watch_file(rd, step2)
+        with ClosingSocketPair() as (rd, wr):
+            _handle = evl.alarm(0, step1)
+            _handle = evl.watch_file(rd.fileno(), step2)
+
+            evl.run()
 
-        evl.run()
         self.assertEqual(out, ["writing", "hi"])
 
     def test_remove_alarm(self):
@@ -43,10 +75,9 @@ def step1():
 
     def test_remove_watch_file(self):
         evl: urwid.EventLoop = self.evl
-        fd_r, fd_w = os.pipe()
 
-        try:
-            handle = evl.watch_file(fd_r, lambda: None)
+        with ClosingSocketPair() as (rd, _wr):
+            handle = evl.watch_file(rd.fileno(), lambda: None)
 
             def step1():
                 self.assertTrue(evl.remove_watch_file(handle))
@@ -55,17 +86,14 @@ def step1():
 
             evl.alarm(0, step1)
             evl.run()
-        finally:
-            os.close(fd_r)
-            os.close(fd_w)
 
     _expected_idle_handle = 1
 
     def test_run(self):
         evl: urwid.EventLoop = self.evl
         out = []
-        rd, wr = os.pipe()
-        self.assertEqual(os.write(wr, b"data"), 4)
+        wr: socket.socket
+        rd: socket.socket
 
         def say_hello():
             out.append("hello")
@@ -78,28 +106,31 @@ def exit_clean() -> typing.NoReturn:
             raise urwid.ExitMainLoop
 
         def exit_error() -> typing.NoReturn:
-            1/0
+            1 / 0
 
-        _handle = evl.alarm(0.01, exit_clean)
-        _handle = evl.alarm(0.005, say_hello)
-        idle_handle = evl.enter_idle(say_waiting)
-        if self._expected_idle_handle is not None:
-            self.assertEqual(idle_handle, 1)
+        with ClosingSocketPair() as (rd, wr):
+            self.assertEqual(wr.send(b"data"), 4)
 
-        evl.run()
-        self.assertTrue("waiting" in out, out)
-        self.assertTrue("hello" in out, out)
-        self.assertTrue("clean exit" in out, out)
-        handle = evl.watch_file(rd, exit_clean)
-        del out[:]
+            _handle = evl.alarm(0.01, exit_clean)
+            _handle = evl.alarm(0.005, say_hello)
+            idle_handle = evl.enter_idle(say_waiting)
+            if self._expected_idle_handle is not None:
+                self.assertEqual(idle_handle, 1)
 
-        evl.run()
-        self.assertEqual(["clean exit"], out)
-        self.assertTrue(evl.remove_watch_file(handle))
-        _handle = evl.alarm(0, exit_error)
-        self.assertRaises(ZeroDivisionError, evl.run)
-        _handle = evl.watch_file(rd, exit_error)
-        self.assertRaises(ZeroDivisionError, evl.run)
+            evl.run()
+            self.assertTrue("waiting" in out, out)
+            self.assertTrue("hello" in out, out)
+            self.assertTrue("clean exit" in out, out)
+            handle = evl.watch_file(rd.fileno(), exit_clean)
+            del out[:]
+
+            evl.run()
+            self.assertEqual(["clean exit"], out)
+            self.assertTrue(evl.remove_watch_file(handle))
+            _handle = evl.alarm(0, exit_error)
+            self.assertRaises(ZeroDivisionError, evl.run)
+            _handle = evl.watch_file(rd.fileno(), exit_error)
+            self.assertRaises(ZeroDivisionError, evl.run)
 
 
 class SelectEventLoopTest(unittest.TestCase, EventLoopTestMixin):
@@ -112,6 +143,7 @@ def setUp(self):
 except ImportError:
     pass
 else:
+
     class GLibEventLoopTest(unittest.TestCase, EventLoopTestMixin):
         def setUp(self):
             self.evl = urwid.GLibEventLoop()
@@ -127,9 +159,12 @@ def test_error(self):
 except ImportError:
     pass
 else:
+
+    @unittest.skipIf(IS_WINDOWS, "Windows is temporary not supported by TornadoEventLoop.")
     class TornadoEventLoopTest(unittest.TestCase, EventLoopTestMixin):
         def setUp(self):
             from tornado.ioloop import IOLoop
+
             self.evl = urwid.TornadoEventLoop(IOLoop())
 
         _expected_idle_handle = None
@@ -140,6 +175,8 @@ def setUp(self):
 except ImportError:
     pass
 else:
+
+    @unittest.skipIf(IS_WINDOWS, "Windows is temporary not supported by TwistedEventLoop.")
     class TwistedEventLoopTest(unittest.TestCase, EventLoopTestMixin):
         def setUp(self):
             self.evl = urwid.TwistedEventLoop()
@@ -157,11 +194,11 @@ def test_remove_watch_file(self):
         def test_run(self):
             evl = self.evl
             out = []
-            rd, wr = os.pipe()
-            self.assertEqual(os.write(wr, b"data"), 4)
+            wr: socket.socket
+            rd: socket.socket
 
             def step2():
-                out.append(os.read(rd, 2).decode('ascii'))
+                out.append(rd.recv(2).decode("ascii"))
 
             def say_hello():
                 out.append("hello")
@@ -176,14 +213,11 @@ def test_remove_alarm():
                 out.append("remove_alarm ok")
 
             def test_remove_watch_file():
-                fd_r, fd_w = os.pipe()
-                try:
-                    handle = evl.watch_file(fd_r, lambda: None)
+                with ClosingSocketPair() as (rd_, _wr):
+                    handle = evl.watch_file(rd_.fileno(), lambda: None)
                     self.assertTrue(evl.remove_watch_file(handle))
                     self.assertFalse(evl.remove_watch_file(handle))
-                finally:
-                    os.close(fd_r)
-                    os.close(fd_w)
+
                 out.append("remove_watch_file ok")
 
             def exit_clean() -> typing.NoReturn:
@@ -191,15 +225,19 @@ def exit_clean() -> typing.NoReturn:
                 raise urwid.ExitMainLoop
 
             def exit_error() -> typing.NoReturn:
-                1/0
-
-            _handle = evl.watch_file(rd, step2)
-            _handle = evl.alarm(0.1, exit_clean)
-            _handle = evl.alarm(0.05, say_hello)
-            _handle = evl.alarm(0.06, test_remove_alarm)
-            _handle = evl.alarm(0.07, test_remove_watch_file)
-            self.assertEqual(evl.enter_idle(say_waiting), 1)
-            evl.run()
+                1 / 0
+
+            with ClosingSocketPair() as (rd, wr):
+                self.assertEqual(wr.send(b"data"), 4)
+
+                _handle = evl.watch_file(rd.fileno(), step2)
+                _handle = evl.alarm(0.1, exit_clean)
+                _handle = evl.alarm(0.05, say_hello)
+                _handle = evl.alarm(0.06, test_remove_alarm)
+                _handle = evl.alarm(0.07, test_remove_watch_file)
+                self.assertEqual(evl.enter_idle(say_waiting), 1)
+                evl.run()
+
             self.assertTrue("da" in out, out)
             self.assertTrue("ta" in out, out)
             self.assertTrue("hello" in out, out)
@@ -212,6 +250,7 @@ def test_error(self):
             self.assertRaises(ZeroDivisionError, evl.run)
 
 
+@unittest.skipIf(IS_WINDOWS, "Windows is temporary not supported by AsyncioEventLoop.")
 class AsyncioEventLoopTest(unittest.TestCase, EventLoopTestMixin):
     def setUp(self):
         self.evl = urwid.AsyncioEventLoop()
@@ -243,6 +282,7 @@ async def error_coro():
 except ImportError:
     pass
 else:
+
     class TrioEventLoopTest(unittest.TestCase, EventLoopTestMixin):
         def setUp(self):
             self.evl = urwid.TrioEventLoop()
@@ -260,6 +300,8 @@ def test_error(self):
 except ImportError:
     pass
 else:
+
+    @unittest.skipIf(IS_WINDOWS, "ZMQEventLoop is not supported under windows")
     class ZMQEventLoopTest(unittest.TestCase, EventLoopTestMixin):
         def setUp(self):
             self.evl = urwid.ZMQEventLoop()
diff --git a/tests/test_vterm.py b/tests/test_vterm.py
index d0a295e40..511a4df64 100644
--- a/tests/test_vterm.py
+++ b/tests/test_vterm.py
@@ -28,10 +28,11 @@
 from itertools import dropwhile
 from time import sleep
 
-from urwid import BoxAdapter, Widget, signals, vterm
-from urwid.listbox import ListBox
+import urwid
 from urwid.util import set_temporary_encoding
 
+IS_WINDOWS = os.name == "nt"
+
 
 class DummyCommand:
     QUITSTRING = b'|||quit|||'
@@ -67,11 +68,12 @@ def quit(self) -> None:
         self.write(self.QUITSTRING)
 
 
+@unittest.skipIf(IS_WINDOWS, "Terminal is not supported under windows")
 class TermTest(unittest.TestCase):
     def setUp(self) -> None:
         self.command = DummyCommand()
 
-        self.term = vterm.Terminal(self.command)
+        self.term = urwid.Terminal(self.command)
         self.resize(80, 24)
 
     def tearDown(self) -> None:
@@ -80,17 +82,17 @@ def tearDown(self) -> None:
     def connect_signal(self, signal: str):
         self._sig_response = None
 
-        def _set_signal_response(widget: Widget, *args, **kwargs) -> None:
+        def _set_signal_response(widget: urwid.Widget, *args, **kwargs) -> None:
             self._sig_response = (args, kwargs)
         self._set_signal_response = _set_signal_response
 
-        signals.connect_signal(self.term, signal, self._set_signal_response)
+        urwid.connect_signal(self.term, signal, self._set_signal_response)
 
     def expect_signal(self, *args, **kwargs):
         self.assertEqual(self._sig_response, (args, kwargs))
 
     def disconnect_signal(self, signal: str) -> None:
-        signals.disconnect_signal(self.term, signal, self._set_signal_response)
+        urwid.disconnect_signal(self.term, signal, self._set_signal_response)
 
     def caught_beep(self, obj):
         self.beeped = True
@@ -393,7 +395,7 @@ def test_set_leds(self):
         self.disconnect_signal('leds')
 
     def test_in_listbox(self):
-        listbox = ListBox([BoxAdapter(self.term, 80)])
+        listbox = urwid.ListBox([urwid.BoxAdapter(self.term, 80)])
         rendered = listbox.render((80, 24))
 
     def test_bracketed_paste_mode_on(self):