Skip to content

Commit 8dc6081

Browse files
committed
pythongh-131507: Refactor screen and cursor position calculations
This is based off python#131509.
1 parent 3c25c95 commit 8dc6081

File tree

3 files changed

+97
-81
lines changed

3 files changed

+97
-81
lines changed

Lib/_pyrepl/reader.py

+48-75
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,11 @@
2525

2626
from contextlib import contextmanager
2727
from dataclasses import dataclass, field, fields
28-
import unicodedata
2928
from _colorize import can_colorize, ANSIColors
3029

3130

3231
from . import commands, console, input
33-
from .utils import wlen, unbracket, str_width
32+
from .utils import wlen, unbracket, disp_str
3433
from .trace import trace
3534

3635

@@ -39,36 +38,6 @@
3938
from .types import Callback, SimpleContextManager, KeySpec, CommandName
4039

4140

42-
def disp_str(buffer: str) -> tuple[str, list[int]]:
43-
"""disp_str(buffer:string) -> (string, [int])
44-
45-
Return the string that should be the printed representation of
46-
|buffer| and a list detailing where the characters of |buffer|
47-
get used up. E.g.:
48-
49-
>>> disp_str(chr(3))
50-
('^C', [1, 0])
51-
52-
"""
53-
b: list[int] = []
54-
s: list[str] = []
55-
for c in buffer:
56-
if c == '\x1a':
57-
s.append(c)
58-
b.append(2)
59-
elif ord(c) < 128:
60-
s.append(c)
61-
b.append(1)
62-
elif unicodedata.category(c).startswith("C"):
63-
c = r"\u%04x" % ord(c)
64-
s.append(c)
65-
b.append(len(c))
66-
else:
67-
s.append(c)
68-
b.append(str_width(c))
69-
return "".join(s), b
70-
71-
7241
# syntax classes:
7342

7443
SYNTAX_WHITESPACE, SYNTAX_WORD, SYNTAX_SYMBOL = range(3)
@@ -347,14 +316,12 @@ def calc_screen(self) -> list[str]:
347316
pos -= offset
348317

