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,66 @@
|
|
|
1
|
+
from time import monotonic
|
|
2
|
+
|
|
3
|
+
from textual.widget import Widget
|
|
4
|
+
|
|
5
|
+
from textual.content import Content
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StrikeText(Widget):
|
|
10
|
+
DEFAULT_CSS = """
|
|
11
|
+
StrikeText {
|
|
12
|
+
height: auto;
|
|
13
|
+
}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
strike_time: reactive[float | None] = reactive(None)
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
content: Content,
|
|
21
|
+
name: str | None = None,
|
|
22
|
+
id: str | None = None,
|
|
23
|
+
classes: str | None = None,
|
|
24
|
+
):
|
|
25
|
+
self.content = content
|
|
26
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
27
|
+
|
|
28
|
+
def strike(self) -> None:
|
|
29
|
+
self.strike_time = monotonic()
|
|
30
|
+
self.auto_refresh = 1 / 30
|
|
31
|
+
|
|
32
|
+
def render(self) -> Content:
|
|
33
|
+
content = self.content
|
|
34
|
+
if self.strike_time is not None:
|
|
35
|
+
position = int((monotonic() - self.strike_time) * 70)
|
|
36
|
+
content = content.stylize("strike", 0, position)
|
|
37
|
+
if position > len(content):
|
|
38
|
+
self.auto_refresh = None
|
|
39
|
+
return content
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
from textual.app import App, ComposeResult
|
|
44
|
+
from textual.widgets import Static
|
|
45
|
+
|
|
46
|
+
class StrikeApp(App):
|
|
47
|
+
CSS = """
|
|
48
|
+
Screen {
|
|
49
|
+
overflow: auto;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
BINDINGS = [("space", "strike", "Strike")]
|
|
54
|
+
|
|
55
|
+
def compose(self) -> ComposeResult:
|
|
56
|
+
for n in range(20):
|
|
57
|
+
yield Static("HELLO")
|
|
58
|
+
yield StrikeText(Content("Where there is a Will, there is a way"))
|
|
59
|
+
for n in range(200):
|
|
60
|
+
yield Static("World")
|
|
61
|
+
|
|
62
|
+
def action_strike(self):
|
|
63
|
+
self.query_one(StrikeText).strike()
|
|
64
|
+
|
|
65
|
+
app = StrikeApp()
|
|
66
|
+
app.run()
|
toad/widgets/terminal.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from time import monotonic
|
|
4
|
+
from typing import Any, Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from textual.cache import LRUCache
|
|
7
|
+
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual import events
|
|
10
|
+
from textual.css.query import NoMatches
|
|
11
|
+
from textual.message import Message
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
from textual.selection import Selection
|
|
14
|
+
from textual.style import Style
|
|
15
|
+
from textual.geometry import Region, Size
|
|
16
|
+
from textual.scroll_view import ScrollView
|
|
17
|
+
from textual.strip import Strip
|
|
18
|
+
from textual.timer import Timer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
from toad import ansi
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Time required to double tab escape
|
|
25
|
+
ESCAPE_TAP_DURATION = 400 / 1000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Terminal(ScrollView, can_focus=True):
|
|
29
|
+
CURSOR_STYLE = Style.parse("reverse")
|
|
30
|
+
|
|
31
|
+
hide_cursor = reactive(False)
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Finalized(Message):
|
|
35
|
+
"""Terminal was finalized."""
|
|
36
|
+
|
|
37
|
+
terminal: Terminal
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def control(self) -> Terminal:
|
|
41
|
+
return self.terminal
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class AlternateScreenChanged(Message):
|
|
45
|
+
"""Terminal enabled or disabled alternate screen."""
|
|
46
|
+
|
|
47
|
+
terminal: Terminal
|
|
48
|
+
enabled: bool
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def control(self) -> Terminal:
|
|
52
|
+
return self.terminal
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
name: str | None = None,
|
|
57
|
+
id: str | None = None,
|
|
58
|
+
classes: str | None = None,
|
|
59
|
+
disabled: bool = False,
|
|
60
|
+
minimum_terminal_width: int = 0,
|
|
61
|
+
size: tuple[int, int] | None = None,
|
|
62
|
+
get_terminal_dimensions: Callable[[], tuple[int, int]] | None = None,
|
|
63
|
+
):
|
|
64
|
+
super().__init__(
|
|
65
|
+
name=name,
|
|
66
|
+
id=id,
|
|
67
|
+
classes=classes,
|
|
68
|
+
disabled=disabled,
|
|
69
|
+
)
|
|
70
|
+
self.minimum_terminal_width = minimum_terminal_width
|
|
71
|
+
self._get_terminal_dimensions = get_terminal_dimensions
|
|
72
|
+
|
|
73
|
+
self.state = ansi.TerminalState(self.write_process_stdin)
|
|
74
|
+
|
|
75
|
+
if size is None:
|
|
76
|
+
self._width = minimum_terminal_width or 80
|
|
77
|
+
self._height: int = 24
|
|
78
|
+
else:
|
|
79
|
+
width, height = size
|
|
80
|
+
self._width = width
|
|
81
|
+
self._height = height
|
|
82
|
+
|
|
83
|
+
self.minimum_terminal_width = self._width
|
|
84
|
+
|
|
85
|
+
self.max_window_width = 0
|
|
86
|
+
self._escape_time = monotonic()
|
|
87
|
+
self._escaping = False
|
|
88
|
+
self._escape_reset_timer: Timer | None = None
|
|
89
|
+
self._finalized: bool = False
|
|
90
|
+
self.current_directory: str | None = None
|
|
91
|
+
self._alternate_screen: bool = False
|
|
92
|
+
self._terminal_render_cache: LRUCache[tuple, Strip] = LRUCache(1024)
|
|
93
|
+
self._write_to_stdin: Callable[[str], Awaitable] | None = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def is_finalized(self) -> bool:
|
|
97
|
+
"""Finalized terminals will not accept writes or receive input."""
|
|
98
|
+
return self._finalized
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def width(self) -> int:
|
|
102
|
+
"""Width of the terminal."""
|
|
103
|
+
return self._width
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def height(self) -> int:
|
|
107
|
+
"""Height of the terminal."""
|
|
108
|
+
height = self._height
|
|
109
|
+
return height
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def size(self) -> Size:
|
|
113
|
+
return Size(self.width, self.height)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def alternate_screen(self) -> bool:
|
|
117
|
+
return self._alternate_screen
|
|
118
|
+
|
|
119
|
+
def notify_style_update(self) -> None:
|
|
120
|
+
"""Clear cache when theme chages."""
|
|
121
|
+
self._terminal_render_cache.clear()
|
|
122
|
+
super().notify_style_update()
|
|
123
|
+
|
|
124
|
+
def set_state(self, state: ansi.TerminalState) -> None:
|
|
125
|
+
"""Set the terminal state, if this terminal is to inherit an existing state.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
state: Terminal state object.
|
|
129
|
+
"""
|
|
130
|
+
self.state = state
|
|
131
|
+
|
|
132
|
+
def set_write_to_stdin(self, write_to_stdin: Callable[[str], Awaitable]) -> None:
|
|
133
|
+
"""Set a callable which is invoked with input, to be sent to stdin.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
write_to_stdin: Callable which takes a string.
|
|
137
|
+
"""
|
|
138
|
+
self._write_to_stdin = write_to_stdin
|
|
139
|
+
|
|
140
|
+
def finalize(self) -> None:
|
|
141
|
+
"""FInalize the terminal.
|
|
142
|
+
|
|
143
|
+
The finalized terminal will reject new writes.
|
|
144
|
+
Adds the TCSS class `-finalize`
|
|
145
|
+
"""
|
|
146
|
+
if not self._finalized:
|
|
147
|
+
self._finalized = True
|
|
148
|
+
self.state.show_cursor = False
|
|
149
|
+
self.add_class("-finalized")
|
|
150
|
+
self._terminal_render_cache.clear()
|
|
151
|
+
self.refresh()
|
|
152
|
+
self.blur()
|
|
153
|
+
self.post_message(self.Finalized(self))
|
|
154
|
+
self.state.remove_trailing_blank_lines_from_scrollback()
|
|
155
|
+
|
|
156
|
+
def allow_focus(self) -> bool:
|
|
157
|
+
"""Prohibit focus when the terminal is finalized and couldn't accept input."""
|
|
158
|
+
return not self.is_finalized
|
|
159
|
+
|
|
160
|
+
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
|
|
161
|
+
"""Get the text under the selection.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
selection: Selection information.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Tuple of extracted text and ending (typically "\n" or " "), or `None` if no text could be extracted.
|
|
168
|
+
"""
|
|
169
|
+
text = "\n".join(
|
|
170
|
+
line_record.content.plain for line_record in self.state.buffer.lines
|
|
171
|
+
)
|
|
172
|
+
return selection.extract(text), "\n"
|
|
173
|
+
|
|
174
|
+
def _on_resize(self, event: events.Resize) -> None:
|
|
175
|
+
if self._get_terminal_dimensions is None:
|
|
176
|
+
width, height = self.scrollable_content_region.size
|
|
177
|
+
else:
|
|
178
|
+
width, height = self._get_terminal_dimensions()
|
|
179
|
+
self.update_size(width, height)
|
|
180
|
+
|
|
181
|
+
def update_size(self, width: int, height: int) -> None:
|
|
182
|
+
old_width = self._width
|
|
183
|
+
old_height = self._height
|
|
184
|
+
self._terminal_render_cache.grow(height * 2)
|
|
185
|
+
self._width = width or 80
|
|
186
|
+
self._height = height or 24
|
|
187
|
+
self._width = max(self._width, self.minimum_terminal_width)
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
old_width != self._width
|
|
191
|
+
or old_height != self._height
|
|
192
|
+
and not self.is_finalized
|
|
193
|
+
):
|
|
194
|
+
from toad.widgets.conversation import Conversation
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
conversation = self.query_ancestor(Conversation)
|
|
198
|
+
except NoMatches:
|
|
199
|
+
pass
|
|
200
|
+
else:
|
|
201
|
+
conversation.shell.update_size(self._width, self._height)
|
|
202
|
+
|
|
203
|
+
self.state.update_size(self._width, height)
|
|
204
|
+
self._terminal_render_cache.clear()
|
|
205
|
+
self.refresh()
|
|
206
|
+
|
|
207
|
+
def on_mount(self) -> None:
|
|
208
|
+
self.auto_links = False
|
|
209
|
+
self.anchor()
|
|
210
|
+
if self._get_terminal_dimensions is None:
|
|
211
|
+
width, height = self.scrollable_content_region.size
|
|
212
|
+
else:
|
|
213
|
+
width, height = self._get_terminal_dimensions()
|
|
214
|
+
self.update_size(width, height)
|
|
215
|
+
|
|
216
|
+
async def write(self, text: str, hide_output: bool=False) -> bool:
|
|
217
|
+
"""Write sequences to the terminal.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
text: Text with ANSI escape sequences.
|
|
221
|
+
hide_output: Do not update the buffers with visible text.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
`True` if the state visuals changed, `False` if no visual change.
|
|
225
|
+
"""
|
|
226
|
+
scrollback_delta, alternate_delta = await self.state.write(
|
|
227
|
+
text, hide_output=hide_output
|
|
228
|
+
)
|
|
229
|
+
self._update_from_state(scrollback_delta, alternate_delta)
|
|
230
|
+
scrollback_changed = bool(scrollback_delta is None or scrollback_delta)
|
|
231
|
+
alternate_changed = bool(alternate_delta is None or alternate_delta)
|
|
232
|
+
|
|
233
|
+
if self._alternate_screen != self.state.alternate_screen:
|
|
234
|
+
self.post_message(
|
|
235
|
+
self.AlternateScreenChanged(self, enabled=self.state.alternate_screen)
|
|
236
|
+
)
|
|
237
|
+
self._alternate_screen = self.state.alternate_screen
|
|
238
|
+
return scrollback_changed or alternate_changed
|
|
239
|
+
|
|
240
|
+
def on_click(self, event: events.Click) -> None:
|
|
241
|
+
self.focus()
|
|
242
|
+
event.stop()
|
|
243
|
+
|
|
244
|
+
def _update_from_state(
|
|
245
|
+
self, scrollback_delta: set[int] | None, alternate_delta: set[int] | None
|
|
246
|
+
) -> None:
|
|
247
|
+
if self.state.current_directory:
|
|
248
|
+
self.current_directory = self.state.current_directory
|
|
249
|
+
self.finalize()
|
|
250
|
+
width = self.state.width
|
|
251
|
+
height = self.state.scrollback_buffer.height
|
|
252
|
+
|
|
253
|
+
if self.state.alternate_screen:
|
|
254
|
+
height += self.state.alternate_buffer.height
|
|
255
|
+
self.virtual_size = Size(min(self.state.buffer.max_line_width, width), height)
|
|
256
|
+
if self._anchored and not self._anchor_released:
|
|
257
|
+
self.scroll_y = self.max_scroll_y
|
|
258
|
+
|
|
259
|
+
scroll_y = int(self.scroll_y)
|
|
260
|
+
visible_lines = frozenset(range(scroll_y, scroll_y + height))
|
|
261
|
+
|
|
262
|
+
if scrollback_delta is None and alternate_delta is None:
|
|
263
|
+
self.refresh()
|
|
264
|
+
else:
|
|
265
|
+
window_width = self.region.width
|
|
266
|
+
scrollback_height = self.state.scrollback_buffer.line_count
|
|
267
|
+
if scrollback_delta is None:
|
|
268
|
+
self.refresh(Region(0, 0, window_width, scrollback_height))
|
|
269
|
+
else:
|
|
270
|
+
refresh_lines = [
|
|
271
|
+
Region(0, y - scroll_y, window_width, 1)
|
|
272
|
+
for y in sorted(scrollback_delta & visible_lines)
|
|
273
|
+
]
|
|
274
|
+
if refresh_lines:
|
|
275
|
+
self.refresh(*refresh_lines)
|
|
276
|
+
alternate_height = self.state.alternate_buffer.line_count
|
|
277
|
+
if alternate_delta is None:
|
|
278
|
+
self.refresh(
|
|
279
|
+
Region(
|
|
280
|
+
0,
|
|
281
|
+
scrollback_height - scroll_y,
|
|
282
|
+
window_width,
|
|
283
|
+
scrollback_height + alternate_height,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
alternate_delta = {
|
|
288
|
+
line_no + scrollback_height for line_no in alternate_delta
|
|
289
|
+
}
|
|
290
|
+
refresh_lines = [
|
|
291
|
+
Region(0, y - scroll_y, window_width, 1)
|
|
292
|
+
for y in sorted(alternate_delta & visible_lines)
|
|
293
|
+
]
|
|
294
|
+
if refresh_lines:
|
|
295
|
+
self.refresh(*refresh_lines)
|
|
296
|
+
|
|
297
|
+
def render_line(self, y: int) -> Strip:
|
|
298
|
+
scroll_x, scroll_y = self.scroll_offset
|
|
299
|
+
strip = self._render_line(scroll_x, scroll_y + y, self._width)
|
|
300
|
+
return strip
|
|
301
|
+
|
|
302
|
+
def on_focus(self) -> None:
|
|
303
|
+
self.border_subtitle = "Tap [b]esc[/b] [i]twice[/i] to exit"
|
|
304
|
+
|
|
305
|
+
def on_blur(self) -> None:
|
|
306
|
+
self.border_subtitle = "Click to focus"
|
|
307
|
+
|
|
308
|
+
def _render_line(self, x: int, y: int, width: int) -> Strip:
|
|
309
|
+
selection = self.text_selection
|
|
310
|
+
visual_style = self.visual_style
|
|
311
|
+
rich_style = visual_style.rich_style
|
|
312
|
+
|
|
313
|
+
state = self.state
|
|
314
|
+
buffer = state.scrollback_buffer
|
|
315
|
+
buffer_offset = 0
|
|
316
|
+
# If alternate screen is active place it (virtually) at the end
|
|
317
|
+
if y >= len(buffer.folded_lines) and state.alternate_screen:
|
|
318
|
+
buffer_offset = len(buffer.folded_lines)
|
|
319
|
+
buffer = state.alternate_buffer
|
|
320
|
+
# Get the folded line, which as a one to one relationship with y
|
|
321
|
+
try:
|
|
322
|
+
folded_line_ = buffer.folded_lines[y - buffer_offset]
|
|
323
|
+
line_no, line_offset, offset, line, updates = folded_line_
|
|
324
|
+
except IndexError:
|
|
325
|
+
return Strip.blank(width, rich_style)
|
|
326
|
+
|
|
327
|
+
line_record = buffer.lines[line_no]
|
|
328
|
+
cache_key: tuple | None = (
|
|
329
|
+
self.state.alternate_screen,
|
|
330
|
+
y,
|
|
331
|
+
line_record.updates,
|
|
332
|
+
updates,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Add in cursor
|
|
336
|
+
if (
|
|
337
|
+
not self.hide_cursor
|
|
338
|
+
and state.show_cursor
|
|
339
|
+
and buffer.cursor_line == y - buffer_offset
|
|
340
|
+
):
|
|
341
|
+
if buffer.cursor_offset >= len(line):
|
|
342
|
+
line = line.pad_right(buffer.cursor_offset - len(line) + 1)
|
|
343
|
+
line_cursor_offset = buffer.cursor_offset
|
|
344
|
+
line = line.stylize(
|
|
345
|
+
self.CURSOR_STYLE, line_cursor_offset, line_cursor_offset + 1
|
|
346
|
+
)
|
|
347
|
+
cache_key = None
|
|
348
|
+
|
|
349
|
+
# get cached strip if there is no selection
|
|
350
|
+
if (
|
|
351
|
+
not selection
|
|
352
|
+
and cache_key is not None
|
|
353
|
+
and (strip := self._terminal_render_cache.get(cache_key))
|
|
354
|
+
):
|
|
355
|
+
strip = strip.crop(x, x + width)
|
|
356
|
+
strip = strip.adjust_cell_length(
|
|
357
|
+
width, (visual_style + line_record.style).rich_style
|
|
358
|
+
)
|
|
359
|
+
strip = strip.apply_offsets(x + offset, line_no)
|
|
360
|
+
return strip
|
|
361
|
+
|
|
362
|
+
# Apply selection
|
|
363
|
+
if selection is not None and (select_span := selection.get_span(line_no)):
|
|
364
|
+
unfolded_content = line_record.content.expand_tabs(8)
|
|
365
|
+
start, end = select_span
|
|
366
|
+
if end == -1:
|
|
367
|
+
end = len(unfolded_content)
|
|
368
|
+
selection_style = self.screen.get_visual_style("screen--selection")
|
|
369
|
+
unfolded_content = unfolded_content.stylize(selection_style, start, end)
|
|
370
|
+
try:
|
|
371
|
+
folded_lines = self.state._fold_line(line_no, unfolded_content, width)
|
|
372
|
+
line = folded_lines[line_offset].content
|
|
373
|
+
cache_key = None
|
|
374
|
+
except IndexError:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
strip = Strip(
|
|
379
|
+
line.render_segments(visual_style), cell_length=line.cell_length
|
|
380
|
+
)
|
|
381
|
+
except Exception:
|
|
382
|
+
# TODO: Is this neccesary?
|
|
383
|
+
strip = Strip.blank(line.cell_length)
|
|
384
|
+
|
|
385
|
+
if cache_key is not None:
|
|
386
|
+
self._terminal_render_cache[cache_key] = strip
|
|
387
|
+
|
|
388
|
+
strip = strip.crop(x, x + width)
|
|
389
|
+
strip = strip.adjust_cell_length(
|
|
390
|
+
width, (visual_style + line_record.style).rich_style
|
|
391
|
+
)
|
|
392
|
+
strip = strip.apply_offsets(x + offset, line_no)
|
|
393
|
+
|
|
394
|
+
return strip
|
|
395
|
+
|
|
396
|
+
async def _reset_escaping(self) -> None:
|
|
397
|
+
if self._escaping:
|
|
398
|
+
await self.write_process_stdin(self.state.key_escape())
|
|
399
|
+
self._escaping = False
|
|
400
|
+
|
|
401
|
+
async def on_key(self, event: events.Key):
|
|
402
|
+
event.prevent_default()
|
|
403
|
+
event.stop()
|
|
404
|
+
|
|
405
|
+
if event.key == "escape":
|
|
406
|
+
if self._escaping:
|
|
407
|
+
if monotonic() < self._escape_time + ESCAPE_TAP_DURATION:
|
|
408
|
+
self.blur()
|
|
409
|
+
self._escaping = False
|
|
410
|
+
return
|
|
411
|
+
else:
|
|
412
|
+
await self.write_process_stdin(self.state.key_escape())
|
|
413
|
+
else:
|
|
414
|
+
self._escaping = True
|
|
415
|
+
self._escape_time = monotonic()
|
|
416
|
+
self._escape_reset_timer = self.set_timer(
|
|
417
|
+
ESCAPE_TAP_DURATION, self._reset_escaping
|
|
418
|
+
)
|
|
419
|
+
return
|
|
420
|
+
else:
|
|
421
|
+
await self._reset_escaping()
|
|
422
|
+
if self._escape_reset_timer is not None:
|
|
423
|
+
self._escape_reset_timer.stop()
|
|
424
|
+
|
|
425
|
+
if (stdin := self.state.key_event_to_stdin(event)) is not None:
|
|
426
|
+
await self.write_process_stdin(stdin)
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def allow_select(self) -> bool:
|
|
430
|
+
return self.is_finalized or not self._alternate_screen
|
|
431
|
+
|
|
432
|
+
def _encode_mouse_event_sgr(self, event: events.MouseEvent) -> str:
|
|
433
|
+
x = int(event.x)
|
|
434
|
+
y = int(event.y)
|
|
435
|
+
|
|
436
|
+
if isinstance(event, events.MouseMove):
|
|
437
|
+
button = event.button + 32 if event.button else 35
|
|
438
|
+
else:
|
|
439
|
+
button = event.button - 1
|
|
440
|
+
if button >= 4:
|
|
441
|
+
button = button - 4 + 128
|
|
442
|
+
if event.shift:
|
|
443
|
+
button += 4
|
|
444
|
+
if event.meta:
|
|
445
|
+
button += 8
|
|
446
|
+
if event.ctrl:
|
|
447
|
+
button += 16
|
|
448
|
+
|
|
449
|
+
if isinstance(event, events.MouseDown):
|
|
450
|
+
final_character = "M"
|
|
451
|
+
elif isinstance(event, events.MouseUp):
|
|
452
|
+
button = 0
|
|
453
|
+
final_character = "m"
|
|
454
|
+
else:
|
|
455
|
+
final_character = "M"
|
|
456
|
+
mouse_stdin = f"\x1b[<{button};{x + 1};{y + 1}{final_character}"
|
|
457
|
+
return mouse_stdin
|
|
458
|
+
|
|
459
|
+
@on(events.MouseMove)
|
|
460
|
+
async def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
461
|
+
if self.is_finalized:
|
|
462
|
+
return
|
|
463
|
+
if (mouse_tracking := self.state.mouse_tracking) is None:
|
|
464
|
+
return
|
|
465
|
+
if mouse_tracking.tracking == "all" or (
|
|
466
|
+
event.button and mouse_tracking.tracking == "drag"
|
|
467
|
+
):
|
|
468
|
+
await self._handle_mouse_event(event)
|
|
469
|
+
event.prevent_default()
|
|
470
|
+
event.stop()
|
|
471
|
+
|
|
472
|
+
@on(events.MouseDown)
|
|
473
|
+
@on(events.MouseUp)
|
|
474
|
+
async def on_mouse_button(self, event: events.MouseUp | events.MouseDown) -> None:
|
|
475
|
+
if self.is_finalized:
|
|
476
|
+
return
|
|
477
|
+
if self.state.mouse_tracking is None:
|
|
478
|
+
return
|
|
479
|
+
await self._handle_mouse_event(event)
|
|
480
|
+
event.prevent_default()
|
|
481
|
+
event.stop()
|
|
482
|
+
|
|
483
|
+
async def _handle_mouse_event(self, event: events.MouseEvent) -> None:
|
|
484
|
+
if (mouse_tracking := self.state.mouse_tracking) is None:
|
|
485
|
+
return
|
|
486
|
+
# TODO: Other mouse tracking formats
|
|
487
|
+
match mouse_tracking.format:
|
|
488
|
+
case "sgr":
|
|
489
|
+
await self.write_process_stdin(self._encode_mouse_event_sgr(event))
|
|
490
|
+
|
|
491
|
+
async def on_paste(self, event: events.Paste) -> None:
|
|
492
|
+
for character in event.text:
|
|
493
|
+
await self.write_process_stdin(character)
|
|
494
|
+
|
|
495
|
+
async def write_process_stdin(self, input: str) -> None:
|
|
496
|
+
if self._write_to_stdin is not None:
|
|
497
|
+
await self._write_to_stdin(input)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
if __name__ == "__main__":
|
|
501
|
+
from textual.app import App, ComposeResult
|
|
502
|
+
|
|
503
|
+
TEST = (
|
|
504
|
+
"\033[31mThis is red text\033[0m\n"
|
|
505
|
+
"\033[32mThis is green text\033[0m\n"
|
|
506
|
+
"\033[33mThis is yellow text\033[0m\n"
|
|
507
|
+
"\033[34mThis is blue text\033[0m\n"
|
|
508
|
+
"\033[35mThis is magenta text\033[0m\n"
|
|
509
|
+
"\033[36mThis is cyan text\033[0m\n"
|
|
510
|
+
"\033[1mThis is bold text\033[0m\n"
|
|
511
|
+
"\033[4mThis is underlined text\033[0m\n"
|
|
512
|
+
"\033[1;31mThis is bold red text\033[0m\n"
|
|
513
|
+
"\033[42mThis has a green background\033[0m\n"
|
|
514
|
+
"\033[97;44mWhite text on blue background\033[0m"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
class TApp(App):
|
|
518
|
+
def compose(self) -> ComposeResult:
|
|
519
|
+
yield Terminal()
|
|
520
|
+
|
|
521
|
+
def on_mount(self) -> None:
|
|
522
|
+
terminal = self.query_one(Terminal)
|
|
523
|
+
terminal.write(TEST)
|
|
524
|
+
|
|
525
|
+
app = TApp()
|
|
526
|
+
app.run()
|