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.
Files changed (120) hide show
  1. batrachian_toad-0.5.22.dist-info/METADATA +197 -0
  2. batrachian_toad-0.5.22.dist-info/RECORD +120 -0
  3. batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
  4. batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
  5. batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
  6. toad/__init__.py +46 -0
  7. toad/__main__.py +4 -0
  8. toad/_loop.py +86 -0
  9. toad/about.py +90 -0
  10. toad/acp/agent.py +671 -0
  11. toad/acp/api.py +47 -0
  12. toad/acp/encode_tool_call_id.py +12 -0
  13. toad/acp/messages.py +138 -0
  14. toad/acp/prompt.py +54 -0
  15. toad/acp/protocol.py +426 -0
  16. toad/agent.py +62 -0
  17. toad/agent_schema.py +70 -0
  18. toad/agents.py +45 -0
  19. toad/ansi/__init__.py +1 -0
  20. toad/ansi/_ansi.py +1612 -0
  21. toad/ansi/_ansi_colors.py +264 -0
  22. toad/ansi/_control_codes.py +37 -0
  23. toad/ansi/_keys.py +251 -0
  24. toad/ansi/_sgr_styles.py +64 -0
  25. toad/ansi/_stream_parser.py +418 -0
  26. toad/answer.py +22 -0
  27. toad/app.py +557 -0
  28. toad/atomic.py +37 -0
  29. toad/cli.py +257 -0
  30. toad/code_analyze.py +28 -0
  31. toad/complete.py +34 -0
  32. toad/constants.py +58 -0
  33. toad/conversation_markdown.py +19 -0
  34. toad/danger.py +371 -0
  35. toad/data/agents/ampcode.com.toml +51 -0
  36. toad/data/agents/augmentcode.com.toml +40 -0
  37. toad/data/agents/claude.com.toml +41 -0
  38. toad/data/agents/docker.com.toml +59 -0
  39. toad/data/agents/geminicli.com.toml +28 -0
  40. toad/data/agents/goose.ai.toml +51 -0
  41. toad/data/agents/inference.huggingface.co.toml +33 -0
  42. toad/data/agents/kimi.com.toml +35 -0
  43. toad/data/agents/openai.com.toml +53 -0
  44. toad/data/agents/opencode.ai.toml +61 -0
  45. toad/data/agents/openhands.dev.toml +44 -0
  46. toad/data/agents/stakpak.dev.toml +61 -0
  47. toad/data/agents/vibe.mistral.ai.toml +27 -0
  48. toad/data/agents/vtcode.dev.toml +62 -0
  49. toad/data/images/frog.png +0 -0
  50. toad/data/sounds/turn-over.wav +0 -0
  51. toad/db.py +5 -0
  52. toad/dec.py +332 -0
  53. toad/directory.py +234 -0
  54. toad/directory_watcher.py +96 -0
  55. toad/fuzzy.py +140 -0
  56. toad/gist.py +2 -0
  57. toad/history.py +138 -0
  58. toad/jsonrpc.py +576 -0
  59. toad/menus.py +14 -0
  60. toad/messages.py +74 -0
  61. toad/option_content.py +51 -0
  62. toad/os.py +0 -0
  63. toad/path_complete.py +145 -0
  64. toad/path_filter.py +124 -0
  65. toad/paths.py +71 -0
  66. toad/pill.py +23 -0
  67. toad/prompt/extract.py +19 -0
  68. toad/prompt/resource.py +68 -0
  69. toad/protocol.py +28 -0
  70. toad/screens/action_modal.py +94 -0
  71. toad/screens/agent_modal.py +172 -0
  72. toad/screens/command_edit_modal.py +58 -0
  73. toad/screens/main.py +192 -0
  74. toad/screens/permissions.py +390 -0
  75. toad/screens/permissions.tcss +72 -0
  76. toad/screens/settings.py +254 -0
  77. toad/screens/settings.tcss +101 -0
  78. toad/screens/store.py +476 -0
  79. toad/screens/store.tcss +261 -0
  80. toad/settings.py +354 -0
  81. toad/settings_schema.py +318 -0
  82. toad/shell.py +263 -0
  83. toad/shell_read.py +42 -0
  84. toad/slash_command.py +34 -0
  85. toad/toad.tcss +752 -0
  86. toad/version.py +80 -0
  87. toad/visuals/columns.py +273 -0
  88. toad/widgets/agent_response.py +79 -0
  89. toad/widgets/agent_thought.py +41 -0
  90. toad/widgets/command_pane.py +224 -0
  91. toad/widgets/condensed_path.py +93 -0
  92. toad/widgets/conversation.py +1626 -0
  93. toad/widgets/danger_warning.py +65 -0
  94. toad/widgets/diff_view.py +709 -0
  95. toad/widgets/flash.py +81 -0
  96. toad/widgets/future_text.py +126 -0
  97. toad/widgets/grid_select.py +223 -0
  98. toad/widgets/highlighted_textarea.py +180 -0
  99. toad/widgets/mandelbrot.py +294 -0
  100. toad/widgets/markdown_note.py +13 -0
  101. toad/widgets/menu.py +147 -0
  102. toad/widgets/non_selectable_label.py +5 -0
  103. toad/widgets/note.py +18 -0
  104. toad/widgets/path_search.py +381 -0
  105. toad/widgets/plan.py +180 -0
  106. toad/widgets/project_directory_tree.py +74 -0
  107. toad/widgets/prompt.py +741 -0
  108. toad/widgets/question.py +337 -0
  109. toad/widgets/shell_result.py +35 -0
  110. toad/widgets/shell_terminal.py +18 -0
  111. toad/widgets/side_bar.py +74 -0
  112. toad/widgets/slash_complete.py +211 -0
  113. toad/widgets/strike_text.py +66 -0
  114. toad/widgets/terminal.py +526 -0
  115. toad/widgets/terminal_tool.py +338 -0
  116. toad/widgets/throbber.py +90 -0
  117. toad/widgets/tool_call.py +303 -0
  118. toad/widgets/user_input.py +23 -0
  119. toad/widgets/version.py +5 -0
  120. 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()