349318
prompt_from_cache = (offset and self.buffer[offset - 1] != "\n")
350-
351319
lines = "".join(self.buffer[offset:]).split("\n")
352-
353320
cursor_found = False
354321
lines_beyond_cursor = 0
355322
for ln, line in enumerate(lines, num_common_lines):
356-
ll = len(line)
357-
if 0 <= pos <= ll:
323+
line_len = len(line)
324+
if 0 <= pos <= line_len:
358325
self.lxy = pos, ln
359326
cursor_found = True
360327
elif cursor_found:
@@ -368,34 +335,35 @@ def calc_screen(self) -> list[str]:
368335
prompt_from_cache = False
369336
prompt = ""
370337
else:
371-
prompt = self.get_prompt(ln, ll >= pos >= 0)
338+
prompt = self.get_prompt(ln, line_len >= pos >= 0)
372339
while "\n" in prompt:
373340
pre_prompt, _, prompt = prompt.partition("\n")
374341
last_refresh_line_end_offsets.append(offset)
375342
screen.append(pre_prompt)
376343
screeninfo.append((0, []))
377-
pos -= ll + 1
378-
prompt, lp = self.process_prompt(prompt)
379-
l, l2 = disp_str(line)
380-
wrapcount = (wlen(l) + lp) // self.console.width
381-
if wrapcount == 0:
382-
offset += ll + 1 # Takes all of the line plus the newline
344+
pos -= line_len + 1
345+
prompt, prompt_len = self.process_prompt(prompt)
346+
chars, char_widths = disp_str(line)
347+
wrapcount = (sum(char_widths) + prompt_len) // self.console.width
348+
trace("wrapcount = {wrapcount}", wrapcount=wrapcount)
349+
if wrapcount == 0 or not char_widths:
350+
offset += line_len + 1 # Takes all of the line plus the newline
383351
last_refresh_line_end_offsets.append(offset)
384-
screen.append(prompt + l)
385-
screeninfo.append((lp, l2))
352+
screen.append(prompt + "".join(chars))
353+
screeninfo.append((prompt_len, char_widths))
386354
else:
387-
i = 0
388-
while l:
389-
prelen = lp if i == 0 else 0
355+
for wrap in range(wrapcount + 1):
356+
pre = prompt if wrap == 0 else ""
357+
prelen = prompt_len if wrap == 0 else 0
390358
index_to_wrap_before = 0
391359
column = 0
392-
for character_width in l2:
393-
if column + character_width >= self.console.width - prelen:
360+
# TODO: where's the column left for the \ ?
361+
for char_width in char_widths:
362+
if column + char_width + prelen >= self.console.width:
394363
break
395364
index_to_wrap_before += 1
396-
column += character_width
397-
pre = prompt if i == 0 else ""
398-
if len(l) > index_to_wrap_before:
365+
column += char_width
366+
if len(chars) > index_to_wrap_before:
399367
offset += index_to_wrap_before
400368
post = "\\"
401369
after = [1]
@@ -404,11 +372,12 @@ def calc_screen(self) -> list[str]:
404372
post = ""
405373
after = []
406374
last_refresh_line_end_offsets.append(offset)
407-
screen.append(pre + l[:index_to_wrap_before] + post)
408-
screeninfo.append((prelen, l2[:index_to_wrap_before] + after))
409-
l = l[index_to_wrap_before:]
410-
l2 = l2[index_to_wrap_before:]
411-
i += 1
375+
render = pre + "".join(chars[:index_to_wrap_before]) + post
376+
render_widths = char_widths[:index_to_wrap_before] + after
377+
screen.append(render)
378+
screeninfo.append((prelen, render_widths))
379+
chars = chars[index_to_wrap_before:]
380+
char_widths = char_widths[index_to_wrap_before:]
412381
self.screeninfo = screeninfo
413382
self.cxy = self.pos2xy()
414383
if self.msg:
@@ -537,9 +506,9 @@ def setpos_from_xy(self, x: int, y: int) -> None:
537506
pos = 0
538507
i = 0
539508
while i < y:
540-
prompt_len, character_widths = self.screeninfo[i]
541-
offset = len(character_widths) - character_widths.count(0)
542-
in_wrapped_line = prompt_len + sum(character_widths) >= self.console.width
509+
prompt_len, char_widths = self.screeninfo[i]
510+
offset = len(char_widths)
511+
in_wrapped_line = prompt_len + sum(char_widths) >= self.console.width
543512
if in_wrapped_line:
544513
pos += offset - 1 # -1 cause backslash is not in buffer
545514
else:
@@ -560,29 +529,33 @@ def setpos_from_xy(self, x: int, y: int) -> None:
560529

561530
def pos2xy(self) -> tuple[int, int]:
562531
"""Return the x, y coordinates of position 'pos'."""
563-
# this *is* incomprehensible, yes.
564-
p, y = 0, 0
565-
l2: list[int] = []
532+
533+
prompt_len, y = 0, 0
534+
char_widths: list[int] = []
566535
pos = self.pos
567536
assert 0 <= pos <= len(self.buffer)
537+
538+
# optimize for the common case: typing at the end of the buffer
568539
if pos == len(self.buffer) and len(self.screeninfo) > 0:
569540
y = len(self.screeninfo) - 1
570-
p, l2 = self.screeninfo[y]
571-
return p + sum(l2) + l2.count(0), y
541+
prompt_len, char_widths = self.screeninfo[y]
542+
return prompt_len + sum(char_widths), y
543+
544+
for prompt_len, char_widths in self.screeninfo:
545+
offset = len(char_widths)
546+
in_wrapped_line = prompt_len + sum(char_widths) >= self.console.width
547+
if in_wrapped_line:
548+
offset -= 1 # need to remove line-wrapping backslash
572549

