feat(tui): implement curses emulation with ANSI escape sequences

- Add complete terminal handling with screen buffer management
- Implement keyboard and mouse input (SGR extended mouse protocol)
- Add support for text attributes, colors, and ACS line-drawing characters
- Handle wide characters and combining character merging
- Support both Windows (msvcrt) and Unix (termios) platforms
- Fix ERR constant to match standard curses (-1 instead of 1)
- Fix unctrl() return type annotation (str instead of int)
This commit is contained in:
Xuehai Pan 2026-02-02 01:06:20 +08:00
parent 319e88f16b
commit a8fe6924ad
5 changed files with 1760 additions and 292 deletions

View file

@ -1,20 +1,30 @@
accessdenied
acs
addch
addnstr
addstr
api
args
ascii
attr
bel
bg
bool
boolean
bstate
cbreak
chgat
chtype
cli
cmdline
codepoint
colorama
colorscheme
compat
conf
const
cpython
csi
csv
ctrl
ctx
@ -29,6 +39,7 @@ divmod
docstring
doctest
ecc
endwin
enum
env
environ
@ -36,6 +47,7 @@ esc
failsafe
fallbacks
fg
fileno
fmt
func
getch
@ -61,6 +73,7 @@ keras
kib
kmd
kwargs
leaveok
len
libcuda
libcudart
@ -75,6 +88,7 @@ mig
migdevice
milliwatts
mps
msvcrt
mypy
namespace
nan
@ -82,6 +96,7 @@ noheader
noqa
nosuchprocess
nounits
num
nvidia
nvidia-smi
nvisel
@ -90,6 +105,7 @@ nvml
nvmlerror
oneshot
ord
ored
os
ot
pan
@ -107,18 +123,27 @@ pytorch
redhat
reentrant
resourcemetriccollector
rgb
rlist
rss
rstrip
rtx
runtime
rw
rx
rxvt
sanitization
scrollok
selectable
setupterm
sgi's
sgr
shader
sm
smi
stderr
stdin
stdout
stdscr
str
struct
@ -130,6 +155,7 @@ superset
sys
tcc
tensorflow
termios
throughputinfo
toml
traceback
@ -141,10 +167,13 @@ uid
uids
unallocated
uncase
ungetch
unicode
unicodedata
uptime
utils
uuid
wcwidth
wddm
wdm
widestring

View file

@ -23,7 +23,7 @@ else:
if not HAS_CURSES_MODULE:
# pylint: disable-next=redefined-builtin
from nvitop.tui.library.curses import ascii # type: ignore[no-redef]
from nvitop.tui.library.curses._curses import * # type: ignore[assignment,no-redef] # noqa: F403
from nvitop.tui.library.curses._curses import * # type: ignore[assignment] # noqa: F403
if _TYPE_CHECKING:
from collections.abc import Callable as _Callable
@ -77,7 +77,7 @@ if not HAS_CURSES_MODULE:
def start_color() -> None: # pylint: disable=function-redefined
from nvitop.tui.library.curses import _curses
retval = _curses.start_color() # type: ignore[func-returns-value]
retval = _curses.start_color() # type: ignore[func-returns-value] # pylint: disable=assignment-from-no-return
if hasattr(_curses, 'COLORS'):
globals()['COLORS'] = _curses.COLORS
if hasattr(_curses, 'COLOR_PAIRS'):

File diff suppressed because it is too large Load diff

View file

@ -193,14 +193,14 @@ def alt(c: int | str) -> int | str:
@overload
def unctrl(c: int) -> int: ...
def unctrl(c: int) -> str: ...
@overload
def unctrl(c: str) -> str: ...
def unctrl(c: int | str) -> int | str:
def unctrl(c: int | str) -> str:
bits = _ctoi(c)
if bits == 0x7F:
rep = '^?'

View file

@ -6,8 +6,8 @@
from __future__ import annotations
import unicodedata
from typing import TYPE_CHECKING, Literal
from unicodedata import east_asian_width
if TYPE_CHECKING:
@ -18,27 +18,50 @@ __all__ = ['WideString', 'wcslen']
ASCIIONLY: frozenset[str] = frozenset(map(chr, range(1, 128)))
COMBINING: Literal[0] = 0
NARROW: Literal[1] = 1
WIDE: Literal[2] = 2
WIDE_SYMBOLS: frozenset[str] = frozenset('WF')
def utf_char_width(string: str) -> Literal[1, 2]:
"""Return the width of a single character."""
if east_asian_width(string) in WIDE_SYMBOLS:
return WIDE
def utf_char_width(string: str) -> Literal[0, 1, 2]: # pylint: disable=too-many-return-statements
"""Return the width of a single character (0 for combining, 2 for wide, 1 otherwise)."""
try:
import wcwidth # pylint: disable=import-outside-toplevel
w = wcwidth.wcwidth(string)
if w < 0:
return NARROW # control characters treated as width 1
if w == 0:
return COMBINING
if w >= 2:
return WIDE
except ImportError:
# Fallback to unicodedata
if unicodedata.combining(string):
return COMBINING
if unicodedata.east_asian_width(string) in WIDE_SYMBOLS:
return WIDE
return NARROW
def string_to_charlist(string: str) -> list[str]:
"""Return a list of characters with extra empty strings after wide chars."""
"""Return a list of characters with extra empty strings after wide chars.
Combining characters (width 0) are merged with the preceding character.
"""
if ASCIIONLY.issuperset(string):
return list(string)
result = []
result: list[str] = []
for char in string:
result.append(char)
if east_asian_width(char) in WIDE_SYMBOLS:
result.append('')
width = utf_char_width(char)
if width == COMBINING and result:
# Merge combining character with the previous character
result[-1] += char
else:
result.append(char)
if width == WIDE:
result.append('')
return result