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,338 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from asyncio.subprocess import Process
|
|
5
|
+
import codecs
|
|
6
|
+
import fcntl
|
|
7
|
+
import os
|
|
8
|
+
import pty
|
|
9
|
+
import shlex
|
|
10
|
+
from collections import deque
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import struct
|
|
13
|
+
import termios
|
|
14
|
+
from typing import Mapping
|
|
15
|
+
|
|
16
|
+
from textual.content import Content
|
|
17
|
+
from textual.reactive import var
|
|
18
|
+
|
|
19
|
+
from toad.shell_read import shell_read
|
|
20
|
+
from toad.widgets.terminal import Terminal
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Command:
|
|
25
|
+
"""A command and corresponding environment."""
|
|
26
|
+
|
|
27
|
+
command: str
|
|
28
|
+
"""Command to run."""
|
|
29
|
+
args: list[str]
|
|
30
|
+
"""List of arguments."""
|
|
31
|
+
env: Mapping[str, str]
|
|
32
|
+
"""Environment variables."""
|
|
33
|
+
cwd: str
|
|
34
|
+
"""Current working directory."""
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
command_str = shlex.join([self.command, *self.args]).strip("'")
|
|
38
|
+
return command_str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ToolState:
|
|
43
|
+
"""Current state of the terminal."""
|
|
44
|
+
|
|
45
|
+
output: str
|
|
46
|
+
truncated: bool
|
|
47
|
+
return_code: int | None = None
|
|
48
|
+
signal: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TerminalTool(Terminal):
|
|
52
|
+
DEFAULT_CSS = """
|
|
53
|
+
TerminalTool {
|
|
54
|
+
height: auto;
|
|
55
|
+
border: panel $text-primary;
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
_command: var[Command | None] = var(None)
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
command: Command,
|
|
64
|
+
*,
|
|
65
|
+
output_byte_limit: int | None = None,
|
|
66
|
+
name: str | None = None,
|
|
67
|
+
id: str | None = None,
|
|
68
|
+
classes: str | None = None,
|
|
69
|
+
disabled: bool = False,
|
|
70
|
+
minimum_terminal_width: int = -1,
|
|
71
|
+
):
|
|
72
|
+
super().__init__(
|
|
73
|
+
name=name,
|
|
74
|
+
id=id,
|
|
75
|
+
classes=classes,
|
|
76
|
+
disabled=disabled,
|
|
77
|
+
minimum_terminal_width=minimum_terminal_width,
|
|
78
|
+
)
|
|
79
|
+
self._command = command
|
|
80
|
+
self._output_byte_limit = output_byte_limit
|
|
81
|
+
self._command_task: asyncio.Task | None = None
|
|
82
|
+
self._output: deque[bytes] = deque()
|
|
83
|
+
|
|
84
|
+
self._process: Process | None = None
|
|
85
|
+
self._bytes_read = 0
|
|
86
|
+
self._output_bytes_count = 0
|
|
87
|
+
self._shell_fd: int | None = None
|
|
88
|
+
self._return_code: int | None = None
|
|
89
|
+
self._released: bool = False
|
|
90
|
+
self._ready_event = asyncio.Event()
|
|
91
|
+
self._exit_event = asyncio.Event()
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def return_code(self) -> int | None:
|
|
95
|
+
"""The command return code, or `None` if not yet set."""
|
|
96
|
+
return self._return_code
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def released(self) -> bool:
|
|
100
|
+
"""Has the terminal been released?"""
|
|
101
|
+
return self._released
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def tool_state(self) -> ToolState:
|
|
105
|
+
"""Get the current terminal state."""
|
|
106
|
+
output, truncated = self.get_output()
|
|
107
|
+
# TODO: report signal
|
|
108
|
+
return ToolState(
|
|
109
|
+
output=output, truncated=truncated, return_code=self.return_code
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def resize_pty(fd: int, columns: int, rows: int) -> None:
|
|
114
|
+
"""Resize the pseudo terminal.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
fd: File descriptor.
|
|
118
|
+
columns: Columns (width).
|
|
119
|
+
rows: Rows (height).
|
|
120
|
+
"""
|
|
121
|
+
# Pack the dimensions into the format expected by TIOCSWINSZ
|
|
122
|
+
size = struct.pack("HHHH", rows, columns, 0, 0)
|
|
123
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, size)
|
|
124
|
+
|
|
125
|
+
async def wait_for_exit(self) -> tuple[int | None, str | None]:
|
|
126
|
+
"""Wait for the terminal process to exit."""
|
|
127
|
+
if self._process is None or self._command_task is None:
|
|
128
|
+
return None, None
|
|
129
|
+
# await self._task
|
|
130
|
+
await self._exit_event.wait()
|
|
131
|
+
return (self.return_code or 0, None)
|
|
132
|
+
|
|
133
|
+
def kill(self) -> bool:
|
|
134
|
+
"""Kill the terminal process.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Returns `True` if the process was killed, or `False` if there
|
|
138
|
+
was no running process.
|
|
139
|
+
"""
|
|
140
|
+
if self.return_code is not None:
|
|
141
|
+
return False
|
|
142
|
+
if self._process is None:
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
self._process.kill()
|
|
146
|
+
except Exception:
|
|
147
|
+
return False
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
def release(self) -> None:
|
|
151
|
+
"""Release the terminal (may no longer be used from ACP)."""
|
|
152
|
+
self._released = True
|
|
153
|
+
|
|
154
|
+
def watch__command(self, command: Command) -> None:
|
|
155
|
+
self.border_title = str(command)
|
|
156
|
+
|
|
157
|
+
async def start(self, width: int = 0, height: int = 0) -> None:
|
|
158
|
+
assert self._command is not None
|
|
159
|
+
|
|
160
|
+
self.update_size(width, height)
|
|
161
|
+
self._command_task = asyncio.create_task(
|
|
162
|
+
self.run(), name=f"Terminal {self._command}"
|
|
163
|
+
)
|
|
164
|
+
await self._ready_event.wait()
|
|
165
|
+
|
|
166
|
+
async def run(self) -> None:
|
|
167
|
+
try:
|
|
168
|
+
await self._run()
|
|
169
|
+
except Exception:
|
|
170
|
+
from traceback import print_exc
|
|
171
|
+
|
|
172
|
+
print_exc()
|
|
173
|
+
finally:
|
|
174
|
+
self._exit_event.set()
|
|
175
|
+
|
|
176
|
+
async def _run(self) -> None:
|
|
177
|
+
self._command_task = asyncio.current_task()
|
|
178
|
+
|
|
179
|
+
assert self._command is not None
|
|
180
|
+
master, slave = pty.openpty()
|
|
181
|
+
self._shell_fd = master
|
|
182
|
+
|
|
183
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
184
|
+
fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
185
|
+
|
|
186
|
+
command = self._command
|
|
187
|
+
environment = os.environ | command.env
|
|
188
|
+
|
|
189
|
+
if " " in command.command:
|
|
190
|
+
run_command = command.command
|
|
191
|
+
else:
|
|
192
|
+
run_command = f"{command.command} {shlex.join(command.args)}"
|
|
193
|
+
|
|
194
|
+
shell = os.environ.get("SHELL", "sh")
|
|
195
|
+
run_command = shlex.join([shell, "-c", run_command])
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
process = self._process = await asyncio.create_subprocess_shell(
|
|
199
|
+
run_command,
|
|
200
|
+
stdin=slave,
|
|
201
|
+
stdout=slave,
|
|
202
|
+
stderr=slave,
|
|
203
|
+
env=environment,
|
|
204
|
+
cwd=command.cwd,
|
|
205
|
+
)
|
|
206
|
+
except Exception as error:
|
|
207
|
+
self._ready_event.set()
|
|
208
|
+
print(error)
|
|
209
|
+
raise
|
|
210
|
+
|
|
211
|
+
self._ready_event.set()
|
|
212
|
+
|
|
213
|
+
self.resize_pty(
|
|
214
|
+
master,
|
|
215
|
+
self._width or 80,
|
|
216
|
+
self._height or 24,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
os.close(slave)
|
|
220
|
+
BUFFER_SIZE = 64 * 1024 * 2
|
|
221
|
+
reader = asyncio.StreamReader(BUFFER_SIZE)
|
|
222
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
223
|
+
|
|
224
|
+
loop = asyncio.get_event_loop()
|
|
225
|
+
transport, _ = await loop.connect_read_pipe(
|
|
226
|
+
lambda: protocol, os.fdopen(master, "rb", 0)
|
|
227
|
+
)
|
|
228
|
+
# Create write transport
|
|
229
|
+
writer_protocol = asyncio.BaseProtocol()
|
|
230
|
+
write_transport, _ = await loop.connect_write_pipe(
|
|
231
|
+
lambda: writer_protocol,
|
|
232
|
+
os.fdopen(os.dup(master), "wb", 0),
|
|
233
|
+
)
|
|
234
|
+
self.writer = write_transport
|
|
235
|
+
|
|
236
|
+
unicode_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
237
|
+
try:
|
|
238
|
+
while True:
|
|
239
|
+
data = await shell_read(reader, BUFFER_SIZE)
|
|
240
|
+
if process_data := unicode_decoder.decode(data, final=not data):
|
|
241
|
+
self._record_output(data)
|
|
242
|
+
if await self.write(process_data):
|
|
243
|
+
self.display = True
|
|
244
|
+
if not data:
|
|
245
|
+
break
|
|
246
|
+
finally:
|
|
247
|
+
transport.close()
|
|
248
|
+
|
|
249
|
+
self.finalize()
|
|
250
|
+
return_code = self._return_code = await process.wait()
|
|
251
|
+
|
|
252
|
+
if return_code == 0:
|
|
253
|
+
self.add_class("-success")
|
|
254
|
+
else:
|
|
255
|
+
self.add_class("-error")
|
|
256
|
+
self.border_title = Content.assemble(
|
|
257
|
+
f"{command} [{return_code}]",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _record_output(self, data: bytes) -> None:
|
|
261
|
+
"""Keep a record of the bytes left.
|
|
262
|
+
|
|
263
|
+
Store at most the limit set in self._output_byte_limit (if set).
|
|
264
|
+
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
self._output.append(data)
|
|
268
|
+
self._output_bytes_count += len(data)
|
|
269
|
+
self._bytes_read += len(data)
|
|
270
|
+
|
|
271
|
+
if self._output_byte_limit is None:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
while self._output_bytes_count > self._output_byte_limit and self._output:
|
|
275
|
+
oldest_bytes = self._output[0]
|
|
276
|
+
oldest_bytes_count = len(oldest_bytes)
|
|
277
|
+
if self._output_bytes_count - oldest_bytes_count < self._output_byte_limit:
|
|
278
|
+
break
|
|
279
|
+
self._output.popleft()
|
|
280
|
+
self._output_bytes_count -= oldest_bytes_count
|
|
281
|
+
|
|
282
|
+
def get_output(self) -> tuple[str, bool]:
|
|
283
|
+
"""Get the output.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
A tuple of the output and a bool to indicate if the output was truncated.
|
|
287
|
+
"""
|
|
288
|
+
output_bytes = b"".join(self._output)
|
|
289
|
+
|
|
290
|
+
def is_continuation(byte_value: int) -> bool:
|
|
291
|
+
"""Check if the given byte is a utf-8 continuation byte.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
byte_value: Ordinal of the byte.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
`True` if the byte is a continuation, or `False` if it is the start of a character.
|
|
298
|
+
"""
|
|
299
|
+
return (byte_value & 0b11000000) == 0b10000000
|
|
300
|
+
|
|
301
|
+
truncated = False
|
|
302
|
+
if (
|
|
303
|
+
self._output_byte_limit is not None
|
|
304
|
+
and len(output_bytes) > self._output_byte_limit
|
|
305
|
+
):
|
|
306
|
+
truncated = True
|
|
307
|
+
output_bytes = output_bytes[-self._output_byte_limit :]
|
|
308
|
+
# Must start on a utf-8 boundary
|
|
309
|
+
# Discard initial bytes that aren't a utf-8 continuation byte.
|
|
310
|
+
for offset, byte_value in enumerate(output_bytes):
|
|
311
|
+
if not is_continuation(byte_value):
|
|
312
|
+
if offset:
|
|
313
|
+
output_bytes = output_bytes[offset:]
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
output = output_bytes.decode("utf-8", "replace")
|
|
317
|
+
return output, truncated
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
from textual.app import App, ComposeResult
|
|
322
|
+
|
|
323
|
+
command = Command("python", ["mandelbrot.py"], os.environ.copy(), os.curdir)
|
|
324
|
+
|
|
325
|
+
class TApp(App):
|
|
326
|
+
CSS = """
|
|
327
|
+
Terminal.-success {
|
|
328
|
+
border: panel $text-success 90%;
|
|
329
|
+
}
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
def compose(self) -> ComposeResult:
|
|
333
|
+
yield TerminalTool(command)
|
|
334
|
+
|
|
335
|
+
def on_mount(self) -> None:
|
|
336
|
+
self.query_one(TerminalTool).start()
|
|
337
|
+
|
|
338
|
+
TApp().run()
|
toad/widgets/throbber.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from time import monotonic
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from rich.segment import Segment
|
|
5
|
+
from rich.style import Style as RichStyle
|
|
6
|
+
|
|
7
|
+
from textual.visual import Visual
|
|
8
|
+
from textual.color import Color, Gradient
|
|
9
|
+
|
|
10
|
+
from textual.style import Style
|
|
11
|
+
from textual.strip import Strip
|
|
12
|
+
from textual.visual import RenderOptions
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
from textual.css.styles import RulesMap
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
COLORS = [
|
|
18
|
+
"#881177",
|
|
19
|
+
"#aa3355",
|
|
20
|
+
"#cc6666",
|
|
21
|
+
"#ee9944",
|
|
22
|
+
"#eedd00",
|
|
23
|
+
"#99dd55",
|
|
24
|
+
"#44dd88",
|
|
25
|
+
"#22ccbb",
|
|
26
|
+
"#00bbcc",
|
|
27
|
+
"#0099cc",
|
|
28
|
+
"#3366bb",
|
|
29
|
+
"#663399",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ThrobberVisual(Visual):
|
|
34
|
+
"""A Textual 'Visual' object.
|
|
35
|
+
|
|
36
|
+
Analogous to a Rich renderable, but with support for transparency.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
gradient = Gradient.from_colors(*[Color.parse(color) for color in COLORS])
|
|
41
|
+
|
|
42
|
+
def render_strips(
|
|
43
|
+
self, width: int, height: int | None, style: Style, options: RenderOptions
|
|
44
|
+
) -> list[Strip]:
|
|
45
|
+
"""Render the Visual into an iterable of strips.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
width: Width of desired render.
|
|
49
|
+
height: Height of desired render or `None` for any height.
|
|
50
|
+
style: The base style to render on top of.
|
|
51
|
+
options: Additional render options.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
An list of Strips.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
time = monotonic()
|
|
58
|
+
gradient = self.gradient
|
|
59
|
+
background = style.rich_style.bgcolor
|
|
60
|
+
|
|
61
|
+
strips = [
|
|
62
|
+
Strip(
|
|
63
|
+
[
|
|
64
|
+
Segment(
|
|
65
|
+
"━",
|
|
66
|
+
RichStyle.from_color(
|
|
67
|
+
gradient.get_rich_color((offset / width - time) % 1.0),
|
|
68
|
+
background,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
for offset in range(width)
|
|
72
|
+
],
|
|
73
|
+
width,
|
|
74
|
+
)
|
|
75
|
+
]
|
|
76
|
+
return strips
|
|
77
|
+
|
|
78
|
+
def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
|
|
79
|
+
return container_width
|
|
80
|
+
|
|
81
|
+
def get_height(self, rules: RulesMap, width: int) -> int:
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Throbber(Widget):
|
|
86
|
+
def on_mount(self) -> None:
|
|
87
|
+
self.auto_refresh = 1 / 15
|
|
88
|
+
|
|
89
|
+
def render(self) -> ThrobberVisual:
|
|
90
|
+
return ThrobberVisual()
|