-
-
Notifications
You must be signed in to change notification settings - Fork 34.4k
Expand file tree
/
Copy pathlayout.py
More file actions
268 lines (224 loc) · 8.34 KB
/
layout.py
File metadata and controls
268 lines (224 loc) · 8.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""Wrap content lines to the terminal width before rendering."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Self
from .content import ContentFragment, ContentLine
from .types import CursorXY, ScreenInfoRow
@dataclass(frozen=True, slots=True)
class LayoutRow:
"""Metadata for one physical screen row.
For the row ``>>> def greet(name):``::
>>> def greet(name):
╰─╯ ╰──────────────╯
4 char_widths=(1,1,1,…) ← 16 entries
buffer_advance=17 ← includes the newline
"""
prompt_width: int
char_widths: tuple[int, ...]
suffix_width: int = 0
buffer_advance: int = 0
@property
def width(self) -> int:
return self.prompt_width + sum(self.char_widths) + self.suffix_width
@property
def screeninfo(self) -> ScreenInfoRow:
widths = list(self.char_widths)
if self.suffix_width:
widths.append(self.suffix_width)
return self.prompt_width, widths
@dataclass(frozen=True, slots=True)
class LayoutMap:
"""Mapping between buffer positions and screen coordinates.
Single source of truth for cursor placement. Given::
>>> def greet(name): ← row 0, buffer_advance=17
... return name ← row 1, buffer_advance=15
▲cursor
``pos_to_xy(31)`` → ``(18, 1)``: prompt width 4 + 14 body chars.
"""
rows: tuple[LayoutRow, ...]
@classmethod
def empty(cls) -> Self:
return cls((LayoutRow(0, ()),))
@property
def screeninfo(self) -> list[ScreenInfoRow]:
return [row.screeninfo for row in self.rows]
def max_column(self, y: int) -> int:
return self.rows[y].width
def max_row(self) -> int:
return len(self.rows) - 1
def pos_to_xy(self, pos: int) -> CursorXY:
if not self.rows:
return 0, 0
remaining = pos
for y, row in enumerate(self.rows):
if remaining <= len(row.char_widths):
# Prompt-only leading rows are terminal scenery, not real
# buffer positions. Treating them as real just manufactures
# bugs.
if remaining == 0 and not row.char_widths and row.buffer_advance == 0 and y < len(self.rows) - 1:
continue
x = row.prompt_width
for width in row.char_widths[:remaining]:
x += width
return x, y
remaining -= row.buffer_advance
last_row = self.rows[-1]
return last_row.width - last_row.suffix_width, len(self.rows) - 1
def xy_to_pos(self, x: int, y: int) -> int:
if not self.rows:
return 0
pos = 0
for row in self.rows[:y]:
pos += row.buffer_advance
row = self.rows[y]
cur_x = row.prompt_width
char_widths = row.char_widths
i = 0
for i, width in enumerate(char_widths):
if cur_x >= x:
# Include trailing zero-width (combining) chars at this position
for trailing_width in char_widths[i:]:
if trailing_width == 0:
pos += 1
else:
break
return pos
if width == 0:
pos += 1
continue
cur_x += width
pos += 1
return pos
@dataclass(frozen=True, slots=True)
class WrappedRow:
"""One physical screen row after wrapping, ready for rendering.
When a line overflows the terminal width, it splits into
multiple rows with a ``\\`` continuation marker::
>>> x = "a very long li\\ ← suffix="\\", suffix_width=1
ne that wraps" ← prompt_text="" (continuation)
"""
prompt_text: str = ""
prompt_width: int = 0
fragments: tuple[ContentFragment, ...] = ()
layout_widths: tuple[int, ...] = ()
suffix: str = ""
suffix_width: int = 0
buffer_advance: int = 0
@dataclass(frozen=True, slots=True)
class LayoutResult:
wrapped_rows: tuple[WrappedRow, ...]
layout_map: LayoutMap
line_end_offsets: tuple[int, ...]
def layout_content_lines(
lines: tuple[ContentLine, ...],
width: int,
start_offset: int,
) -> LayoutResult:
"""Wrap content lines to fit *width* columns.
A short line passes through as one ``WrappedRow``; a long line is
split at the column boundary with ``\\`` markers::
>>> short = 1 ← one WrappedRow
>>> x = "a long stri\\ ← two WrappedRows, first has suffix="\\"
ng"
"""
if width <= 0:
return LayoutResult((), LayoutMap(()), ())
offset = start_offset
wrapped_rows: list[WrappedRow] = []
layout_rows: list[LayoutRow] = []
line_end_offsets: list[int] = []
for line in lines:
newline_advance = int(line.source.has_newline)
for leading in line.prompt.leading_lines:
line_end_offsets.append(offset)
wrapped_rows.append(
WrappedRow(
fragments=(leading,),
)
)
layout_rows.append(LayoutRow(0, (), buffer_advance=0))
prompt_text = line.prompt.text
prompt_width = line.prompt.width
body = tuple(line.body)
body_widths = tuple(fragment.width for fragment in body)
# Fast path: line fits on one row.
if not body_widths or (sum(body_widths) + prompt_width) < width:
offset += len(body) + newline_advance
line_end_offsets.append(offset)
wrapped_rows.append(
WrappedRow(
prompt_text=prompt_text,
prompt_width=prompt_width,
fragments=body,
layout_widths=body_widths,
buffer_advance=len(body) + newline_advance,
)
)
layout_rows.append(
LayoutRow(
prompt_width,
body_widths,
buffer_advance=len(body) + newline_advance,
)
)
continue
# Slow path: line needs wrapping.
current_prompt = prompt_text
current_prompt_width = prompt_width
start = 0
total = len(body)
while True:
# Find how many characters fit on this row.
index_to_wrap_before = 0
column = 0
for char_width in body_widths[start:]:
if column + char_width + current_prompt_width >= width:
break
index_to_wrap_before += 1
column += char_width
if index_to_wrap_before == 0 and start < total:
index_to_wrap_before = 1 # force progress
at_line_end = (start + index_to_wrap_before) >= total
if at_line_end:
offset += index_to_wrap_before + newline_advance
suffix = ""
suffix_width = 0
buffer_advance = index_to_wrap_before + newline_advance
else:
offset += index_to_wrap_before
suffix = "\\"
suffix_width = 1
buffer_advance = index_to_wrap_before
end = start + index_to_wrap_before
row_fragments = body[start:end]
row_widths = body_widths[start:end]
line_end_offsets.append(offset)
wrapped_rows.append(
WrappedRow(
prompt_text=current_prompt,
prompt_width=current_prompt_width,
fragments=row_fragments,
layout_widths=row_widths,
suffix=suffix,
suffix_width=suffix_width,
buffer_advance=buffer_advance,
)
)
layout_rows.append(
LayoutRow(
current_prompt_width,
row_widths,
suffix_width=suffix_width,
buffer_advance=buffer_advance,
)
)
start = end
current_prompt = ""
current_prompt_width = 0
if at_line_end:
break
return LayoutResult(
tuple(wrapped_rows),
LayoutMap(tuple(layout_rows)),
tuple(line_end_offsets),
)