batrachian-toad 0.5.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- batrachian_toad-0.5.22.dist-info/METADATA +197 -0
- batrachian_toad-0.5.22.dist-info/RECORD +120 -0
- batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
- batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
- batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
- toad/__init__.py +46 -0
- toad/__main__.py +4 -0
- toad/_loop.py +86 -0
- toad/about.py +90 -0
- toad/acp/agent.py +671 -0
- toad/acp/api.py +47 -0
- toad/acp/encode_tool_call_id.py +12 -0
- toad/acp/messages.py +138 -0
- toad/acp/prompt.py +54 -0
- toad/acp/protocol.py +426 -0
- toad/agent.py +62 -0
- toad/agent_schema.py +70 -0
- toad/agents.py +45 -0
- toad/ansi/__init__.py +1 -0
- toad/ansi/_ansi.py +1612 -0
- toad/ansi/_ansi_colors.py +264 -0
- toad/ansi/_control_codes.py +37 -0
- toad/ansi/_keys.py +251 -0
- toad/ansi/_sgr_styles.py +64 -0
- toad/ansi/_stream_parser.py +418 -0
- toad/answer.py +22 -0
- toad/app.py +557 -0
- toad/atomic.py +37 -0
- toad/cli.py +257 -0
- toad/code_analyze.py +28 -0
- toad/complete.py +34 -0
- toad/constants.py +58 -0
- toad/conversation_markdown.py +19 -0
- toad/danger.py +371 -0
- toad/data/agents/ampcode.com.toml +51 -0
- toad/data/agents/augmentcode.com.toml +40 -0
- toad/data/agents/claude.com.toml +41 -0
- toad/data/agents/docker.com.toml +59 -0
- toad/data/agents/geminicli.com.toml +28 -0
- toad/data/agents/goose.ai.toml +51 -0
- toad/data/agents/inference.huggingface.co.toml +33 -0
- toad/data/agents/kimi.com.toml +35 -0
- toad/data/agents/openai.com.toml +53 -0
- toad/data/agents/opencode.ai.toml +61 -0
- toad/data/agents/openhands.dev.toml +44 -0
- toad/data/agents/stakpak.dev.toml +61 -0
- toad/data/agents/vibe.mistral.ai.toml +27 -0
- toad/data/agents/vtcode.dev.toml +62 -0
- toad/data/images/frog.png +0 -0
- toad/data/sounds/turn-over.wav +0 -0
- toad/db.py +5 -0
- toad/dec.py +332 -0
- toad/directory.py +234 -0
- toad/directory_watcher.py +96 -0
- toad/fuzzy.py +140 -0
- toad/gist.py +2 -0
- toad/history.py +138 -0
- toad/jsonrpc.py +576 -0
- toad/menus.py +14 -0
- toad/messages.py +74 -0
- toad/option_content.py +51 -0
- toad/os.py +0 -0
- toad/path_complete.py +145 -0
- toad/path_filter.py +124 -0
- toad/paths.py +71 -0
- toad/pill.py +23 -0
- toad/prompt/extract.py +19 -0
- toad/prompt/resource.py +68 -0
- toad/protocol.py +28 -0
- toad/screens/action_modal.py +94 -0
- toad/screens/agent_modal.py +172 -0
- toad/screens/command_edit_modal.py +58 -0
- toad/screens/main.py +192 -0
- toad/screens/permissions.py +390 -0
- toad/screens/permissions.tcss +72 -0
- toad/screens/settings.py +254 -0
- toad/screens/settings.tcss +101 -0
- toad/screens/store.py +476 -0
- toad/screens/store.tcss +261 -0
- toad/settings.py +354 -0
- toad/settings_schema.py +318 -0
- toad/shell.py +263 -0
- toad/shell_read.py +42 -0
- toad/slash_command.py +34 -0
- toad/toad.tcss +752 -0
- toad/version.py +80 -0
- toad/visuals/columns.py +273 -0
- toad/widgets/agent_response.py +79 -0
- toad/widgets/agent_thought.py +41 -0
- toad/widgets/command_pane.py +224 -0
- toad/widgets/condensed_path.py +93 -0
- toad/widgets/conversation.py +1626 -0
- toad/widgets/danger_warning.py +65 -0
- toad/widgets/diff_view.py +709 -0
- toad/widgets/flash.py +81 -0
- toad/widgets/future_text.py +126 -0
- toad/widgets/grid_select.py +223 -0
- toad/widgets/highlighted_textarea.py +180 -0
- toad/widgets/mandelbrot.py +294 -0
- toad/widgets/markdown_note.py +13 -0
- toad/widgets/menu.py +147 -0
- toad/widgets/non_selectable_label.py +5 -0
- toad/widgets/note.py +18 -0
- toad/widgets/path_search.py +381 -0
- toad/widgets/plan.py +180 -0
- toad/widgets/project_directory_tree.py +74 -0
- toad/widgets/prompt.py +741 -0
- toad/widgets/question.py +337 -0
- toad/widgets/shell_result.py +35 -0
- toad/widgets/shell_terminal.py +18 -0
- toad/widgets/side_bar.py +74 -0
- toad/widgets/slash_complete.py +211 -0
- toad/widgets/strike_text.py +66 -0
- toad/widgets/terminal.py +526 -0
- toad/widgets/terminal_tool.py +338 -0
- toad/widgets/throbber.py +90 -0
- toad/widgets/tool_call.py +303 -0
- toad/widgets/user_input.py +23 -0
- toad/widgets/version.py +5 -0
- toad/widgets/welcome.py +31 -0
toad/ansi/_ansi.py
ADDED
|
@@ -0,0 +1,1612 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from itertools import accumulate
|
|
5
|
+
import re2 as re
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Any, Awaitable, Callable, Iterable, Literal, Mapping, NamedTuple
|
|
10
|
+
|
|
11
|
+
import rich.repr
|
|
12
|
+
|
|
13
|
+
from textual import events
|
|
14
|
+
from textual.color import Color
|
|
15
|
+
from textual.content import Content, EMPTY_CONTENT
|
|
16
|
+
from textual.geometry import clamp
|
|
17
|
+
from textual.style import Style, NULL_STYLE
|
|
18
|
+
|
|
19
|
+
from toad.ansi._ansi_colors import ANSI_COLORS
|
|
20
|
+
from toad.ansi._keys import TERMINAL_KEY_MAP, CURSOR_KEYS_APPLICATION
|
|
21
|
+
from toad.ansi._control_codes import CONTROL_CODES
|
|
22
|
+
from toad.ansi._sgr_styles import SGR_STYLES
|
|
23
|
+
from toad.ansi._stream_parser import (
|
|
24
|
+
StreamParser,
|
|
25
|
+
SeparatorToken,
|
|
26
|
+
PatternToken,
|
|
27
|
+
Pattern,
|
|
28
|
+
PatternCheck,
|
|
29
|
+
ParseResult,
|
|
30
|
+
Token,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from toad.dec import CHARSET_MAP
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def character_range(start: int, end: int) -> frozenset:
|
|
37
|
+
"""Build a set of characters between to code-points.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
start: Start codepoint.
|
|
41
|
+
end: End codepoint (inclusive)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A frozenset of the characters..
|
|
45
|
+
"""
|
|
46
|
+
return frozenset(map(chr, range(start, end + 1)))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ANSIToken:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DEC(NamedTuple):
|
|
54
|
+
slot: int
|
|
55
|
+
character_set: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DECInvoke(NamedTuple):
|
|
59
|
+
gl: int | None = None
|
|
60
|
+
gr: int | None = None
|
|
61
|
+
shift: int | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
DEC_SLOTS = {"(": 0, ")": 1, "*": 2, "+": 3, "-": 1, ".": 2, "//": 3}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def show(obj: object) -> object:
|
|
68
|
+
print(obj)
|
|
69
|
+
return obj
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class FEPattern(Pattern):
|
|
73
|
+
FINAL = character_range(0x30, 0x7E)
|
|
74
|
+
INTERMEDIATE = character_range(0x20, 0x2F)
|
|
75
|
+
CSI_TERMINATORS = character_range(0x40, 0x7E)
|
|
76
|
+
OSC_TERMINATORS = frozenset({"\x07", "\x9c"})
|
|
77
|
+
DSC_TERMINATORS = frozenset({"\x9c"})
|
|
78
|
+
|
|
79
|
+
def check(self) -> PatternCheck:
|
|
80
|
+
sequence = io.StringIO()
|
|
81
|
+
store = sequence.write
|
|
82
|
+
store(character := (yield))
|
|
83
|
+
|
|
84
|
+
match character:
|
|
85
|
+
# CSI
|
|
86
|
+
case "[":
|
|
87
|
+
CSI_TERMINATORS = self.CSI_TERMINATORS
|
|
88
|
+
while (character := (yield)) not in CSI_TERMINATORS:
|
|
89
|
+
store(character)
|
|
90
|
+
store(character)
|
|
91
|
+
return ("csi", sequence.getvalue())
|
|
92
|
+
|
|
93
|
+
# OSC
|
|
94
|
+
case "]":
|
|
95
|
+
last_character = ""
|
|
96
|
+
OSC_TERMINATORS = self.OSC_TERMINATORS
|
|
97
|
+
while (character := (yield)) not in OSC_TERMINATORS:
|
|
98
|
+
store(character)
|
|
99
|
+
if last_character == "\x1b" and character in {"\\", "\0x5c"}:
|
|
100
|
+
break
|
|
101
|
+
last_character = character
|
|
102
|
+
store(character)
|
|
103
|
+
|
|
104
|
+
return ("osc", sequence.getvalue())
|
|
105
|
+
|
|
106
|
+
# DCS
|
|
107
|
+
case "P":
|
|
108
|
+
print("TODO DCS")
|
|
109
|
+
last_character = ""
|
|
110
|
+
DSC_TERMINATORS = self.DSC_TERMINATORS
|
|
111
|
+
while (character := (yield)) not in DSC_TERMINATORS:
|
|
112
|
+
store(character)
|
|
113
|
+
if last_character == "\x1b" and character == "\\":
|
|
114
|
+
break
|
|
115
|
+
last_character = character
|
|
116
|
+
store(character)
|
|
117
|
+
return ("dcs", sequence.getvalue())
|
|
118
|
+
|
|
119
|
+
# Character set designation
|
|
120
|
+
case "(" | ")" | "*" | "+" | "-" | "." | "/":
|
|
121
|
+
if (character := (yield)) not in self.FINAL:
|
|
122
|
+
return False
|
|
123
|
+
store(character)
|
|
124
|
+
return ("dec", sequence.getvalue())
|
|
125
|
+
|
|
126
|
+
case "n" | "o" | "~" | "}" | "|" | "N" | "O":
|
|
127
|
+
return ("dec_invoke", sequence.getvalue())
|
|
128
|
+
|
|
129
|
+
# Line attribute
|
|
130
|
+
case "#":
|
|
131
|
+
print("LINE ATTRIBUTES")
|
|
132
|
+
store((yield))
|
|
133
|
+
return ("la", sequence.getvalue())
|
|
134
|
+
# ISO 2022: ESC SP
|
|
135
|
+
case " ":
|
|
136
|
+
store((yield))
|
|
137
|
+
return ("sp", sequence.getvalue())
|
|
138
|
+
case _:
|
|
139
|
+
return ("control", character)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ANSIParser(StreamParser[tuple[str, str]]):
|
|
143
|
+
"""Parse a stream of text containing escape sequences in to logical tokens."""
|
|
144
|
+
|
|
145
|
+
def parse(self) -> ParseResult[tuple[str, str]]:
|
|
146
|
+
NEW_LINE = "\n"
|
|
147
|
+
CARRIAGE_RETURN = "\r"
|
|
148
|
+
ESCAPE = "\x1b"
|
|
149
|
+
BACKSPACE = "\x08"
|
|
150
|
+
|
|
151
|
+
while True:
|
|
152
|
+
token = yield self.read_until(NEW_LINE, CARRIAGE_RETURN, ESCAPE, BACKSPACE)
|
|
153
|
+
if isinstance(token, SeparatorToken):
|
|
154
|
+
if token.text == ESCAPE:
|
|
155
|
+
token = yield self.read_patterns("\x1b", fe=FEPattern())
|
|
156
|
+
if isinstance(token, PatternToken):
|
|
157
|
+
yield token.value
|
|
158
|
+
else:
|
|
159
|
+
yield "separator", token.text
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
yield "content", token.text
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
EMPTY_LINE = Content()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
type ClearType = Literal["cursor_to_end", "cursor_to_beginning", "screen", "scrollback"]
|
|
169
|
+
ANSI_CLEAR: Mapping[int, ClearType] = {
|
|
170
|
+
0: "cursor_to_end",
|
|
171
|
+
1: "cursor_to_beginning",
|
|
172
|
+
2: "screen",
|
|
173
|
+
3: "scrollback",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@rich.repr.auto
|
|
178
|
+
class ANSIContent(NamedTuple):
|
|
179
|
+
"""Content to be written to the terminal."""
|
|
180
|
+
|
|
181
|
+
text: str
|
|
182
|
+
|
|
183
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
184
|
+
yield self.text
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@rich.repr.auto
|
|
188
|
+
class ANSICursor(NamedTuple):
|
|
189
|
+
"""Represents a single operation on the ANSI output.
|
|
190
|
+
|
|
191
|
+
All values may be `None` meaning "not set".
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
delta_x: int | None = None
|
|
195
|
+
"""Relative x change."""
|
|
196
|
+
delta_y: int | None = None
|
|
197
|
+
"""Relative y change."""
|
|
198
|
+
absolute_x: int | None = None
|
|
199
|
+
"""Replace x."""
|
|
200
|
+
absolute_y: int | None = None
|
|
201
|
+
"""Replace y."""
|
|
202
|
+
erase: bool = False
|
|
203
|
+
"""Erase (replace with spaces)?"""
|
|
204
|
+
clear_range: tuple[int | None, int | None] | None = None
|
|
205
|
+
"""Replace range (slice like)."""
|
|
206
|
+
relative: bool = False
|
|
207
|
+
"""Should replace be relative (`False`) or absolute (`True`)"""
|
|
208
|
+
update_background: bool = False
|
|
209
|
+
"""Optional style for remaining line."""
|
|
210
|
+
auto_scroll: bool = False
|
|
211
|
+
"""Perform a scroll with the movement?"""
|
|
212
|
+
|
|
213
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
214
|
+
yield "delta_x", self.delta_x, None
|
|
215
|
+
yield "delta_y", self.delta_y, None
|
|
216
|
+
yield "absolute_x", self.absolute_x, None
|
|
217
|
+
yield "absolute_y", self.absolute_y, None
|
|
218
|
+
yield "erase", self.erase, False
|
|
219
|
+
yield "clear_range", self.clear_range, None
|
|
220
|
+
yield "relative", self.relative, False
|
|
221
|
+
yield "update_background", self.update_background, False
|
|
222
|
+
yield "auto_scroll", self.auto_scroll, False
|
|
223
|
+
|
|
224
|
+
@lru_cache(maxsize=1024)
|
|
225
|
+
def get_clear_offsets(
|
|
226
|
+
self, cursor_offset: int, line_length: int
|
|
227
|
+
) -> tuple[int, int]:
|
|
228
|
+
"""Get replace offsets.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
cursor_offset: Current cursor offset.
|
|
232
|
+
line_length: Length of line.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
A pair of offsets (inclusive).
|
|
236
|
+
"""
|
|
237
|
+
assert self.clear_range is not None, (
|
|
238
|
+
"Only call this if the replace attribute has a value"
|
|
239
|
+
)
|
|
240
|
+
replace_start, replace_end = self.clear_range
|
|
241
|
+
if replace_start is None:
|
|
242
|
+
replace_start = cursor_offset
|
|
243
|
+
if replace_end is None:
|
|
244
|
+
replace_end = cursor_offset
|
|
245
|
+
if replace_start < 0:
|
|
246
|
+
replace_start = line_length + replace_start
|
|
247
|
+
if replace_end < 0:
|
|
248
|
+
replace_end = line_length + replace_end
|
|
249
|
+
if self.relative:
|
|
250
|
+
return (cursor_offset + replace_start, cursor_offset + replace_end)
|
|
251
|
+
else:
|
|
252
|
+
return (replace_start, replace_end)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@rich.repr.auto
|
|
256
|
+
class ANSINewLine:
|
|
257
|
+
"""New line (diffrent in alternate buffer)"""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@rich.repr.auto
|
|
261
|
+
class ANSIStyle(NamedTuple):
|
|
262
|
+
"""Update style."""
|
|
263
|
+
|
|
264
|
+
style: Style
|
|
265
|
+
|
|
266
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
267
|
+
yield self.style
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@rich.repr.auto
|
|
271
|
+
class ANSIClear(NamedTuple):
|
|
272
|
+
"""Enumeration for clearing the 'screen'."""
|
|
273
|
+
|
|
274
|
+
clear: ClearType
|
|
275
|
+
|
|
276
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
277
|
+
yield self.clear
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@rich.repr.auto
|
|
281
|
+
class ANSIScrollMargin(NamedTuple):
|
|
282
|
+
"""Set the scroll margin."""
|
|
283
|
+
|
|
284
|
+
top: int | None = None
|
|
285
|
+
bottom: int | None = None
|
|
286
|
+
|
|
287
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
288
|
+
yield self.top
|
|
289
|
+
yield self.bottom
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@rich.repr.auto
|
|
293
|
+
class ANSIScroll(NamedTuple):
|
|
294
|
+
"""Scroll buffer."""
|
|
295
|
+
|
|
296
|
+
direction: Literal[+1, -1]
|
|
297
|
+
lines: int
|
|
298
|
+
|
|
299
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
300
|
+
yield self.direction
|
|
301
|
+
yield self.lines
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class ANSIFeatures(NamedTuple):
|
|
305
|
+
"""Terminal feature flags."""
|
|
306
|
+
|
|
307
|
+
show_cursor: bool | None = None
|
|
308
|
+
alternate_screen: bool | None = None
|
|
309
|
+
bracketed_paste: bool | None = None
|
|
310
|
+
cursor_blink: bool | None = None
|
|
311
|
+
cursor_keys: bool | None = None
|
|
312
|
+
replace_mode: bool | None = None
|
|
313
|
+
auto_wrap: bool | None = None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
MOUSE_TRACKING_MODES = Literal["button", "drag", "all"]
|
|
317
|
+
MOUSE_FORMAT = Literal["normal", "utf8", "sgr", "urxvt"]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class ANSIMouseTracking(NamedTuple):
|
|
321
|
+
"""Set mouse tracking."""
|
|
322
|
+
|
|
323
|
+
mode: Literal["none"] | MOUSE_TRACKING_MODES | None = None
|
|
324
|
+
format: MOUSE_FORMAT | None = None
|
|
325
|
+
focus_events: bool | None = None
|
|
326
|
+
alternate_scroll: bool | None = None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# Not technically part of the terminal protocol
|
|
330
|
+
@rich.repr.auto
|
|
331
|
+
class ANSIWorkingDirectory(NamedTuple):
|
|
332
|
+
"""Working directory changed"""
|
|
333
|
+
|
|
334
|
+
path: str
|
|
335
|
+
|
|
336
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
337
|
+
yield self.path
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@rich.repr.auto
|
|
341
|
+
class ANSICharacterSet(NamedTuple):
|
|
342
|
+
"""Updated character set state."""
|
|
343
|
+
|
|
344
|
+
dec: DEC | None = None
|
|
345
|
+
dec_invoke: DECInvoke | None = None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@rich.repr.auto
|
|
349
|
+
class ANSICursorPositionRequest(NamedTuple):
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
type ANSICommand = (
|
|
354
|
+
ANSIStyle
|
|
355
|
+
| ANSIContent
|
|
356
|
+
| ANSICursor
|
|
357
|
+
| ANSINewLine
|
|
358
|
+
| ANSIClear
|
|
359
|
+
| ANSIScrollMargin
|
|
360
|
+
| ANSIScroll
|
|
361
|
+
| ANSIWorkingDirectory
|
|
362
|
+
| ANSICharacterSet
|
|
363
|
+
| ANSIFeatures
|
|
364
|
+
| ANSIMouseTracking
|
|
365
|
+
| ANSICursorPositionRequest
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class ANSIStream:
|
|
370
|
+
def __init__(self) -> None:
|
|
371
|
+
self.parser = ANSIParser()
|
|
372
|
+
self.style = NULL_STYLE
|
|
373
|
+
self.show_cursor = True
|
|
374
|
+
|
|
375
|
+
@classmethod
|
|
376
|
+
@lru_cache(maxsize=1024)
|
|
377
|
+
def _parse_sgr(cls, sgr: str) -> Style | None:
|
|
378
|
+
"""Parse a SGR (Select Graphics Rendition) code in to a Style instance,
|
|
379
|
+
or `None` to indicate a reset.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
sgr: SGR sequence.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
A Visual Style, or `None`.
|
|
386
|
+
"""
|
|
387
|
+
codes = [
|
|
388
|
+
code if code < 255 else 255
|
|
389
|
+
for code in map(int, [sgr_code or "0" for sgr_code in sgr.split(";")])
|
|
390
|
+
]
|
|
391
|
+
style = NULL_STYLE
|
|
392
|
+
while codes:
|
|
393
|
+
match codes:
|
|
394
|
+
case [38, 2, red, green, blue, *codes]:
|
|
395
|
+
# Foreground RGB
|
|
396
|
+
style += Style(foreground=Color(red, green, blue))
|
|
397
|
+
case [48, 2, red, green, blue, *codes]:
|
|
398
|
+
# Background RGB
|
|
399
|
+
style += Style(background=Color(red, green, blue))
|
|
400
|
+
case [38, 5, ansi_color, *codes]:
|
|
401
|
+
# Foreground ANSI
|
|
402
|
+
style += Style(foreground=ANSI_COLORS[ansi_color])
|
|
403
|
+
case [48, 5, ansi_color, *codes]:
|
|
404
|
+
# Background ANSI
|
|
405
|
+
style += Style(background=ANSI_COLORS[ansi_color])
|
|
406
|
+
case [0, *codes]:
|
|
407
|
+
# reset
|
|
408
|
+
return None
|
|
409
|
+
case [code, *codes]:
|
|
410
|
+
if sgr_style := SGR_STYLES.get(code):
|
|
411
|
+
style += sgr_style
|
|
412
|
+
|
|
413
|
+
return style
|
|
414
|
+
|
|
415
|
+
def feed(self, text: str) -> Iterable[ANSICommand]:
|
|
416
|
+
"""Feed text potentially containing ANSI sequences, and parse in to
|
|
417
|
+
an iterable of ansi commands.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
text: Text to feed.
|
|
421
|
+
|
|
422
|
+
Yields:
|
|
423
|
+
`ANSICommand` instances.
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
for token in self.parser.feed(text):
|
|
427
|
+
if not isinstance(token, Token):
|
|
428
|
+
yield from self.on_token(token)
|
|
429
|
+
|
|
430
|
+
ANSI_SEPARATORS = {
|
|
431
|
+
"\n": ANSICursor(delta_y=+1, absolute_x=0),
|
|
432
|
+
"\r": ANSICursor(absolute_x=0),
|
|
433
|
+
"\x08": ANSICursor(delta_x=-1),
|
|
434
|
+
}
|
|
435
|
+
CLEAR_LINE_CURSOR_TO_END = ANSICursor(
|
|
436
|
+
clear_range=(None, -1), erase=True, update_background=True
|
|
437
|
+
)
|
|
438
|
+
CLEAR_LINE_CURSOR_TO_BEGINNING = ANSICursor(
|
|
439
|
+
clear_range=(0, None), erase=True, update_background=True
|
|
440
|
+
)
|
|
441
|
+
CLEAR_LINE = ANSICursor(clear_range=(0, -1), erase=True, update_background=True)
|
|
442
|
+
CLEAR_SCREEN_CURSOR_TO_END = ANSIClear("cursor_to_end")
|
|
443
|
+
CLEAR_SCREEN_CURSOR_TO_BEGINNING = ANSIClear("cursor_to_beginning")
|
|
444
|
+
CLEAR_SCREEN = ANSIClear("screen")
|
|
445
|
+
CLEAR_SCREEN_SCROLLBACK = ANSIClear("scrollback")
|
|
446
|
+
SHOW_CURSOR = ANSIFeatures(show_cursor=True)
|
|
447
|
+
HIDE_CURSOR = ANSIFeatures(show_cursor=False)
|
|
448
|
+
ENABLE_ALTERNATE_SCREEN = ANSIFeatures(alternate_screen=True)
|
|
449
|
+
DISABLE_ALTERNATE_SCREEN = ANSIFeatures(alternate_screen=False)
|
|
450
|
+
ENABLE_BRACKETED_PASTE = ANSIFeatures(bracketed_paste=True)
|
|
451
|
+
DISABLE_BRACKETED_PASTE = ANSIFeatures(bracketed_paste=False)
|
|
452
|
+
ENABLE_CURSOR_BLINK = ANSIFeatures(cursor_blink=True)
|
|
453
|
+
DISABLE_CURSOR_BLINK = ANSIFeatures(cursor_blink=False)
|
|
454
|
+
ENABLE_CURSOR_KEYS_APPLICATION_MODE = ANSIFeatures(cursor_keys=True)
|
|
455
|
+
DISABLE_CURSOR_KEYS_APPLICATION_MODE = ANSIFeatures(cursor_keys=False)
|
|
456
|
+
ENABLE_REPLACE_MODE = ANSIFeatures(replace_mode=True)
|
|
457
|
+
DISABLE_REPLACE_MODE = ANSIFeatures(replace_mode=False)
|
|
458
|
+
ENABLE_AUTO_WRAP = ANSIFeatures(auto_wrap=True)
|
|
459
|
+
DISABLE_AUTO_WRAP = ANSIFeatures(auto_wrap=False)
|
|
460
|
+
|
|
461
|
+
INVOKE_G2_INTO_GL = DECInvoke(gl=2)
|
|
462
|
+
INVOKE_G3_INTO_GL = DECInvoke(gl=3)
|
|
463
|
+
INVOKE_G1_INTO_GR = DECInvoke(gr=1)
|
|
464
|
+
INVOKE_G2_INTO_GR = DECInvoke(gr=2)
|
|
465
|
+
INVOKE_G3_INTO_GR = DECInvoke(gr=3)
|
|
466
|
+
SHIFT_G2 = DECInvoke(shift=2)
|
|
467
|
+
SHIFT_G3 = DECInvoke(shift=3)
|
|
468
|
+
|
|
469
|
+
DEC_INVOKE_MAP = {
|
|
470
|
+
"n": INVOKE_G2_INTO_GL,
|
|
471
|
+
"o": INVOKE_G3_INTO_GL,
|
|
472
|
+
"~": INVOKE_G1_INTO_GR,
|
|
473
|
+
"}": INVOKE_G2_INTO_GR,
|
|
474
|
+
"|": INVOKE_G3_INTO_GR,
|
|
475
|
+
"N": SHIFT_G2,
|
|
476
|
+
"O": SHIFT_G3,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
@classmethod
|
|
480
|
+
@lru_cache(maxsize=1024)
|
|
481
|
+
def _parse_csi(cls, csi: str) -> ANSICommand | None:
|
|
482
|
+
"""Parse CSI sequence in to an ansi segment.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
csi: CSI sequence.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Ansi segment, or `None` if one couldn't be decoded.
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
if match := re.fullmatch(r"\[(\d+)?(?:;)?(\d*)?(\w)", csi):
|
|
492
|
+
match_groups = match.groups(default="")
|
|
493
|
+
match match_groups:
|
|
494
|
+
case [lines, _, "A"]:
|
|
495
|
+
# CUU - Cursor Up: ESC[nA
|
|
496
|
+
return ANSICursor(delta_y=-int(lines or 1))
|
|
497
|
+
case [lines, _, "B"]:
|
|
498
|
+
# CUD - Cursor Down: ESC[nB
|
|
499
|
+
return ANSICursor(delta_y=+int(lines or 1))
|
|
500
|
+
case [cells, _, "C"]:
|
|
501
|
+
# CUF - Cursor Forward: ESC[nC
|
|
502
|
+
return ANSICursor(delta_x=+int(cells or 1))
|
|
503
|
+
case [cells, _, "D"]:
|
|
504
|
+
# CUB - Cursor Back: ESC[nD
|
|
505
|
+
return ANSICursor(delta_x=-int(cells or 1))
|
|
506
|
+
case [lines, _, "E"]:
|
|
507
|
+
# CNL - Cursor Next Line: ESC[nE
|
|
508
|
+
return ANSICursor(absolute_x=0, delta_y=+int(lines or 1))
|
|
509
|
+
case [lines, _, "F"]:
|
|
510
|
+
# CPL - Cursor Previous Line: ESC[nF
|
|
511
|
+
return ANSICursor(absolute_x=0, delta_y=-int(lines or 1))
|
|
512
|
+
case [cells, _, "G"]:
|
|
513
|
+
# CHA - Cursor Horizontal Absolute: ESC[nG
|
|
514
|
+
return ANSICursor(absolute_x=+int(cells or 1) - 1)
|
|
515
|
+
case [row, column, "H" | "f"]:
|
|
516
|
+
# CUP - Cursor Position: ESC[n;mH
|
|
517
|
+
# HVP - Horizontal Vertical Position: ESC[n;mf
|
|
518
|
+
return ANSICursor(
|
|
519
|
+
absolute_x=int(column or 1) - 1,
|
|
520
|
+
absolute_y=int(row or 1) - 1,
|
|
521
|
+
)
|
|
522
|
+
case [characters, _, "P"]:
|
|
523
|
+
return ANSICursor(
|
|
524
|
+
clear_range=(0, int(characters or 1) - 1),
|
|
525
|
+
relative=True,
|
|
526
|
+
erase=True,
|
|
527
|
+
)
|
|
528
|
+
case [lines, _, "S"]:
|
|
529
|
+
return ANSIScroll(-1, int(lines))
|
|
530
|
+
case [lines, _, "T"]:
|
|
531
|
+
return ANSIScroll(+1, int(lines))
|
|
532
|
+
case [row, _, "d"]:
|
|
533
|
+
# VPA - Vertical Position Absolute: ESC[nd
|
|
534
|
+
return ANSICursor(absolute_y=int(row or 1) - 1)
|
|
535
|
+
case [characters, _, "X"]:
|
|
536
|
+
return ANSICursor(
|
|
537
|
+
clear_range=(0, int(characters or 1) - 1),
|
|
538
|
+
relative=True,
|
|
539
|
+
erase=False,
|
|
540
|
+
)
|
|
541
|
+
case ["0" | "", _, "J"]:
|
|
542
|
+
return cls.CLEAR_SCREEN_CURSOR_TO_END
|
|
543
|
+
case ["1", _, "J"]:
|
|
544
|
+
return cls.CLEAR_SCREEN_CURSOR_TO_BEGINNING
|
|
545
|
+
case ["2", _, "J"]:
|
|
546
|
+
return cls.CLEAR_SCREEN
|
|
547
|
+
case ["3", _, "J"]:
|
|
548
|
+
return cls.CLEAR_SCREEN_SCROLLBACK
|
|
549
|
+
case ["0" | "", _, "K"]:
|
|
550
|
+
return cls.CLEAR_LINE_CURSOR_TO_END
|
|
551
|
+
case ["1", _, "K"]:
|
|
552
|
+
return cls.CLEAR_LINE_CURSOR_TO_BEGINNING
|
|
553
|
+
case ["2", _, "K"]:
|
|
554
|
+
return cls.CLEAR_LINE
|
|
555
|
+
case [top, bottom, "r"]:
|
|
556
|
+
return ANSIScrollMargin(
|
|
557
|
+
int(top or "1") - 1 if top else None,
|
|
558
|
+
int(bottom or "1") - 1 if top else None,
|
|
559
|
+
)
|
|
560
|
+
case ["4", _, "h" | "l" as replace_mode]:
|
|
561
|
+
return (
|
|
562
|
+
cls.ENABLE_REPLACE_MODE
|
|
563
|
+
if replace_mode == "h"
|
|
564
|
+
else cls.DISABLE_REPLACE_MODE
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
case ["6", _, "n"]:
|
|
568
|
+
return ANSICursorPositionRequest()
|
|
569
|
+
|
|
570
|
+
case _:
|
|
571
|
+
print("Unknown CSI (a)", repr(csi))
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
elif match := re.fullmatch(r"\[([0-9:;<=>?]*)([!-/]*)([@-~])", csi):
|
|
575
|
+
match match.groups(default=""):
|
|
576
|
+
case ["?25", "", "h"]:
|
|
577
|
+
return cls.SHOW_CURSOR
|
|
578
|
+
case ["?25", "", "l"]:
|
|
579
|
+
return cls.HIDE_CURSOR
|
|
580
|
+
case ["?1049", "", "h"]:
|
|
581
|
+
return cls.ENABLE_ALTERNATE_SCREEN
|
|
582
|
+
case ["?1049", "", "l"]:
|
|
583
|
+
return cls.DISABLE_ALTERNATE_SCREEN
|
|
584
|
+
case ["?2004", "", "h"]:
|
|
585
|
+
return cls.ENABLE_BRACKETED_PASTE
|
|
586
|
+
case ["?2004", "", "l"]:
|
|
587
|
+
return cls.DISABLE_BRACKETED_PASTE
|
|
588
|
+
case ["?12", "", "h"]:
|
|
589
|
+
return cls.ENABLE_CURSOR_BLINK
|
|
590
|
+
case ["?12", "", "l"]:
|
|
591
|
+
return cls.DISABLE_CURSOR_BLINK
|
|
592
|
+
case ["?1", "", "h"]:
|
|
593
|
+
return cls.ENABLE_CURSOR_KEYS_APPLICATION_MODE
|
|
594
|
+
case ["?1", "", "l"]:
|
|
595
|
+
return cls.DISABLE_CURSOR_KEYS_APPLICATION_MODE
|
|
596
|
+
case ["?7", "", "h"]:
|
|
597
|
+
return cls.ENABLE_AUTO_WRAP
|
|
598
|
+
case ["?7", "", "l"]:
|
|
599
|
+
return cls.DISABLE_AUTO_WRAP
|
|
600
|
+
|
|
601
|
+
# \x1b[22;0;0t
|
|
602
|
+
case [param1, param2, "t"]:
|
|
603
|
+
print("TODO", "XTWINOPS", param1, param2)
|
|
604
|
+
# 't' = XTWINOPS (Window manipulation)
|
|
605
|
+
return None
|
|
606
|
+
case _:
|
|
607
|
+
if match := re.fullmatch(r"\[\?([0-9;]+)([hl])", csi):
|
|
608
|
+
modes = [m for m in match.group(1).split(";")]
|
|
609
|
+
enable = match.group(2) == "h"
|
|
610
|
+
tracking: Literal["none"] | MOUSE_TRACKING_MODES | None = None
|
|
611
|
+
format: MOUSE_FORMAT | None = None
|
|
612
|
+
focus_events: bool | None = None
|
|
613
|
+
alternate_scroll: bool | None = None
|
|
614
|
+
for mode in modes:
|
|
615
|
+
if mode == "1000":
|
|
616
|
+
tracking = "button" if enable else "none"
|
|
617
|
+
elif mode == "1002":
|
|
618
|
+
tracking = "drag" if enable else "none"
|
|
619
|
+
elif mode == "1003":
|
|
620
|
+
tracking = "all" if enable else "none"
|
|
621
|
+
elif mode == "1006":
|
|
622
|
+
format = "sgr"
|
|
623
|
+
elif mode == "1015":
|
|
624
|
+
format = "urxvt"
|
|
625
|
+
elif mode == "1004":
|
|
626
|
+
focus_events = enable
|
|
627
|
+
elif mode == "1007":
|
|
628
|
+
alternate_scroll = enable
|
|
629
|
+
return ANSIMouseTracking(
|
|
630
|
+
mode=tracking,
|
|
631
|
+
format=format,
|
|
632
|
+
focus_events=focus_events,
|
|
633
|
+
alternate_scroll=alternate_scroll,
|
|
634
|
+
)
|
|
635
|
+
else:
|
|
636
|
+
print("Unknown CSI (b)", repr(csi))
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
print("Unknown CSI (c)", repr(csi))
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
def on_token(self, token: tuple[str, str]) -> Iterable[ANSICommand]:
|
|
643
|
+
match token:
|
|
644
|
+
case ["separator", separator]:
|
|
645
|
+
if separator == "\n":
|
|
646
|
+
yield ANSINewLine()
|
|
647
|
+
else:
|
|
648
|
+
yield self.ANSI_SEPARATORS[separator]
|
|
649
|
+
|
|
650
|
+
case ["osc", osc]:
|
|
651
|
+
match osc[1:].split(";"):
|
|
652
|
+
case ["8", *_, link]:
|
|
653
|
+
self.style += Style(link=link or None)
|
|
654
|
+
case ["2025", current_directory, *_]:
|
|
655
|
+
self.current_directory = current_directory
|
|
656
|
+
yield ANSIWorkingDirectory(current_directory)
|
|
657
|
+
|
|
658
|
+
case ["csi", csi]:
|
|
659
|
+
if csi.endswith("m"):
|
|
660
|
+
if (sgr_style := self._parse_sgr(csi[1:-1])) is None:
|
|
661
|
+
self.style = NULL_STYLE
|
|
662
|
+
else:
|
|
663
|
+
self.style += sgr_style
|
|
664
|
+
# Special case to use widget background rather
|
|
665
|
+
# than theme background
|
|
666
|
+
if (
|
|
667
|
+
sgr_style.background is not None
|
|
668
|
+
and sgr_style.background.ansi == -1
|
|
669
|
+
):
|
|
670
|
+
self.style = (
|
|
671
|
+
Style(foreground=self.style.foreground)
|
|
672
|
+
+ sgr_style.without_color
|
|
673
|
+
)
|
|
674
|
+
yield ANSIStyle(self.style)
|
|
675
|
+
else:
|
|
676
|
+
if (ansi_segment := self._parse_csi(csi)) is not None:
|
|
677
|
+
yield ansi_segment
|
|
678
|
+
|
|
679
|
+
case ["dec", dec]:
|
|
680
|
+
slot, character_set = list(dec)
|
|
681
|
+
yield ANSICharacterSet(DEC(DEC_SLOTS[slot], character_set))
|
|
682
|
+
|
|
683
|
+
case ["dec_invoke", dec_invoke]:
|
|
684
|
+
yield ANSICharacterSet(dec_invoke=self.DEC_INVOKE_MAP[dec_invoke[0]])
|
|
685
|
+
|
|
686
|
+
case ["control", code]:
|
|
687
|
+
if (control := CONTROL_CODES.get(code)) is not None:
|
|
688
|
+
if control == "ri": # control code
|
|
689
|
+
yield ANSICursor(delta_y=-1, auto_scroll=True)
|
|
690
|
+
elif control == "ind":
|
|
691
|
+
yield ANSICursor(delta_y=+1, auto_scroll=True)
|
|
692
|
+
else:
|
|
693
|
+
print("CONTROL", repr(code), repr(control))
|
|
694
|
+
else:
|
|
695
|
+
print("NOT HANDLED", code)
|
|
696
|
+
|
|
697
|
+
case ["content", text]:
|
|
698
|
+
yield ANSIContent(text)
|
|
699
|
+
|
|
700
|
+
case _:
|
|
701
|
+
print("UNKNWON TOKEN", repr(token))
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
class LineFold(NamedTuple):
|
|
705
|
+
"""A line from the terminal, folded for presentation."""
|
|
706
|
+
|
|
707
|
+
line_no: int
|
|
708
|
+
"""The (unfolded) line number."""
|
|
709
|
+
|
|
710
|
+
line_offset: int
|
|
711
|
+
"""The index of the folded line."""
|
|
712
|
+
|
|
713
|
+
offset: int
|
|
714
|
+
"""The offset within the original line."""
|
|
715
|
+
|
|
716
|
+
content: Content
|
|
717
|
+
"""The content."""
|
|
718
|
+
|
|
719
|
+
updates: int = 0
|
|
720
|
+
"""Integer that increments on update."""
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@dataclass
|
|
724
|
+
class LineRecord:
|
|
725
|
+
"""A single line in the terminal."""
|
|
726
|
+
|
|
727
|
+
content: Content
|
|
728
|
+
"""The content."""
|
|
729
|
+
|
|
730
|
+
style: Style = NULL_STYLE
|
|
731
|
+
"""The style for the remaining line."""
|
|
732
|
+
|
|
733
|
+
folds: list[LineFold] = field(default_factory=list)
|
|
734
|
+
"""Line "folds" for wrapped lines."""
|
|
735
|
+
|
|
736
|
+
updates: int = 0
|
|
737
|
+
"""An integer used for caching."""
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@rich.repr.auto
|
|
741
|
+
class ScrollMargin(NamedTuple):
|
|
742
|
+
"""Margins at the top and bottom of a window that won't scroll."""
|
|
743
|
+
|
|
744
|
+
top: int | None = None
|
|
745
|
+
"""Margin at the top (in lines), or `None` for no scroll margin set."""
|
|
746
|
+
bottom: int | None = None
|
|
747
|
+
"""Margin at the bottom (in lines), or `None` for no scroll margin set."""
|
|
748
|
+
|
|
749
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
750
|
+
yield self.top
|
|
751
|
+
yield self.bottom
|
|
752
|
+
|
|
753
|
+
def get_line_range(self, height: int) -> tuple[int, int]:
|
|
754
|
+
"""Get the scrollable line range (inclusive).
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
height: terminal height.
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
A tuple of the (exclusive) top and bottom line numbers that scroll.
|
|
761
|
+
"""
|
|
762
|
+
return (
|
|
763
|
+
self.top or 0,
|
|
764
|
+
height - 1 if self.bottom is None else self.bottom,
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@dataclass
|
|
769
|
+
class Buffer:
|
|
770
|
+
"""A terminal buffer (scrollback or alternate)"""
|
|
771
|
+
|
|
772
|
+
name: str = "buffer"
|
|
773
|
+
"""Name of the buffer (debugging aid)."""
|
|
774
|
+
lines: list[LineRecord] = field(default_factory=list)
|
|
775
|
+
"""unfolded lines."""
|
|
776
|
+
line_to_fold: list[int] = field(default_factory=list)
|
|
777
|
+
"""An index from folded lines on to unfolded lines."""
|
|
778
|
+
folded_lines: list[LineFold] = field(default_factory=list)
|
|
779
|
+
"""Folded lines."""
|
|
780
|
+
scroll_margin: ScrollMargin = ScrollMargin(None, None)
|
|
781
|
+
"""Scroll margins"""
|
|
782
|
+
cursor_line: int = 0
|
|
783
|
+
"""Folded line index."""
|
|
784
|
+
cursor_offset: int = 0
|
|
785
|
+
"""Folded line offset."""
|
|
786
|
+
max_line_width: int = 0
|
|
787
|
+
"""The longest line in the buffer."""
|
|
788
|
+
updates: int = 0
|
|
789
|
+
"""Updates count (used in caching)."""
|
|
790
|
+
_updated_lines: set[int] | None = None
|
|
791
|
+
|
|
792
|
+
@property
|
|
793
|
+
def line_count(self) -> int:
|
|
794
|
+
"""Total number of lines."""
|
|
795
|
+
return len(self.lines)
|
|
796
|
+
|
|
797
|
+
@property
|
|
798
|
+
def height(self) -> int:
|
|
799
|
+
"""Height of the buffer (number of folded lines)."""
|
|
800
|
+
height = len(self.folded_lines)
|
|
801
|
+
return height
|
|
802
|
+
|
|
803
|
+
@property
|
|
804
|
+
def last_line_no(self) -> int:
|
|
805
|
+
"""Index of last lines."""
|
|
806
|
+
return len(self.lines) - 1
|
|
807
|
+
|
|
808
|
+
@property
|
|
809
|
+
def unfolded_line(self) -> int:
|
|
810
|
+
"""THh unfolded line index under the cursor."""
|
|
811
|
+
cursor_folded_line = self.folded_lines[self.cursor_line]
|
|
812
|
+
return cursor_folded_line.line_no
|
|
813
|
+
|
|
814
|
+
@property
|
|
815
|
+
def cursor(self) -> tuple[int, int]:
|
|
816
|
+
"""The cursor offset within the un-folded lines."""
|
|
817
|
+
|
|
818
|
+
if self.cursor_line >= len(self.folded_lines):
|
|
819
|
+
return (len(self.folded_lines), 0)
|
|
820
|
+
cursor_folded_line = self.folded_lines[self.cursor_line]
|
|
821
|
+
cursor_line_offset = cursor_folded_line.line_offset
|
|
822
|
+
line_no = cursor_folded_line.line_no
|
|
823
|
+
line = self.lines[line_no]
|
|
824
|
+
position = 0
|
|
825
|
+
for folded_line_offset, folded_line in enumerate(line.folds):
|
|
826
|
+
if folded_line_offset == cursor_line_offset:
|
|
827
|
+
position += self.cursor_offset
|
|
828
|
+
break
|
|
829
|
+
position += len(folded_line.content)
|
|
830
|
+
|
|
831
|
+
return (line_no, position)
|
|
832
|
+
|
|
833
|
+
@property
|
|
834
|
+
def is_blank(self) -> bool:
|
|
835
|
+
"""Is this buffer blank (spaces in all lines)?"""
|
|
836
|
+
return not any(
|
|
837
|
+
(line.content.plain.strip() or line.content.spans) for line in self.lines
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
def update_cursor(self, line_no: int, cursor_line_offset: int) -> None:
|
|
841
|
+
"""Move the cursor to the given unfolded line and offset.
|
|
842
|
+
|
|
843
|
+
Sets `cursor_line` and `cursor_offset`.
|
|
844
|
+
|
|
845
|
+
Args:
|
|
846
|
+
line_no: Unfolded line number.
|
|
847
|
+
cursor_line_offset: Offset within the line.
|
|
848
|
+
"""
|
|
849
|
+
line = self.lines[line_no]
|
|
850
|
+
fold_line_start = self.line_to_fold[line_no]
|
|
851
|
+
position = 0
|
|
852
|
+
fold_offset = 0
|
|
853
|
+
for fold_offset, fold in enumerate(line.folds):
|
|
854
|
+
line_length = len(fold.content)
|
|
855
|
+
if (
|
|
856
|
+
cursor_line_offset >= position
|
|
857
|
+
and cursor_line_offset < position + line_length
|
|
858
|
+
):
|
|
859
|
+
self.cursor_line = fold_line_start + fold_offset
|
|
860
|
+
self.cursor_offset = cursor_line_offset - position
|
|
861
|
+
break
|
|
862
|
+
position += line_length
|
|
863
|
+
else:
|
|
864
|
+
self.cursor_line = fold_line_start + len(line.folds) - 1
|
|
865
|
+
self.cursor_offset = len(line.folds[-1].content)
|
|
866
|
+
|
|
867
|
+
def update_line(self, line_no: int) -> None:
|
|
868
|
+
"""Record an updated line.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
line_no: Line number to update.
|
|
872
|
+
"""
|
|
873
|
+
if self._updated_lines is not None:
|
|
874
|
+
self._updated_lines.add(line_no)
|
|
875
|
+
|
|
876
|
+
def clear(self, updates: int) -> None:
|
|
877
|
+
"""Clear the buffer to its initial state.
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
updates: the initial updates index.
|
|
881
|
+
|
|
882
|
+
"""
|
|
883
|
+
del self.lines[:]
|
|
884
|
+
del self.line_to_fold[:]
|
|
885
|
+
del self.folded_lines[:]
|
|
886
|
+
self.cursor_line = 0
|
|
887
|
+
self.cursor_offset = 0
|
|
888
|
+
self.max_line_width = 0
|
|
889
|
+
self.updates = updates
|
|
890
|
+
|
|
891
|
+
def remove_last_line(self) -> None:
|
|
892
|
+
if not self.lines:
|
|
893
|
+
return
|
|
894
|
+
last_line_index = len(self.lines) - 1
|
|
895
|
+
del self.lines[-1]
|
|
896
|
+
del self.folded_lines[self.line_to_fold[last_line_index] :]
|
|
897
|
+
del self.line_to_fold[last_line_index]
|
|
898
|
+
self.updates += 1
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
@dataclass
|
|
902
|
+
class DECState:
|
|
903
|
+
"""The (somewhat bonkers) mechanism for switching characters sets pre-unicode."""
|
|
904
|
+
|
|
905
|
+
slots: list[str] = field(default_factory=lambda: ["B", "B", "<", "0"])
|
|
906
|
+
gl_slot: int = 0
|
|
907
|
+
gr_slot: int = 2
|
|
908
|
+
shift: int | None = None
|
|
909
|
+
|
|
910
|
+
@property
|
|
911
|
+
def gl(self) -> str:
|
|
912
|
+
return self.slots[self.gl_slot]
|
|
913
|
+
|
|
914
|
+
@property
|
|
915
|
+
def gr(self) -> str:
|
|
916
|
+
return self.slots[self.gr_slot]
|
|
917
|
+
|
|
918
|
+
def update(self, dec: DEC | None, dec_invoke: DECInvoke | None) -> None:
|
|
919
|
+
if dec is not None:
|
|
920
|
+
self.slots[dec.slot] = dec.character_set
|
|
921
|
+
elif dec_invoke is not None:
|
|
922
|
+
if dec_invoke.shift:
|
|
923
|
+
self.shift = dec_invoke.shift
|
|
924
|
+
else:
|
|
925
|
+
if dec_invoke.gl is not None:
|
|
926
|
+
self.gl_slot = dec_invoke.gl
|
|
927
|
+
elif dec_invoke.gr is not None:
|
|
928
|
+
self.gr_slot = dec_invoke.gr
|
|
929
|
+
|
|
930
|
+
def translate(self, text: str) -> str:
|
|
931
|
+
translate_table: dict[int, str] | None
|
|
932
|
+
first_character: str | None = None
|
|
933
|
+
if self.shift is not None and (
|
|
934
|
+
translate_table := CHARSET_MAP.get(self.slots[self.shift], None)
|
|
935
|
+
):
|
|
936
|
+
first_character = text[0].translate(translate_table)
|
|
937
|
+
self.shift = None
|
|
938
|
+
|
|
939
|
+
if translate_table := CHARSET_MAP.get(self.gl, None):
|
|
940
|
+
text = text.translate(translate_table)
|
|
941
|
+
if first_character is None:
|
|
942
|
+
return text
|
|
943
|
+
return f"{first_character}{text}"
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@dataclass
|
|
947
|
+
class MouseTracking:
|
|
948
|
+
"""The mouse tracking state."""
|
|
949
|
+
|
|
950
|
+
tracking: MOUSE_TRACKING_MODES = "all"
|
|
951
|
+
format: MOUSE_FORMAT = "normal"
|
|
952
|
+
focus_events: bool = False
|
|
953
|
+
alternate_scroll: bool = False
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
@rich.repr.auto
|
|
957
|
+
class TerminalState:
|
|
958
|
+
"""Abstract terminal state."""
|
|
959
|
+
|
|
960
|
+
def __init__(
|
|
961
|
+
self,
|
|
962
|
+
write_stdin: Callable[[str], Awaitable],
|
|
963
|
+
*,
|
|
964
|
+
width: int = 80,
|
|
965
|
+
height: int = 24,
|
|
966
|
+
) -> None:
|
|
967
|
+
"""
|
|
968
|
+
Args:
|
|
969
|
+
width: Initial width.
|
|
970
|
+
height: Initial height.
|
|
971
|
+
"""
|
|
972
|
+
self._write_stdin = write_stdin
|
|
973
|
+
|
|
974
|
+
self._ansi_stream = ANSIStream()
|
|
975
|
+
"""ANSI stream processor."""
|
|
976
|
+
|
|
977
|
+
self.width = width
|
|
978
|
+
"""Width of the terminal."""
|
|
979
|
+
self.height = height
|
|
980
|
+
"""Height of the terminal."""
|
|
981
|
+
self.style = NULL_STYLE
|
|
982
|
+
"""The current style."""
|
|
983
|
+
self.show_cursor = True
|
|
984
|
+
"""Is the cursor visible?"""
|
|
985
|
+
self.alternate_screen = False
|
|
986
|
+
"""Is the terminal in the alternate buffer state?"""
|
|
987
|
+
self.bracketed_paste = False
|
|
988
|
+
"""Is bracketed pase enabled?"""
|
|
989
|
+
self.cursor_blink = False
|
|
990
|
+
"""Should the cursor blink?"""
|
|
991
|
+
self.cursor_keys = False
|
|
992
|
+
"""Is cursor keys application mode enabled?"""
|
|
993
|
+
self.replace_mode = True
|
|
994
|
+
"""Should content replaces characters (`True`) or insert (`False`)?"""
|
|
995
|
+
self.auto_wrap = True
|
|
996
|
+
"""Should content wrap?"""
|
|
997
|
+
self.current_directory: str = ""
|
|
998
|
+
"""Current working directory."""
|
|
999
|
+
self.scrollback_buffer = Buffer("scrollback")
|
|
1000
|
+
"""Scrollbar buffer lines."""
|
|
1001
|
+
self.alternate_buffer = Buffer("alternate")
|
|
1002
|
+
"""Alternate buffer lines."""
|
|
1003
|
+
self.dec_state = DECState()
|
|
1004
|
+
"""The DEC (character set) state."""
|
|
1005
|
+
self.mouse_tracking: MouseTracking | None = None
|
|
1006
|
+
"""The mouse tracking state."""
|
|
1007
|
+
|
|
1008
|
+
self._updates: int = 0
|
|
1009
|
+
"""Incrementing integer used in caching."""
|
|
1010
|
+
|
|
1011
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
1012
|
+
yield "width", self.width
|
|
1013
|
+
yield "height", self.height
|
|
1014
|
+
yield "style", self.style, NULL_STYLE
|
|
1015
|
+
yield "show_cursor", self.show_cursor, True
|
|
1016
|
+
yield "alternate_screen", self.alternate_screen, False
|
|
1017
|
+
yield "bracketed_paste", self.bracketed_paste, False
|
|
1018
|
+
yield "cursor_blink", self.cursor_blink, False
|
|
1019
|
+
yield "replace_mode", self.replace_mode, True
|
|
1020
|
+
yield "auto_wrap", self.auto_wrap, True
|
|
1021
|
+
yield "dec_state", self.dec_state
|
|
1022
|
+
yield "mouse_tracking", self.mouse_tracking, None
|
|
1023
|
+
|
|
1024
|
+
async def write_stdin(self, text: str) -> bool:
|
|
1025
|
+
if self._write_stdin is not None:
|
|
1026
|
+
return await self._write_stdin(text)
|
|
1027
|
+
return False
|
|
1028
|
+
return True
|
|
1029
|
+
|
|
1030
|
+
@property
|
|
1031
|
+
def screen_start_line_no(self) -> int:
|
|
1032
|
+
return self.buffer.line_count - self.height
|
|
1033
|
+
|
|
1034
|
+
@property
|
|
1035
|
+
def screen_end_line_no(self) -> int:
|
|
1036
|
+
return self.buffer.line_count
|
|
1037
|
+
|
|
1038
|
+
@property
|
|
1039
|
+
def updates(self) -> int:
|
|
1040
|
+
"""An integer that advanvces when the state is changed."""
|
|
1041
|
+
return self._updates
|
|
1042
|
+
|
|
1043
|
+
@property
|
|
1044
|
+
def buffer(self) -> Buffer:
|
|
1045
|
+
"""The buffer (scrollack or alternate)"""
|
|
1046
|
+
if self.alternate_screen:
|
|
1047
|
+
return self.alternate_buffer
|
|
1048
|
+
return self.scrollback_buffer
|
|
1049
|
+
|
|
1050
|
+
@property
|
|
1051
|
+
def max_line_width(self) -> int | None:
|
|
1052
|
+
return self.scrollback_buffer.max_line_width
|
|
1053
|
+
|
|
1054
|
+
def advance_updates(self) -> int:
|
|
1055
|
+
"""Advance the `updates` integer and return it.
|
|
1056
|
+
|
|
1057
|
+
Returns:
|
|
1058
|
+
int: Updates.
|
|
1059
|
+
"""
|
|
1060
|
+
self._updates += 1
|
|
1061
|
+
return self._updates
|
|
1062
|
+
|
|
1063
|
+
def update_size(self, width: int | None = None, height: int | None = None) -> None:
|
|
1064
|
+
"""Update the dimensions of the terminal.
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
width: New width, or `None` for no change.
|
|
1068
|
+
height: New height, or `None` for no change.
|
|
1069
|
+
"""
|
|
1070
|
+
previous_width = self.width
|
|
1071
|
+
if width is not None:
|
|
1072
|
+
self.width = width
|
|
1073
|
+
if height is not None:
|
|
1074
|
+
self.height = height
|
|
1075
|
+
|
|
1076
|
+
if previous_width != width:
|
|
1077
|
+
self._reflow()
|
|
1078
|
+
|
|
1079
|
+
def key_event_to_stdin(self, event: events.Key) -> str | None:
|
|
1080
|
+
"""Get the stdin string for a key event.
|
|
1081
|
+
|
|
1082
|
+
This will depend on the terminal state.
|
|
1083
|
+
|
|
1084
|
+
Args:
|
|
1085
|
+
event: Key event.
|
|
1086
|
+
|
|
1087
|
+
Returns:
|
|
1088
|
+
A string to be sent to stdin, or `None` if no key was produced.
|
|
1089
|
+
"""
|
|
1090
|
+
if (
|
|
1091
|
+
self.cursor_keys
|
|
1092
|
+
and (sequence := CURSOR_KEYS_APPLICATION.get(event.key)) is not None
|
|
1093
|
+
):
|
|
1094
|
+
return sequence
|
|
1095
|
+
|
|
1096
|
+
if (mapped_key := TERMINAL_KEY_MAP.get(event.key)) is not None:
|
|
1097
|
+
return mapped_key
|
|
1098
|
+
if event.character:
|
|
1099
|
+
return event.character
|
|
1100
|
+
return None
|
|
1101
|
+
|
|
1102
|
+
def key_escape(self) -> str:
|
|
1103
|
+
"""Generate the escape sequence for the escape key.
|
|
1104
|
+
|
|
1105
|
+
Returns:
|
|
1106
|
+
str: ANSI escape sequences.
|
|
1107
|
+
"""
|
|
1108
|
+
return "\x1b"
|
|
1109
|
+
|
|
1110
|
+
def remove_trailing_blank_lines_from_scrollback(self) -> None:
|
|
1111
|
+
"""Remove blank lines at the end of the scrollback buffer.
|
|
1112
|
+
|
|
1113
|
+
A line is considered blank if it is whitespace and has no color or style applied.
|
|
1114
|
+
|
|
1115
|
+
"""
|
|
1116
|
+
buffer = self.scrollback_buffer
|
|
1117
|
+
while buffer.lines:
|
|
1118
|
+
last_line_content = buffer.lines[-1].content
|
|
1119
|
+
if last_line_content.spans or last_line_content.plain.rstrip():
|
|
1120
|
+
break
|
|
1121
|
+
buffer.remove_last_line()
|
|
1122
|
+
|
|
1123
|
+
def _reflow(self) -> None:
|
|
1124
|
+
buffer = self.buffer
|
|
1125
|
+
if not buffer.lines:
|
|
1126
|
+
return
|
|
1127
|
+
|
|
1128
|
+
buffer._updated_lines = None
|
|
1129
|
+
# Unfolded cursor position
|
|
1130
|
+
cursor_line, cursor_offset = buffer.cursor
|
|
1131
|
+
|
|
1132
|
+
buffer.folded_lines.clear()
|
|
1133
|
+
buffer.line_to_fold.clear()
|
|
1134
|
+
width = self.width
|
|
1135
|
+
|
|
1136
|
+
for line_no, line_record in enumerate(buffer.lines):
|
|
1137
|
+
line_expanded_tabs = line_record.content.expand_tabs(8)
|
|
1138
|
+
line_record.folds[:] = self._fold_line(line_no, line_expanded_tabs, width)
|
|
1139
|
+
line_record.updates = self.advance_updates()
|
|
1140
|
+
buffer.line_to_fold.append(len(buffer.folded_lines))
|
|
1141
|
+
buffer.folded_lines.extend(line_record.folds)
|
|
1142
|
+
|
|
1143
|
+
# After reflow, we need to work out where the cursor is within the folded lines
|
|
1144
|
+
# cursor_line = min(cursor_line, len(buffer.lines) - 1)
|
|
1145
|
+
if cursor_line >= len(buffer.lines):
|
|
1146
|
+
buffer.cursor_line = len(buffer.lines)
|
|
1147
|
+
buffer.cursor_offset = 0
|
|
1148
|
+
else:
|
|
1149
|
+
line = buffer.lines[cursor_line]
|
|
1150
|
+
fold_cursor_line = buffer.line_to_fold[cursor_line]
|
|
1151
|
+
|
|
1152
|
+
fold_cursor_offset = 0
|
|
1153
|
+
for fold in reversed(line.folds):
|
|
1154
|
+
if cursor_offset >= fold.offset:
|
|
1155
|
+
fold_cursor_line += fold.line_offset
|
|
1156
|
+
fold_cursor_offset = cursor_offset - fold.offset
|
|
1157
|
+
break
|
|
1158
|
+
|
|
1159
|
+
buffer.cursor_line = fold_cursor_line
|
|
1160
|
+
buffer.cursor_offset = fold_cursor_offset
|
|
1161
|
+
|
|
1162
|
+
async def write(
|
|
1163
|
+
self, text: str, *, hide_output: bool = False
|
|
1164
|
+
) -> tuple[set[int] | None, set[int] | None]:
|
|
1165
|
+
"""Write to the terminal.
|
|
1166
|
+
|
|
1167
|
+
Args:
|
|
1168
|
+
text: Text to write.
|
|
1169
|
+
hide_output: Hide visible output from buffers.
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
A pair of deltas or `None for full refresh, for scrollback and alternate screen.
|
|
1173
|
+
"""
|
|
1174
|
+
alternate_buffer = self.alternate_buffer
|
|
1175
|
+
scrollback_buffer = self.scrollback_buffer
|
|
1176
|
+
|
|
1177
|
+
# Reset updated lines delta
|
|
1178
|
+
alternate_buffer._updated_lines = set()
|
|
1179
|
+
scrollback_buffer._updated_lines = set()
|
|
1180
|
+
# Write sequences and update
|
|
1181
|
+
if hide_output:
|
|
1182
|
+
for ansi_command in self._ansi_stream.feed(text):
|
|
1183
|
+
if not isinstance(ansi_command, (ANSIContent, ANSICursor)):
|
|
1184
|
+
await self._handle_ansi_command(ansi_command)
|
|
1185
|
+
else:
|
|
1186
|
+
for ansi_command in self._ansi_stream.feed(text):
|
|
1187
|
+
await self._handle_ansi_command(ansi_command)
|
|
1188
|
+
|
|
1189
|
+
# Get deltas
|
|
1190
|
+
scrollback_updates = (
|
|
1191
|
+
None
|
|
1192
|
+
if scrollback_buffer._updated_lines is None
|
|
1193
|
+
else scrollback_buffer._updated_lines.copy()
|
|
1194
|
+
)
|
|
1195
|
+
alternate_updates = (
|
|
1196
|
+
None
|
|
1197
|
+
if alternate_buffer._updated_lines is None
|
|
1198
|
+
else alternate_buffer._updated_lines.copy()
|
|
1199
|
+
)
|
|
1200
|
+
# Reset deltas
|
|
1201
|
+
self.alternate_buffer._updated_lines = set()
|
|
1202
|
+
self.scrollback_buffer._updated_lines = set()
|
|
1203
|
+
# Return deltas accumulated during write
|
|
1204
|
+
return (scrollback_updates, alternate_updates)
|
|
1205
|
+
|
|
1206
|
+
def get_cursor_line_offset(self, buffer: Buffer) -> int:
|
|
1207
|
+
"""The cursor offset within the un-folded lines."""
|
|
1208
|
+
cursor_folded_line = buffer.folded_lines[buffer.cursor_line]
|
|
1209
|
+
cursor_line_offset = cursor_folded_line.line_offset
|
|
1210
|
+
line_no = cursor_folded_line.line_no
|
|
1211
|
+
line = buffer.lines[line_no]
|
|
1212
|
+
position = 0
|
|
1213
|
+
for folded_line_offset, folded_line in enumerate(line.folds):
|
|
1214
|
+
if folded_line_offset == cursor_line_offset:
|
|
1215
|
+
position += buffer.cursor_offset
|
|
1216
|
+
break
|
|
1217
|
+
position += len(folded_line.content)
|
|
1218
|
+
return position
|
|
1219
|
+
|
|
1220
|
+
def clear_buffer(self, clear: ClearType) -> None:
|
|
1221
|
+
buffer = self.buffer
|
|
1222
|
+
if clear == "screen":
|
|
1223
|
+
buffer.clear(self.advance_updates())
|
|
1224
|
+
# for _ in range(self.height):
|
|
1225
|
+
# self.add_line(buffer, EMPTY_CONTENT)
|
|
1226
|
+
elif clear == "cursor_to_end":
|
|
1227
|
+
buffer._updated_lines = None
|
|
1228
|
+
folded_cursor_line = buffer.cursor_line
|
|
1229
|
+
cursor_line, cursor_line_offset = buffer.cursor
|
|
1230
|
+
while buffer.cursor_line >= len(buffer.folded_lines):
|
|
1231
|
+
self.add_line(buffer, EMPTY_LINE)
|
|
1232
|
+
line = buffer.lines[cursor_line]
|
|
1233
|
+
del buffer.lines[cursor_line + 1 :]
|
|
1234
|
+
del buffer.line_to_fold[cursor_line + 1 :]
|
|
1235
|
+
del buffer.folded_lines[folded_cursor_line + 1 :]
|
|
1236
|
+
self.update_line(buffer, cursor_line, line.content[:cursor_line_offset])
|
|
1237
|
+
else:
|
|
1238
|
+
# print(f"TODO: clear_buffer({clear!r})")
|
|
1239
|
+
buffer.clear(self.advance_updates())
|
|
1240
|
+
|
|
1241
|
+
def scroll_buffer(self, direction: int, lines: int) -> None:
|
|
1242
|
+
"""Scroll the buffer.
|
|
1243
|
+
|
|
1244
|
+
Args:
|
|
1245
|
+
direction: +1 for down, -1 for up.
|
|
1246
|
+
lines: Number of lines.
|
|
1247
|
+
"""
|
|
1248
|
+
buffer = self.buffer
|
|
1249
|
+
margin_top, margin_bottom = buffer.scroll_margin.get_line_range(self.height)
|
|
1250
|
+
|
|
1251
|
+
if direction == -1:
|
|
1252
|
+
# up (first in test)
|
|
1253
|
+
for line_no in range(margin_top, margin_bottom + 1):
|
|
1254
|
+
copy_line_no = line_no + lines
|
|
1255
|
+
copy_content = EMPTY_CONTENT
|
|
1256
|
+
copy_style = NULL_STYLE
|
|
1257
|
+
if copy_line_no <= margin_bottom:
|
|
1258
|
+
try:
|
|
1259
|
+
copy_line = buffer.lines[copy_line_no]
|
|
1260
|
+
except IndexError:
|
|
1261
|
+
pass
|
|
1262
|
+
else:
|
|
1263
|
+
copy_content = copy_line.content
|
|
1264
|
+
copy_style = copy_line.style
|
|
1265
|
+
|
|
1266
|
+
self.update_line(buffer, line_no, copy_content, copy_style)
|
|
1267
|
+
else:
|
|
1268
|
+
# down
|
|
1269
|
+
for line_no in reversed(range(margin_top, margin_bottom + 1)):
|
|
1270
|
+
copy_line_no = line_no - lines
|
|
1271
|
+
copy_content = EMPTY_CONTENT
|
|
1272
|
+
copy_style = NULL_STYLE
|
|
1273
|
+
if copy_line_no >= margin_top:
|
|
1274
|
+
try:
|
|
1275
|
+
copy_line = buffer.lines[copy_line_no]
|
|
1276
|
+
except IndexError:
|
|
1277
|
+
pass
|
|
1278
|
+
else:
|
|
1279
|
+
copy_content = copy_line.content
|
|
1280
|
+
copy_style = copy_line.style
|
|
1281
|
+
self.update_line(buffer, line_no, copy_content, copy_style)
|
|
1282
|
+
|
|
1283
|
+
@classmethod
|
|
1284
|
+
def _expand_content(cls, content: Content, offset: int, style: Style) -> Content:
|
|
1285
|
+
"""Expand content to be at least as long as a given offset.
|
|
1286
|
+
|
|
1287
|
+
Args:
|
|
1288
|
+
content: Content to expand.
|
|
1289
|
+
offset: Offset within the content.
|
|
1290
|
+
style: Style of padding.
|
|
1291
|
+
|
|
1292
|
+
Returns:
|
|
1293
|
+
New Content.
|
|
1294
|
+
"""
|
|
1295
|
+
if offset > len(content):
|
|
1296
|
+
content += Content.blank(offset - len(content), style)
|
|
1297
|
+
return content
|
|
1298
|
+
|
|
1299
|
+
async def _handle_ansi_command(self, ansi_command: ANSICommand) -> None:
|
|
1300
|
+
if isinstance(ansi_command, ANSINewLine):
|
|
1301
|
+
if self.alternate_screen:
|
|
1302
|
+
# New line behaves differently in alternate screen
|
|
1303
|
+
ansi_command = ANSICursor(delta_y=+1, auto_scroll=True)
|
|
1304
|
+
else:
|
|
1305
|
+
ansi_command = ANSICursor(delta_y=+1, absolute_x=0)
|
|
1306
|
+
|
|
1307
|
+
match ansi_command:
|
|
1308
|
+
case ANSIStyle(style):
|
|
1309
|
+
self.style = style
|
|
1310
|
+
|
|
1311
|
+
case ANSIContent(text):
|
|
1312
|
+
buffer = self.buffer
|
|
1313
|
+
folded_lines = buffer.folded_lines
|
|
1314
|
+
while buffer.cursor_line >= len(folded_lines):
|
|
1315
|
+
self.add_line(buffer, EMPTY_LINE)
|
|
1316
|
+
folded_line = folded_lines[buffer.cursor_line]
|
|
1317
|
+
previous_content = folded_line.content
|
|
1318
|
+
line_no = folded_line.line_no
|
|
1319
|
+
line = buffer.lines[line_no]
|
|
1320
|
+
|
|
1321
|
+
cursor_line_offset = self.get_cursor_line_offset(buffer)
|
|
1322
|
+
line_content = line.content
|
|
1323
|
+
if cursor_line_offset > len(line_content):
|
|
1324
|
+
line_content = self._expand_content(
|
|
1325
|
+
line_content, cursor_line_offset, line.style
|
|
1326
|
+
)
|
|
1327
|
+
content = Content.styled(
|
|
1328
|
+
self.dec_state.translate(text),
|
|
1329
|
+
self.style,
|
|
1330
|
+
strip_control_codes=False,
|
|
1331
|
+
)
|
|
1332
|
+
if self.replace_mode:
|
|
1333
|
+
updated_line = Content.assemble(
|
|
1334
|
+
line_content[:cursor_line_offset],
|
|
1335
|
+
content,
|
|
1336
|
+
line_content[cursor_line_offset + len(content) :],
|
|
1337
|
+
strip_control_codes=False,
|
|
1338
|
+
)
|
|
1339
|
+
else:
|
|
1340
|
+
updated_line = Content.assemble(
|
|
1341
|
+
line_content[:cursor_line_offset],
|
|
1342
|
+
content,
|
|
1343
|
+
line_content[cursor_line_offset:],
|
|
1344
|
+
strip_control_codes=False,
|
|
1345
|
+
)
|
|
1346
|
+
self.update_line(buffer, line_no, updated_line)
|
|
1347
|
+
buffer.update_cursor(line_no, cursor_line_offset + len(content))
|
|
1348
|
+
buffer.updates = self.advance_updates()
|
|
1349
|
+
|
|
1350
|
+
case ANSICursor(
|
|
1351
|
+
delta_x,
|
|
1352
|
+
delta_y,
|
|
1353
|
+
absolute_x,
|
|
1354
|
+
absolute_y,
|
|
1355
|
+
erase,
|
|
1356
|
+
clear_range,
|
|
1357
|
+
_relative,
|
|
1358
|
+
update_background,
|
|
1359
|
+
auto_scroll,
|
|
1360
|
+
):
|
|
1361
|
+
# print(repr(ansi_command))
|
|
1362
|
+
buffer = self.buffer
|
|
1363
|
+
folded_lines = buffer.folded_lines
|
|
1364
|
+
while buffer.cursor_line >= len(folded_lines):
|
|
1365
|
+
self.add_line(buffer, EMPTY_LINE)
|
|
1366
|
+
|
|
1367
|
+
if auto_scroll and delta_y is not None:
|
|
1368
|
+
margins = buffer.scroll_margin.get_line_range(self.height)
|
|
1369
|
+
margin_top, margin_bottom = margins
|
|
1370
|
+
|
|
1371
|
+
if (
|
|
1372
|
+
buffer.cursor_line >= margin_top
|
|
1373
|
+
and buffer.cursor_line <= margin_bottom
|
|
1374
|
+
):
|
|
1375
|
+
start_line_no = self.screen_start_line_no
|
|
1376
|
+
start_line_no = 0
|
|
1377
|
+
scroll_cursor = buffer.cursor_line + delta_y
|
|
1378
|
+
if scroll_cursor > (start_line_no + margin_bottom):
|
|
1379
|
+
self.scroll_buffer(-1, 1)
|
|
1380
|
+
return
|
|
1381
|
+
elif scroll_cursor < (start_line_no + margin_top):
|
|
1382
|
+
self.scroll_buffer(+1, 1)
|
|
1383
|
+
return
|
|
1384
|
+
|
|
1385
|
+
folded_line = folded_lines[buffer.cursor_line]
|
|
1386
|
+
previous_content = folded_line.content
|
|
1387
|
+
line = buffer.lines[folded_line.line_no]
|
|
1388
|
+
if update_background:
|
|
1389
|
+
line.style = self.style
|
|
1390
|
+
|
|
1391
|
+
if clear_range is not None:
|
|
1392
|
+
cursor_line_offset = self.get_cursor_line_offset(buffer)
|
|
1393
|
+
|
|
1394
|
+
line_content = line.content
|
|
1395
|
+
if cursor_line_offset > len(line.content):
|
|
1396
|
+
line_content = self._expand_content(
|
|
1397
|
+
line.content, cursor_line_offset, line.style
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
# Start and end replace are *inclusive*
|
|
1401
|
+
clear_start, clear_end = ansi_command.get_clear_offsets(
|
|
1402
|
+
cursor_line_offset, len(line_content)
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
before_clear = line_content[:clear_start]
|
|
1406
|
+
after_clear = line_content[clear_end + 1 :]
|
|
1407
|
+
|
|
1408
|
+
if erase:
|
|
1409
|
+
# Range is remove
|
|
1410
|
+
updated_line = Content.assemble(
|
|
1411
|
+
before_clear,
|
|
1412
|
+
after_clear,
|
|
1413
|
+
strip_control_codes=False,
|
|
1414
|
+
)
|
|
1415
|
+
self.update_line(buffer, folded_line.line_no, updated_line)
|
|
1416
|
+
else:
|
|
1417
|
+
# Range is replaced with spaces
|
|
1418
|
+
blank_width = clear_end - clear_start + 1
|
|
1419
|
+
|
|
1420
|
+
updated_line = Content.assemble(
|
|
1421
|
+
before_clear,
|
|
1422
|
+
Content.blank(blank_width, self.style),
|
|
1423
|
+
after_clear,
|
|
1424
|
+
strip_control_codes=False,
|
|
1425
|
+
)
|
|
1426
|
+
self.update_line(buffer, folded_line.line_no, updated_line)
|
|
1427
|
+
|
|
1428
|
+
if not previous_content.is_same(folded_line.content):
|
|
1429
|
+
buffer.updates = self.advance_updates()
|
|
1430
|
+
|
|
1431
|
+
if delta_x is not None:
|
|
1432
|
+
buffer.cursor_offset = clamp(
|
|
1433
|
+
buffer.cursor_offset + delta_x, 0, self.width - 1
|
|
1434
|
+
)
|
|
1435
|
+
buffer.update_line(buffer.cursor_line)
|
|
1436
|
+
if absolute_x is not None:
|
|
1437
|
+
buffer.cursor_offset = clamp(absolute_x, 0, self.width - 1)
|
|
1438
|
+
buffer.update_line(buffer.cursor_line)
|
|
1439
|
+
|
|
1440
|
+
current_cursor_line = buffer.cursor_line
|
|
1441
|
+
if delta_y is not None:
|
|
1442
|
+
buffer.update_line(buffer.cursor_line)
|
|
1443
|
+
buffer.cursor_line = max(0, buffer.cursor_line + delta_y)
|
|
1444
|
+
buffer.update_line(buffer.cursor_line)
|
|
1445
|
+
if absolute_y is not None:
|
|
1446
|
+
buffer.update_line(buffer.cursor_line)
|
|
1447
|
+
buffer.cursor_line = max(0, absolute_y)
|
|
1448
|
+
buffer.update_line(buffer.cursor_line)
|
|
1449
|
+
|
|
1450
|
+
if current_cursor_line != buffer.cursor_line:
|
|
1451
|
+
# Simplify when the cursor moves away from the current line
|
|
1452
|
+
line.content.simplify() # Reduce segments
|
|
1453
|
+
self._line_updated(buffer, current_cursor_line)
|
|
1454
|
+
self._line_updated(buffer, buffer.cursor_line)
|
|
1455
|
+
|
|
1456
|
+
case ANSIFeatures() as features:
|
|
1457
|
+
if features.show_cursor is not None:
|
|
1458
|
+
self.show_cursor = features.show_cursor
|
|
1459
|
+
if features.alternate_screen is not None:
|
|
1460
|
+
self.alternate_screen = features.alternate_screen
|
|
1461
|
+
if features.bracketed_paste is not None:
|
|
1462
|
+
self.bracketed_paste = features.bracketed_paste
|
|
1463
|
+
if features.cursor_blink is not None:
|
|
1464
|
+
self.cursor_blink = features.cursor_blink
|
|
1465
|
+
if features.cursor_keys is not None:
|
|
1466
|
+
self.cursor_keys = features.cursor_keys
|
|
1467
|
+
if features.auto_wrap is not None:
|
|
1468
|
+
self.auto_wrap = features.auto_wrap
|
|
1469
|
+
self.advance_updates()
|
|
1470
|
+
|
|
1471
|
+
case ANSIClear(clear):
|
|
1472
|
+
self.clear_buffer(clear)
|
|
1473
|
+
|
|
1474
|
+
case ANSIScrollMargin(top, bottom):
|
|
1475
|
+
self.buffer.scroll_margin = ScrollMargin(top, bottom)
|
|
1476
|
+
# Setting the scroll margins moves the cursor to (1, 1)
|
|
1477
|
+
buffer = self.buffer
|
|
1478
|
+
self._line_updated(buffer, buffer.cursor_line)
|
|
1479
|
+
buffer.cursor_line = 0
|
|
1480
|
+
buffer.cursor_offset = 0
|
|
1481
|
+
self._line_updated(buffer, buffer.cursor_line)
|
|
1482
|
+
|
|
1483
|
+
case ANSIScroll(direction, lines):
|
|
1484
|
+
self.scroll_buffer(direction, lines)
|
|
1485
|
+
|
|
1486
|
+
case ANSICharacterSet(dec, dec_invoke):
|
|
1487
|
+
self.dec_state.update(dec, dec_invoke)
|
|
1488
|
+
|
|
1489
|
+
case ANSIWorkingDirectory(path):
|
|
1490
|
+
self.current_directory = path
|
|
1491
|
+
|
|
1492
|
+
case ANSIMouseTracking(tracking, format, focus_events, alternate_scroll):
|
|
1493
|
+
if tracking == "none":
|
|
1494
|
+
self.mouse_tracking = None
|
|
1495
|
+
return
|
|
1496
|
+
if (mouse_tracking := self.mouse_tracking) is None:
|
|
1497
|
+
mouse_tracking = self.mouse_tracking = MouseTracking()
|
|
1498
|
+
if tracking is not None:
|
|
1499
|
+
mouse_tracking.tracking = tracking
|
|
1500
|
+
if format is not None:
|
|
1501
|
+
mouse_tracking.format = format
|
|
1502
|
+
if focus_events is not None:
|
|
1503
|
+
mouse_tracking.focus_events = focus_events
|
|
1504
|
+
if alternate_scroll is not None:
|
|
1505
|
+
mouse_tracking.alternate_scroll = alternate_scroll
|
|
1506
|
+
|
|
1507
|
+
case ANSICursorPositionRequest():
|
|
1508
|
+
row = self.buffer.cursor_line + 1
|
|
1509
|
+
column = self.buffer.cursor_offset + 1
|
|
1510
|
+
await self.write_stdin(f"\x1b[{row};{column}R")
|
|
1511
|
+
|
|
1512
|
+
case _:
|
|
1513
|
+
print("Unhandled", ansi_command)
|
|
1514
|
+
|
|
1515
|
+
def _line_updated(self, buffer: Buffer, line_no: int) -> None:
|
|
1516
|
+
"""Mark a line has having been udpated.
|
|
1517
|
+
|
|
1518
|
+
Args:
|
|
1519
|
+
buffer: Buffer to use.
|
|
1520
|
+
line_no: Line number to mark as updated.
|
|
1521
|
+
"""
|
|
1522
|
+
try:
|
|
1523
|
+
buffer.lines[line_no].updates = self.advance_updates()
|
|
1524
|
+
if buffer._updated_lines is not None:
|
|
1525
|
+
buffer._updated_lines.add(line_no)
|
|
1526
|
+
except IndexError:
|
|
1527
|
+
pass
|
|
1528
|
+
|
|
1529
|
+
def _fold_line(self, line_no: int, line: Content, width: int) -> list[LineFold]:
|
|
1530
|
+
updates = self._updates
|
|
1531
|
+
if not self.auto_wrap:
|
|
1532
|
+
return [LineFold(line_no, 0, 0, line, updates)]
|
|
1533
|
+
if not width:
|
|
1534
|
+
return [LineFold(0, 0, 0, line, updates)]
|
|
1535
|
+
line_length = line.cell_length
|
|
1536
|
+
if line_length <= width:
|
|
1537
|
+
return [LineFold(line_no, 0, 0, line, updates)]
|
|
1538
|
+
|
|
1539
|
+
folded_lines = line.fold(width)
|
|
1540
|
+
offsets = [0, *accumulate(len(line) for line in folded_lines)][:-1]
|
|
1541
|
+
folds = [
|
|
1542
|
+
LineFold(line_no, line_offset, offset, folded_line, updates)
|
|
1543
|
+
for line_offset, (offset, folded_line) in enumerate(
|
|
1544
|
+
zip(offsets, folded_lines)
|
|
1545
|
+
)
|
|
1546
|
+
]
|
|
1547
|
+
assert len(folds)
|
|
1548
|
+
return folds
|
|
1549
|
+
|
|
1550
|
+
def add_line(
|
|
1551
|
+
self, buffer: Buffer, content: Content, style: Style = NULL_STYLE
|
|
1552
|
+
) -> None:
|
|
1553
|
+
updates = self.advance_updates()
|
|
1554
|
+
line_no = buffer.line_count
|
|
1555
|
+
width = self.width
|
|
1556
|
+
line_record = LineRecord(
|
|
1557
|
+
content,
|
|
1558
|
+
style,
|
|
1559
|
+
self._fold_line(line_no, content, width),
|
|
1560
|
+
updates,
|
|
1561
|
+
)
|
|
1562
|
+
buffer.lines.append(line_record)
|
|
1563
|
+
folds = line_record.folds
|
|
1564
|
+
buffer.line_to_fold.append(len(buffer.folded_lines))
|
|
1565
|
+
fold_count = len(buffer.folded_lines)
|
|
1566
|
+
if buffer._updated_lines is not None:
|
|
1567
|
+
buffer._updated_lines.update(range(fold_count, fold_count + len(folds)))
|
|
1568
|
+
buffer.folded_lines.extend(folds)
|
|
1569
|
+
buffer.updates = updates
|
|
1570
|
+
|
|
1571
|
+
def update_line(
|
|
1572
|
+
self, buffer: Buffer, line_index: int, line: Content, style: Style | None = None
|
|
1573
|
+
) -> None:
|
|
1574
|
+
"""Update a line (potentially refolding and moving subsequencte lines down).
|
|
1575
|
+
|
|
1576
|
+
Args:
|
|
1577
|
+
buffer: Buffer.
|
|
1578
|
+
line_index: Line index (unfolded).
|
|
1579
|
+
line: New line content.
|
|
1580
|
+
style: New background style, or `None` not to update.
|
|
1581
|
+
"""
|
|
1582
|
+
while line_index >= len(buffer.lines):
|
|
1583
|
+
self.add_line(buffer, EMPTY_LINE)
|
|
1584
|
+
|
|
1585
|
+
line_expanded_tabs = line.expand_tabs(8)
|
|
1586
|
+
buffer.max_line_width = max(
|
|
1587
|
+
line_expanded_tabs.cell_length, buffer.max_line_width
|
|
1588
|
+
)
|
|
1589
|
+
line_record = buffer.lines[line_index]
|
|
1590
|
+
line_record.content = line
|
|
1591
|
+
if style is not None:
|
|
1592
|
+
line_record.style = style
|
|
1593
|
+
line_record.folds[:] = self._fold_line(
|
|
1594
|
+
line_index, line_expanded_tabs, self.width
|
|
1595
|
+
)
|
|
1596
|
+
line_record.updates = self.advance_updates()
|
|
1597
|
+
|
|
1598
|
+
if buffer._updated_lines is not None:
|
|
1599
|
+
fold_start = buffer.line_to_fold[line_index]
|
|
1600
|
+
buffer._updated_lines.update(
|
|
1601
|
+
range(fold_start, fold_start + len(line_record.folds))
|
|
1602
|
+
)
|
|
1603
|
+
|
|
1604
|
+
fold_line = buffer.line_to_fold[line_index]
|
|
1605
|
+
del buffer.line_to_fold[line_index:]
|
|
1606
|
+
del buffer.folded_lines[fold_line:]
|
|
1607
|
+
|
|
1608
|
+
for line_no in range(line_index, buffer.line_count):
|
|
1609
|
+
line_record = buffer.lines[line_no]
|
|
1610
|
+
buffer.line_to_fold.append(len(buffer.folded_lines))
|
|
1611
|
+
for fold in line_record.folds:
|
|
1612
|
+
buffer.folded_lines.append(fold)
|