573-
for p, l2 in self.screeninfo:
574-
l = len(l2) - l2.count(0)
575-
in_wrapped_line = p + sum(l2) >= self.console.width
576-
offset = l - 1 if in_wrapped_line else l # need to remove backslash
577550
if offset >= pos:
578551
break
579552

580-
if p + sum(l2) >= self.console.width:
581-
pos -= l - 1 # -1 cause backslash is not in buffer
582-
else:
583-
pos -= l + 1 # +1 cause newline is in buffer
553+
if not in_wrapped_line:
554+
offset += 1 # there's a newline in buffer
555+
556+
pos -= offset
584557
y += 1
585-
return p + sum(l2[:pos]), y
558+
return prompt_len + sum(char_widths[:pos]), y
586559

587560
def insert(self, text: str | list[str]) -> None:
588561
"""Insert 'text' at the insertion point."""

Lib/_pyrepl/types.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from collections.abc import Callable, Iterator
22

3-
Callback = Callable[[], object]
4-
SimpleContextManager = Iterator[None]
5-
KeySpec = str # like r"\C-c"
6-
CommandName = str # like "interrupt"
7-
EventTuple = tuple[CommandName, str]
8-
Completer = Callable[[str, int], str | None]
3+
type Callback = Callable[[], object]
4+
type SimpleContextManager = Iterator[None]
5+
type KeySpec = str # like r"\C-c"
6+
type CommandName = str # like "interrupt"
7+
type EventTuple = tuple[CommandName, str]
8+
type Completer = Callable[[str, int], str | None]
9+
type CharBuffer = list[str]
10+
type CharWidths = list[int]

Lib/_pyrepl/utils.py

+41
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
import unicodedata
33
import functools
44

5+
from typing import cast, Iterator, Literal, Match, NamedTuple, Pattern, Self
6+
7+
from .types import CharBuffer, CharWidths
8+
from .trace import trace
9+
510
ANSI_ESCAPE_SEQUENCE = re.compile(r"\x1b\[[ -@]*[A-~]")
611
ZERO_WIDTH_BRACKET = re.compile(r"\x01.*?\x02")
712
ZERO_WIDTH_TRANS = str.maketrans({"\x01": "", "\x02": ""})
@@ -36,3 +41,39 @@ def unbracket(s: str, including_content: bool = False) -> str:
3641
if including_content:
3742
return ZERO_WIDTH_BRACKET.sub("", s)
3843
return s.translate(ZERO_WIDTH_TRANS)
44+
45+
46+
def disp_str(buffer: str) -> tuple[CharBuffer, CharWidths]:
47+
r"""Decompose the input buffer into a printable variant.
48+
49+
Returns a tuple of two lists:
50+
- the first list is the input buffer, character by character;
51+
- the second list is the visible width of each character in the input
52+
buffer.
53+
54+
Examples:
55+
>>> utils.disp_str("a = 9")
56+
(['a', ' ', '=', ' ', '9'], [1, 1, 1, 1, 1])
57+
"""
58+
chars: CharBuffer = []
59+
char_widths: CharWidths = []
60+
61+
if not buffer:
62+
return chars, char_widths
63+
64+
for c in buffer:
65+
if c == "\x1a": # CTRL-Z on Windows
66+
chars.append(c)
67+
char_widths.append(2)
68+
elif ord(c) < 128:
69+
chars.append(c)
70+
char_widths.append(1)
71+
elif unicodedata.category(c).startswith("C"):
72+
c = r"\u%04x" % ord(c)
73+
chars.append(c)
74+
char_widths.append(len(c))
75+
else:
76+
chars.append(c)
77+
char_widths.append(str_width(c))
78+
trace("disp_str({buffer}) = {s}, {b}", buffer=repr(buffer), s=chars, b=char_widths)
79+
return chars, char_widths

0 commit comments

Comments
 (0)