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
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import difflib
|
|
6
|
+
from itertools import starmap
|
|
7
|
+
from typing import Iterable, Literal
|
|
8
|
+
|
|
9
|
+
from rich.segment import Segment
|
|
10
|
+
from rich.style import Style as RichStyle
|
|
11
|
+
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.content import Content, Span
|
|
14
|
+
from textual.geometry import Size
|
|
15
|
+
from textual import highlight
|
|
16
|
+
from textual import events
|
|
17
|
+
|
|
18
|
+
from textual.css.styles import RulesMap
|
|
19
|
+
from textual.selection import Selection
|
|
20
|
+
from textual.strip import Strip
|
|
21
|
+
from textual.style import Style
|
|
22
|
+
from textual.reactive import reactive, var
|
|
23
|
+
from textual.visual import Visual, RenderOptions
|
|
24
|
+
from textual.widget import Widget
|
|
25
|
+
from textual.widgets import Static
|
|
26
|
+
from textual import containers
|
|
27
|
+
|
|
28
|
+
type Annotation = Literal["+", "-", "/", " "]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DiffScrollContainer(containers.HorizontalGroup):
|
|
32
|
+
scroll_link: var[Widget | None] = var(None)
|
|
33
|
+
DEFAULT_CSS = """
|
|
34
|
+
DiffScrollContainer {
|
|
35
|
+
overflow: scroll hidden;
|
|
36
|
+
scrollbar-size: 0 0;
|
|
37
|
+
height: auto;
|
|
38
|
+
}
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def watch_scroll_x(self, old_value: float, new_value: float) -> None:
|
|
42
|
+
super().watch_scroll_x(old_value, new_value)
|
|
43
|
+
if self.scroll_link:
|
|
44
|
+
self.scroll_link.scroll_x = new_value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LineContent(Visual):
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
code_lines: list[Content | None],
|
|
51
|
+
line_styles: list[str],
|
|
52
|
+
width: int | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.code_lines = code_lines
|
|
55
|
+
self.line_styles = line_styles
|
|
56
|
+
self._width = width
|
|
57
|
+
|
|
58
|
+
def render_strips(
|
|
59
|
+
self, width: int, height: int | None, style: Style, options: RenderOptions
|
|
60
|
+
) -> list[Strip]:
|
|
61
|
+
strips: list[Strip] = []
|
|
62
|
+
y = 0
|
|
63
|
+
selection = options.selection
|
|
64
|
+
selection_style = options.selection_style or Style.null()
|
|
65
|
+
for y, (line, color) in enumerate(zip(self.code_lines, self.line_styles)):
|
|
66
|
+
if line is None:
|
|
67
|
+
line = Content.styled("╲" * width, "$foreground 15%")
|
|
68
|
+
else:
|
|
69
|
+
if selection is not None:
|
|
70
|
+
if span := selection.get_span(y):
|
|
71
|
+
start, end = span
|
|
72
|
+
if end == -1:
|
|
73
|
+
end = len(line)
|
|
74
|
+
line = line.stylize(selection_style, start, end)
|
|
75
|
+
if line.cell_length < width:
|
|
76
|
+
line = line.pad_right(width - line.cell_length)
|
|
77
|
+
|
|
78
|
+
line = line.stylize_before(color).stylize_before(style)
|
|
79
|
+
x = 0
|
|
80
|
+
meta = {"offset": (x, y)}
|
|
81
|
+
segments = []
|
|
82
|
+
for text, rich_style, _ in line.render_segments():
|
|
83
|
+
if rich_style is not None:
|
|
84
|
+
meta["offset"] = (x, y)
|
|
85
|
+
segments.append(
|
|
86
|
+
Segment(text, rich_style + RichStyle.from_meta(meta))
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
segments.append(Segment(text, rich_style))
|
|
90
|
+
x += len(text)
|
|
91
|
+
|
|
92
|
+
strips.append(Strip(segments, line.cell_length))
|
|
93
|
+
return strips
|
|
94
|
+
|
|
95
|
+
def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
|
|
96
|
+
if self._width is not None:
|
|
97
|
+
return self._width
|
|
98
|
+
return max(line.cell_length for line in self.code_lines if line is not None)
|
|
99
|
+
|
|
100
|
+
def get_minimal_width(self, rules: RulesMap) -> int:
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
def get_height(self, rules: RulesMap, width: int) -> int:
|
|
104
|
+
return len(self.line_styles)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class LineAnnotations(Widget):
|
|
108
|
+
"""A vertical strip next to the code, containing line numbers or symbols."""
|
|
109
|
+
|
|
110
|
+
DEFAULT_CSS = """
|
|
111
|
+
LineAnnotations {
|
|
112
|
+
width: auto;
|
|
113
|
+
height: auto;
|
|
114
|
+
}
|
|
115
|
+
"""
|
|
116
|
+
numbers: reactive[list[Content]] = reactive(list)
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
numbers: Iterable[Content],
|
|
121
|
+
*,
|
|
122
|
+
name: str | None = None,
|
|
123
|
+
id: str | None = None,
|
|
124
|
+
classes: str | None = None,
|
|
125
|
+
disabled: bool = False,
|
|
126
|
+
):
|
|
127
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
128
|
+
self.numbers = list(numbers)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def total_width(self) -> int:
|
|
132
|
+
return self.number_width
|
|
133
|
+
|
|
134
|
+
def get_content_width(self, container: Size, viewport: Size) -> int:
|
|
135
|
+
return self.total_width
|
|
136
|
+
|
|
137
|
+
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
|
138
|
+
return len(self.numbers)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def number_width(self) -> int:
|
|
142
|
+
return max(number.cell_length for number in self.numbers) if self.numbers else 0
|
|
143
|
+
|
|
144
|
+
def render_line(self, y: int) -> Strip:
|
|
145
|
+
width = self.total_width
|
|
146
|
+
visual_style = self.visual_style
|
|
147
|
+
rich_style = visual_style.rich_style
|
|
148
|
+
try:
|
|
149
|
+
number = self.numbers[y]
|
|
150
|
+
except IndexError:
|
|
151
|
+
number = Content.empty()
|
|
152
|
+
|
|
153
|
+
strip = Strip(
|
|
154
|
+
number.render_segments(visual_style), cell_length=number.cell_length
|
|
155
|
+
)
|
|
156
|
+
strip = strip.adjust_cell_length(width, rich_style)
|
|
157
|
+
return strip
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class DiffCode(Static):
|
|
161
|
+
"""Container for the code."""
|
|
162
|
+
|
|
163
|
+
DEFAULT_CSS = """
|
|
164
|
+
DiffCode {
|
|
165
|
+
width: auto;
|
|
166
|
+
height: auto;
|
|
167
|
+
min-width: 1fr;
|
|
168
|
+
}
|
|
169
|
+
"""
|
|
170
|
+
ALLOW_SELECT = True
|
|
171
|
+
|
|
172
|
+
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
|
|
173
|
+
visual = self._render()
|
|
174
|
+
if isinstance(visual, LineContent):
|
|
175
|
+
text = "\n".join(
|
|
176
|
+
"" if line is None else line.plain for line in visual.code_lines
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
return None
|
|
180
|
+
return selection.extract(text), "\n"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def fill_lists[T](list_a: list[T], list_b: list[T], fill_value: T) -> None:
|
|
184
|
+
"""Make two lists the same size by extending the smaller with a fill value.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
list_a: The first list.
|
|
188
|
+
list_b: The second list.
|
|
189
|
+
fill_value: Value used to extend a list.
|
|
190
|
+
|
|
191
|
+
"""
|
|
192
|
+
a_length = len(list_a)
|
|
193
|
+
b_length = len(list_b)
|
|
194
|
+
if a_length != b_length:
|
|
195
|
+
if a_length > b_length:
|
|
196
|
+
list_b.extend([fill_value] * (a_length - b_length))
|
|
197
|
+
elif b_length > a_length:
|
|
198
|
+
list_a.extend([fill_value] * (b_length - a_length))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class DiffView(containers.VerticalGroup):
|
|
202
|
+
"""A formatted diff in unified or split format."""
|
|
203
|
+
|
|
204
|
+
code_before: reactive[str] = reactive("")
|
|
205
|
+
code_after: reactive[str] = reactive("")
|
|
206
|
+
path1: reactive[str] = reactive("")
|
|
207
|
+
path2: reactive[str] = reactive("")
|
|
208
|
+
split: reactive[bool] = reactive(True, recompose=True)
|
|
209
|
+
annotations: var[bool] = var(False, toggle_class="-with-annotations")
|
|
210
|
+
auto_split: var[bool] = var(False)
|
|
211
|
+
|
|
212
|
+
DEFAULT_CSS = """
|
|
213
|
+
DiffView {
|
|
214
|
+
width: 1fr;
|
|
215
|
+
height: auto;
|
|
216
|
+
|
|
217
|
+
.diff-group {
|
|
218
|
+
height: auto;
|
|
219
|
+
background: $foreground 4%;
|
|
220
|
+
margin-bottom: 1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.annotations { width: 1; }
|
|
224
|
+
&.-with-annotations {
|
|
225
|
+
.annotations { width: auto; }
|
|
226
|
+
}
|
|
227
|
+
.title {
|
|
228
|
+
border-bottom: dashed $foreground 20%;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
}
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
NUMBER_STYLES = {
|
|
235
|
+
"+": "$text-success 80% on $success 20%",
|
|
236
|
+
"-": "$text-error 80% on $error 20%",
|
|
237
|
+
" ": "$foreground 30% on $foreground 3%",
|
|
238
|
+
}
|
|
239
|
+
LINE_STYLES = {
|
|
240
|
+
"+": "on $success 10%",
|
|
241
|
+
"-": "on $error 10%",
|
|
242
|
+
" ": "",
|
|
243
|
+
"/": "",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
path1: str,
|
|
249
|
+
path2: str,
|
|
250
|
+
code_before: str,
|
|
251
|
+
code_after: str,
|
|
252
|
+
*,
|
|
253
|
+
name: str | None = None,
|
|
254
|
+
id: str | None = None,
|
|
255
|
+
classes: str | None = None,
|
|
256
|
+
disabled: bool = False,
|
|
257
|
+
):
|
|
258
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
259
|
+
self.set_reactive(DiffView.path1, path1)
|
|
260
|
+
self.set_reactive(DiffView.path2, path2)
|
|
261
|
+
self.set_reactive(DiffView.code_before, code_before.expandtabs())
|
|
262
|
+
self.set_reactive(DiffView.code_after, code_after.expandtabs())
|
|
263
|
+
self._grouped_opcodes: list[list[tuple[str, int, int, int, int]]] | None = None
|
|
264
|
+
self._highlighted_code_lines: tuple[list[Content], list[Content]] | None = None
|
|
265
|
+
|
|
266
|
+
async def prepare(self) -> None:
|
|
267
|
+
"""Do CPU work in a thread.
|
|
268
|
+
|
|
269
|
+
Call this method prior to composing or mounting to ensure lazy calculated
|
|
270
|
+
data structures run in a thread. Otherwise the work will be done in the async
|
|
271
|
+
loop, potentially causing a brief freeze.
|
|
272
|
+
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def prepare() -> None:
|
|
276
|
+
"""Call properties which will lazily update data structures."""
|
|
277
|
+
self.grouped_opcodes
|
|
278
|
+
self.highlighted_code_lines
|
|
279
|
+
|
|
280
|
+
await asyncio.to_thread(prepare)
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def grouped_opcodes(self) -> list[list[tuple[str, int, int, int, int]]]:
|
|
284
|
+
if self._grouped_opcodes is None:
|
|
285
|
+
text_lines_a = self.code_before.splitlines()
|
|
286
|
+
text_lines_b = self.code_after.splitlines()
|
|
287
|
+
sequence_matcher = difflib.SequenceMatcher(
|
|
288
|
+
lambda character: character in " \t",
|
|
289
|
+
text_lines_a,
|
|
290
|
+
text_lines_b,
|
|
291
|
+
autojunk=True,
|
|
292
|
+
)
|
|
293
|
+
self._grouped_opcodes = list(sequence_matcher.get_grouped_opcodes())
|
|
294
|
+
|
|
295
|
+
return self._grouped_opcodes
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def counts(self) -> tuple[int, int]:
|
|
299
|
+
"""Additions and removals."""
|
|
300
|
+
additions = 0
|
|
301
|
+
removals = 0
|
|
302
|
+
for group in self.grouped_opcodes:
|
|
303
|
+
for tag, i1, i2, j1, j2 in group:
|
|
304
|
+
if tag == "delete":
|
|
305
|
+
removals += 1
|
|
306
|
+
elif tag == "replace":
|
|
307
|
+
additions += 1
|
|
308
|
+
removals += 1
|
|
309
|
+
elif tag == "insert":
|
|
310
|
+
additions += 1
|
|
311
|
+
return additions, removals
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def highlighted_code_lines(self) -> tuple[list[Content], list[Content]]:
|
|
315
|
+
"""Get syntax highlighted code for both files, as a list of lines.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
A pair of line lists for `code_before` and `code_after`
|
|
319
|
+
"""
|
|
320
|
+
if self._highlighted_code_lines is None:
|
|
321
|
+
language1 = highlight.guess_language(self.code_before, self.path1)
|
|
322
|
+
language2 = highlight.guess_language(self.code_after, self.path2)
|
|
323
|
+
text_lines_a = self.code_before.splitlines()
|
|
324
|
+
text_lines_b = self.code_after.splitlines()
|
|
325
|
+
|
|
326
|
+
code_a = highlight.highlight(
|
|
327
|
+
"\n".join(text_lines_a), language=language1, path=self.path1
|
|
328
|
+
)
|
|
329
|
+
code_b = highlight.highlight(
|
|
330
|
+
"\n".join(text_lines_b), language=language2, path=self.path2
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
sequence_matcher = difflib.SequenceMatcher(
|
|
334
|
+
lambda character: character in " \t",
|
|
335
|
+
self.code_before,
|
|
336
|
+
self.code_after,
|
|
337
|
+
autojunk=True,
|
|
338
|
+
)
|
|
339
|
+
code_a_spans: list[Span] = []
|
|
340
|
+
code_b_spans: list[Span] = []
|
|
341
|
+
for tag, i1, i2, j1, j2 in sequence_matcher.get_opcodes():
|
|
342
|
+
if tag == "delete" and "\n" not in code_a.plain[i1 : i2 + 1]:
|
|
343
|
+
code_a_spans.append(Span(i1, i2, "on $error 40%"))
|
|
344
|
+
|
|
345
|
+
if tag == "insert" and "\n" not in code_b.plain[j1 : j2 + 1]:
|
|
346
|
+
code_b_spans.append(Span(j1, j2, "on $success 40%"))
|
|
347
|
+
|
|
348
|
+
code_a = code_a.add_spans(code_a_spans)
|
|
349
|
+
code_b = code_b.add_spans(code_b_spans)
|
|
350
|
+
|
|
351
|
+
lines_a = code_a.split("\n")
|
|
352
|
+
lines_b = code_b.split("\n")
|
|
353
|
+
self._highlighted_code_lines = (lines_a, lines_b)
|
|
354
|
+
return self._highlighted_code_lines
|
|
355
|
+
|
|
356
|
+
def get_title(self) -> Content:
|
|
357
|
+
"""Get a title for the diff view.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
A Content instance.
|
|
361
|
+
"""
|
|
362
|
+
additions, removals = self.counts
|
|
363
|
+
title = Content.from_markup(
|
|
364
|
+
"📄 [dim]$path[/dim] ([$text-success][b]+$additions[/b][/], [$text-error][b]-$removals[/b][/])",
|
|
365
|
+
path=self.path2,
|
|
366
|
+
additions=additions,
|
|
367
|
+
removals=removals,
|
|
368
|
+
additions_label="addition" if additions == 1 else "additions",
|
|
369
|
+
removals_label="removals" if removals == 1 else "removals",
|
|
370
|
+
).stylize_before("$text")
|
|
371
|
+
return title
|
|
372
|
+
|
|
373
|
+
def compose(self) -> ComposeResult:
|
|
374
|
+
"""Compose either split or unified view."""
|
|
375
|
+
|
|
376
|
+
yield Static(self.get_title(), classes="title")
|
|
377
|
+
if self.split:
|
|
378
|
+
yield from self.compose_split()
|
|
379
|
+
else:
|
|
380
|
+
yield from self.compose_unified()
|
|
381
|
+
|
|
382
|
+
def _check_auto_split(self, width: int):
|
|
383
|
+
if self.auto_split:
|
|
384
|
+
lines_a, lines_b = self.highlighted_code_lines
|
|
385
|
+
split_width = max([line.cell_length for line in (lines_a + lines_b)]) * 2
|
|
386
|
+
split_width += 4 + 2 * (
|
|
387
|
+
max(
|
|
388
|
+
[
|
|
389
|
+
len(str(len(lines_a))),
|
|
390
|
+
len(str(len(lines_b))),
|
|
391
|
+
]
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
split_width += 3 * 2 if self.annotations else 2
|
|
395
|
+
self.split = width >= split_width
|
|
396
|
+
|
|
397
|
+
async def on_resize(self, event: events.Resize) -> None:
|
|
398
|
+
self._check_auto_split(event.size.width)
|
|
399
|
+
|
|
400
|
+
async def on_mount(self) -> None:
|
|
401
|
+
self._check_auto_split(self.size.width)
|
|
402
|
+
|
|
403
|
+
def compose_unified(self) -> ComposeResult:
|
|
404
|
+
lines_a, lines_b = self.highlighted_code_lines
|
|
405
|
+
|
|
406
|
+
for group in self.grouped_opcodes:
|
|
407
|
+
line_numbers_a: list[int | None] = []
|
|
408
|
+
line_numbers_b: list[int | None] = []
|
|
409
|
+
annotations: list[str] = []
|
|
410
|
+
code_lines: list[Content | None] = []
|
|
411
|
+
for tag, i1, i2, j1, j2 in group:
|
|
412
|
+
if tag == "equal":
|
|
413
|
+
for line_offset, line in enumerate(lines_a[i1:i2], 1):
|
|
414
|
+
annotations.append(" ")
|
|
415
|
+
line_numbers_a.append(i1 + line_offset)
|
|
416
|
+
line_numbers_b.append(j1 + line_offset)
|
|
417
|
+
code_lines.append(line)
|
|
418
|
+
continue
|
|
419
|
+
if tag in {"replace", "delete"}:
|
|
420
|
+
for line_offset, line in enumerate(lines_a[i1:i2], 1):
|
|
421
|
+
annotations.append("-")
|
|
422
|
+
line_numbers_a.append(i1 + line_offset)
|
|
423
|
+
line_numbers_b.append(None)
|
|
424
|
+
code_lines.append(line)
|
|
425
|
+
if tag in {"replace", "insert"}:
|
|
426
|
+
for line_offset, line in enumerate(lines_b[j1:j2], 1):
|
|
427
|
+
annotations.append("+")
|
|
428
|
+
line_numbers_a.append(None)
|
|
429
|
+
line_numbers_b.append(j1 + line_offset)
|
|
430
|
+
code_lines.append(line)
|
|
431
|
+
|
|
432
|
+
NUMBER_STYLES = self.NUMBER_STYLES
|
|
433
|
+
LINE_STYLES = self.LINE_STYLES
|
|
434
|
+
|
|
435
|
+
line_number_width = max(
|
|
436
|
+
len("" if line_no is None else str(line_no))
|
|
437
|
+
for line_no in (line_numbers_a + line_numbers_b)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
with containers.HorizontalGroup(classes="diff-group"):
|
|
441
|
+
yield LineAnnotations(
|
|
442
|
+
[
|
|
443
|
+
(
|
|
444
|
+
Content(f" {' ' * line_number_width} ")
|
|
445
|
+
if line_no is None
|
|
446
|
+
else Content(f" {line_no:>{line_number_width}} ")
|
|
447
|
+
).stylize(NUMBER_STYLES[annotation])
|
|
448
|
+
for line_no, annotation in zip(line_numbers_a, annotations)
|
|
449
|
+
]
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
yield LineAnnotations(
|
|
453
|
+
[
|
|
454
|
+
(
|
|
455
|
+
Content(f" {' ' * line_number_width} ")
|
|
456
|
+
if line_no is None
|
|
457
|
+
else Content(f" {line_no:>{line_number_width}} ")
|
|
458
|
+
).stylize(NUMBER_STYLES[annotation])
|
|
459
|
+
for line_no, annotation in zip(line_numbers_b, annotations)
|
|
460
|
+
]
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
yield LineAnnotations(
|
|
464
|
+
[
|
|
465
|
+
(Content(f" {annotation} "))
|
|
466
|
+
.stylize(LINE_STYLES[annotation])
|
|
467
|
+
.stylize("bold")
|
|
468
|
+
for annotation in annotations
|
|
469
|
+
],
|
|
470
|
+
classes="annotations",
|
|
471
|
+
)
|
|
472
|
+
code_line_styles = [
|
|
473
|
+
LINE_STYLES[annotation] for annotation in annotations
|
|
474
|
+
]
|
|
475
|
+
with DiffScrollContainer():
|
|
476
|
+
yield DiffCode(LineContent(code_lines, code_line_styles))
|
|
477
|
+
|
|
478
|
+
def compose_split(self) -> ComposeResult:
|
|
479
|
+
lines_a, lines_b = self.highlighted_code_lines
|
|
480
|
+
|
|
481
|
+
annotation_hatch = Content.styled("╲" * 3, "$foreground 15%")
|
|
482
|
+
annotation_blank = Content(" " * 3)
|
|
483
|
+
|
|
484
|
+
def make_annotation(
|
|
485
|
+
annotation: Annotation, highlight_annotation: Literal["+", "-"]
|
|
486
|
+
) -> Content:
|
|
487
|
+
"""Format an annotation.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
annotation: Annotation to format.
|
|
491
|
+
highlight_annotation: Annotation to highlight ('+' or '-')
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Content with annotation.
|
|
495
|
+
"""
|
|
496
|
+
if annotation == highlight_annotation:
|
|
497
|
+
return (
|
|
498
|
+
Content(f" {annotation} ")
|
|
499
|
+
.stylize(self.LINE_STYLES[annotation])
|
|
500
|
+
.stylize("bold")
|
|
501
|
+
)
|
|
502
|
+
if annotation == "/":
|
|
503
|
+
return annotation_hatch
|
|
504
|
+
return annotation_blank
|
|
505
|
+
|
|
506
|
+
for group in self.grouped_opcodes:
|
|
507
|
+
line_numbers_a: list[int | None] = []
|
|
508
|
+
line_numbers_b: list[int | None] = []
|
|
509
|
+
annotations_a: list[Annotation] = []
|
|
510
|
+
annotations_b: list[Annotation] = []
|
|
511
|
+
code_lines_a: list[Content | None] = []
|
|
512
|
+
code_lines_b: list[Content | None] = []
|
|
513
|
+
for tag, i1, i2, j1, j2 in group:
|
|
514
|
+
if tag == "equal":
|
|
515
|
+
for line_offset, line in enumerate(lines_a[i1:i2], 1):
|
|
516
|
+
annotations_a.append(" ")
|
|
517
|
+
annotations_b.append(" ")
|
|
518
|
+
line_numbers_a.append(i1 + line_offset)
|
|
519
|
+
line_numbers_b.append(j1 + line_offset)
|
|
520
|
+
code_lines_a.append(line)
|
|
521
|
+
code_lines_b.append(line)
|
|
522
|
+
else:
|
|
523
|
+
if tag in {"replace", "delete"}:
|
|
524
|
+
for line_number, line in enumerate(lines_a[i1:i2], i1 + 1):
|
|
525
|
+
annotations_a.append("-")
|
|
526
|
+
line_numbers_a.append(line_number)
|
|
527
|
+
code_lines_a.append(line)
|
|
528
|
+
if tag in {"replace", "insert"}:
|
|
529
|
+
for line_number, line in enumerate(lines_b[j1:j2], j1 + 1):
|
|
530
|
+
annotations_b.append("+")
|
|
531
|
+
line_numbers_b.append(line_number)
|
|
532
|
+
code_lines_b.append(line)
|
|
533
|
+
fill_lists(code_lines_a, code_lines_b, None)
|
|
534
|
+
fill_lists(annotations_a, annotations_b, "/")
|
|
535
|
+
fill_lists(line_numbers_a, line_numbers_b, None)
|
|
536
|
+
|
|
537
|
+
if line_numbers_a or line_numbers_b:
|
|
538
|
+
line_number_width = max(
|
|
539
|
+
0 if line_no is None else len(str(line_no))
|
|
540
|
+
for line_no in (line_numbers_a + line_numbers_b)
|
|
541
|
+
)
|
|
542
|
+
else:
|
|
543
|
+
line_number_width = 1
|
|
544
|
+
|
|
545
|
+
hatch = Content.styled("╲" * (2 + line_number_width), "$foreground 15%")
|
|
546
|
+
|
|
547
|
+
def format_number(line_no: int | None, annotation: str) -> Content:
|
|
548
|
+
"""Format a line number with an annotation.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
line_no: Line number or `None` if there is no line here.
|
|
552
|
+
annotation: An annotation string ('+', '-', or ' ')
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
Content for use in the `LineAnnotations` widget.
|
|
556
|
+
"""
|
|
557
|
+
return (
|
|
558
|
+
hatch
|
|
559
|
+
if line_no is None
|
|
560
|
+
else Content(f" {line_no:>{line_number_width}} ").stylize(
|
|
561
|
+
self.NUMBER_STYLES[annotation]
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
with containers.HorizontalGroup(classes="diff-group"):
|
|
566
|
+
# Before line numbers
|
|
567
|
+
yield LineAnnotations(
|
|
568
|
+
starmap(format_number, zip(line_numbers_a, annotations_a))
|
|
569
|
+
)
|
|
570
|
+
# Before annotations
|
|
571
|
+
yield LineAnnotations(
|
|
572
|
+
[make_annotation(annotation, "-") for annotation in annotations_a],
|
|
573
|
+
classes="annotations",
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
code_line_styles = [
|
|
577
|
+
self.LINE_STYLES[annotation] for annotation in annotations_a
|
|
578
|
+
]
|
|
579
|
+
line_width = max(
|
|
580
|
+
line.cell_length
|
|
581
|
+
for line in code_lines_a + code_lines_b
|
|
582
|
+
if line is not None
|
|
583
|
+
)
|
|
584
|
+
# Before code
|
|
585
|
+
with DiffScrollContainer() as scroll_container_a:
|
|
586
|
+
yield DiffCode(
|
|
587
|
+
LineContent(code_lines_a, code_line_styles, width=line_width)
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# After line numbers
|
|
591
|
+
yield LineAnnotations(
|
|
592
|
+
starmap(format_number, zip(line_numbers_b, annotations_b))
|
|
593
|
+
)
|
|
594
|
+
# After annotations
|
|
595
|
+
yield LineAnnotations(
|
|
596
|
+
[make_annotation(annotation, "+") for annotation in annotations_b],
|
|
597
|
+
classes="annotations",
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
code_line_styles = [
|
|
601
|
+
self.LINE_STYLES[annotation] for annotation in annotations_b
|
|
602
|
+
]
|
|
603
|
+
# After code
|
|
604
|
+
with DiffScrollContainer() as scroll_container_b:
|
|
605
|
+
yield DiffCode(
|
|
606
|
+
LineContent(code_lines_b, code_line_styles, width=line_width)
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# Link scroll containers, so they scroll together
|
|
610
|
+
scroll_container_a.scroll_link = scroll_container_b
|
|
611
|
+
scroll_container_b.scroll_link = scroll_container_a
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
if __name__ == "__main__":
|
|
615
|
+
SOURCE1 = '''\
|
|
616
|
+
def loop_first(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
|
|
617
|
+
\t"""Iterate and generate a tuple with a flag for first value."""
|
|
618
|
+
\titer_values = iter(values)
|
|
619
|
+
try:
|
|
620
|
+
value = next(iter_values)
|
|
621
|
+
except StopIteration:
|
|
622
|
+
return
|
|
623
|
+
yield True, value
|
|
624
|
+
for value in iter_values:
|
|
625
|
+
yield False, value
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
|
|
629
|
+
"""Iterate and generate a tuple with a flag for first and last value."""
|
|
630
|
+
iter_values = iter(values)
|
|
631
|
+
try:
|
|
632
|
+
previous_value = next(iter_values)
|
|
633
|
+
except StopIteration:
|
|
634
|
+
return
|
|
635
|
+
first = True
|
|
636
|
+
for value in iter_values:
|
|
637
|
+
yield first, False, previous_value
|
|
638
|
+
first = False
|
|
639
|
+
previous_value = value
|
|
640
|
+
yield first, True, previous_value
|
|
641
|
+
|
|
642
|
+
'''
|
|
643
|
+
|
|
644
|
+
SOURCE2 = '''\
|
|
645
|
+
def loop_first(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
|
|
646
|
+
"""Iterate and generate a tuple with a flag for first value.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
values: iterables of values.
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
Iterable of a boolean to indicate first value, and a value from the iterable.
|
|
653
|
+
"""
|
|
654
|
+
iter_values = iter(values)
|
|
655
|
+
try:
|
|
656
|
+
value = next(iter_values)
|
|
657
|
+
except StopIteration:
|
|
658
|
+
return
|
|
659
|
+
yield True, value
|
|
660
|
+
for value in iter_values:
|
|
661
|
+
yield False, value
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def loop_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]:
|
|
665
|
+
"""Iterate and generate a tuple with a flag for last value."""
|
|
666
|
+
iter_values = iter(values)
|
|
667
|
+
try:
|
|
668
|
+
previous_value = next(iter_values)
|
|
669
|
+
except StopIteration:
|
|
670
|
+
return
|
|
671
|
+
for value in iter_values:
|
|
672
|
+
yield False, previous_value
|
|
673
|
+
previous_value = value
|
|
674
|
+
yield True, previous_value
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def loop_first_last(values: Iterable[ValueType]) -> Iterable[tuple[bool, bool, ValueType]]:
|
|
678
|
+
"""Iterate and generate a tuple with a flag for first and last value."""
|
|
679
|
+
iter_values = iter(values)
|
|
680
|
+
try:
|
|
681
|
+
previous_value = next(iter_values) # Get previous value
|
|
682
|
+
except StopIteration:
|
|
683
|
+
return
|
|
684
|
+
first = True
|
|
685
|
+
|
|
686
|
+
'''
|
|
687
|
+
from textual.app import App
|
|
688
|
+
from textual.widgets import Footer
|
|
689
|
+
|
|
690
|
+
class DiffApp(App):
|
|
691
|
+
BINDINGS = [
|
|
692
|
+
("space", "split", "Toggle split"),
|
|
693
|
+
("a", "toggle_annotations", "Toggle annotations"),
|
|
694
|
+
]
|
|
695
|
+
|
|
696
|
+
def compose(self) -> ComposeResult:
|
|
697
|
+
yield DiffView("foo.py", "foo.py", SOURCE1, SOURCE2)
|
|
698
|
+
yield Footer()
|
|
699
|
+
|
|
700
|
+
def action_split(self) -> None:
|
|
701
|
+
self.query_one(DiffView).split = not self.query_one(DiffView).split
|
|
702
|
+
|
|
703
|
+
def action_toggle_annotations(self) -> None:
|
|
704
|
+
self.query_one(DiffView).annotations = not self.query_one(
|
|
705
|
+
DiffView
|
|
706
|
+
).annotations
|
|
707
|
+
|
|
708
|
+
app = DiffApp()
|
|
709
|
+
app.run()
|