kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
kstlib/ui/spinner.py
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
"""Animated spinner utilities for CLI feedback during long operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import io
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections import deque
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import IO, TYPE_CHECKING, Any, ParamSpec, TypeVar
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
from typing_extensions import Self
|
|
20
|
+
|
|
21
|
+
from kstlib.config import ConfigNotLoadedError, get_config
|
|
22
|
+
from kstlib.ui.exceptions import SpinnerError
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
import types
|
|
26
|
+
|
|
27
|
+
from rich.style import Style
|
|
28
|
+
|
|
29
|
+
P = ParamSpec("P")
|
|
30
|
+
R = TypeVar("R")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SpinnerStyle(Enum):
|
|
34
|
+
"""Predefined spinner animation families.
|
|
35
|
+
|
|
36
|
+
Each style defines a sequence of frames that cycle during animation.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
BRAILLE = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷")
|
|
40
|
+
DOTS = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
41
|
+
LINE = ("|", "/", "-", "\\")
|
|
42
|
+
ARROW = ("←", "↖", "↑", "↗", "→", "↘", "↓", "↙")
|
|
43
|
+
BLOCKS = ("▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂")
|
|
44
|
+
CIRCLE = ("◐", "◓", "◑", "◒")
|
|
45
|
+
SQUARE = ("◰", "◳", "◲", "◱")
|
|
46
|
+
MOON = ("🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘")
|
|
47
|
+
CLOCK = ("🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SpinnerPosition(Enum):
|
|
51
|
+
"""Position of the spinner relative to the message text."""
|
|
52
|
+
|
|
53
|
+
BEFORE = "before"
|
|
54
|
+
AFTER = "after"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SpinnerAnimationType(Enum):
|
|
58
|
+
"""Type of animation to display."""
|
|
59
|
+
|
|
60
|
+
SPIN = "spin"
|
|
61
|
+
BOUNCE = "bounce"
|
|
62
|
+
COLOR_WAVE = "color_wave"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Default configuration
|
|
66
|
+
DEFAULT_SPINNER_CONFIG: dict[str, Any] = {
|
|
67
|
+
"defaults": {
|
|
68
|
+
"style": "BRAILLE",
|
|
69
|
+
"position": "before",
|
|
70
|
+
"animation_type": "spin",
|
|
71
|
+
"interval": 0.08,
|
|
72
|
+
"spinner_style": "cyan",
|
|
73
|
+
"text_style": None,
|
|
74
|
+
"done_character": "✓",
|
|
75
|
+
"done_style": "green",
|
|
76
|
+
"fail_character": "✗",
|
|
77
|
+
"fail_style": "red",
|
|
78
|
+
},
|
|
79
|
+
"presets": {
|
|
80
|
+
"minimal": {
|
|
81
|
+
"style": "LINE",
|
|
82
|
+
"spinner_style": "dim white",
|
|
83
|
+
"interval": 0.1,
|
|
84
|
+
},
|
|
85
|
+
"fancy": {
|
|
86
|
+
"style": "BRAILLE",
|
|
87
|
+
"spinner_style": "bold cyan",
|
|
88
|
+
"interval": 0.06,
|
|
89
|
+
},
|
|
90
|
+
"blocks": {
|
|
91
|
+
"style": "BLOCKS",
|
|
92
|
+
"spinner_style": "blue",
|
|
93
|
+
"interval": 0.05,
|
|
94
|
+
},
|
|
95
|
+
"bounce": {
|
|
96
|
+
"animation_type": "bounce",
|
|
97
|
+
"interval": 0.08,
|
|
98
|
+
"spinner_style": "yellow",
|
|
99
|
+
},
|
|
100
|
+
"color_wave": {
|
|
101
|
+
"animation_type": "color_wave",
|
|
102
|
+
"interval": 0.1,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Color wave palette
|
|
108
|
+
COLOR_WAVE_COLORS = [
|
|
109
|
+
"bright_blue",
|
|
110
|
+
"cyan",
|
|
111
|
+
"bright_cyan",
|
|
112
|
+
"white",
|
|
113
|
+
"bright_cyan",
|
|
114
|
+
"cyan",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# Bounce bar width
|
|
118
|
+
BOUNCE_WIDTH = 20
|
|
119
|
+
BOUNCE_CHAR = "="
|
|
120
|
+
BOUNCE_BRACKET_LEFT = "["
|
|
121
|
+
BOUNCE_BRACKET_RIGHT = "]"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _load_spinner_config() -> dict[str, Any]:
|
|
125
|
+
"""Load spinner configuration from kstlib.conf.yml with fallback to defaults."""
|
|
126
|
+
try:
|
|
127
|
+
config = get_config().to_dict()
|
|
128
|
+
ui_config = config.get("ui", {})
|
|
129
|
+
spinners_config = ui_config.get("spinners", {})
|
|
130
|
+
if spinners_config:
|
|
131
|
+
# Merge with defaults to ensure all keys exist
|
|
132
|
+
merged: dict[str, Any] = {
|
|
133
|
+
"defaults": {**DEFAULT_SPINNER_CONFIG["defaults"]},
|
|
134
|
+
"presets": {**DEFAULT_SPINNER_CONFIG["presets"]},
|
|
135
|
+
}
|
|
136
|
+
if "defaults" in spinners_config:
|
|
137
|
+
merged["defaults"].update(spinners_config["defaults"])
|
|
138
|
+
if "presets" in spinners_config:
|
|
139
|
+
for preset_name, preset_vals in spinners_config["presets"].items():
|
|
140
|
+
if preset_name in merged["presets"]:
|
|
141
|
+
merged["presets"][preset_name].update(preset_vals)
|
|
142
|
+
else:
|
|
143
|
+
merged["presets"][preset_name] = dict(preset_vals)
|
|
144
|
+
return merged
|
|
145
|
+
except ConfigNotLoadedError:
|
|
146
|
+
pass
|
|
147
|
+
return DEFAULT_SPINNER_CONFIG
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Spinner:
|
|
151
|
+
"""Animated spinner for CLI feedback during long operations.
|
|
152
|
+
|
|
153
|
+
Supports multiple animation styles including character spinners, bouncing bars,
|
|
154
|
+
and color wave effects. Can be used as a context manager or controlled manually.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
message: Text to display alongside the spinner.
|
|
158
|
+
style: Spinner animation style (SpinnerStyle enum or string name).
|
|
159
|
+
position: Where to place spinner relative to text (before/after).
|
|
160
|
+
animation_type: Type of animation (spin/bounce/color_wave).
|
|
161
|
+
interval: Seconds between animation frames.
|
|
162
|
+
spinner_style: Rich style for the spinner character.
|
|
163
|
+
text_style: Rich style for the message text.
|
|
164
|
+
console: Optional Rich console instance.
|
|
165
|
+
file: Output stream (defaults to sys.stderr).
|
|
166
|
+
|
|
167
|
+
Examples:
|
|
168
|
+
Create a spinner with default settings:
|
|
169
|
+
|
|
170
|
+
>>> spinner = Spinner("Loading...")
|
|
171
|
+
>>> spinner.message
|
|
172
|
+
'Loading...'
|
|
173
|
+
|
|
174
|
+
Create with custom style:
|
|
175
|
+
|
|
176
|
+
>>> spinner = Spinner("Working", style=SpinnerStyle.DOTS)
|
|
177
|
+
>>> spinner = Spinner("Building", style="BLOCKS", interval=0.1)
|
|
178
|
+
|
|
179
|
+
Using as a context manager (terminal I/O):
|
|
180
|
+
|
|
181
|
+
>>> with Spinner("Processing...") as s: # doctest: +SKIP
|
|
182
|
+
... do_long_operation()
|
|
183
|
+
... s.update("Almost done...")
|
|
184
|
+
|
|
185
|
+
Manual control:
|
|
186
|
+
|
|
187
|
+
>>> spinner = Spinner("Working...") # doctest: +SKIP
|
|
188
|
+
>>> spinner.start() # doctest: +SKIP
|
|
189
|
+
>>> spinner.stop(success=True) # doctest: +SKIP
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
message: str = "",
|
|
195
|
+
*,
|
|
196
|
+
style: SpinnerStyle | str = SpinnerStyle.BRAILLE,
|
|
197
|
+
position: SpinnerPosition | str = SpinnerPosition.BEFORE,
|
|
198
|
+
animation_type: SpinnerAnimationType | str = SpinnerAnimationType.SPIN,
|
|
199
|
+
interval: float = 0.08,
|
|
200
|
+
spinner_style: str | Style | None = "cyan",
|
|
201
|
+
text_style: str | Style | None = None,
|
|
202
|
+
done_character: str = "✓",
|
|
203
|
+
done_style: str | Style | None = "green",
|
|
204
|
+
fail_character: str = "✗",
|
|
205
|
+
fail_style: str | Style | None = "red",
|
|
206
|
+
console: Console | None = None,
|
|
207
|
+
file: IO[str] | None = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Initialize spinner with configuration."""
|
|
210
|
+
self._message = message
|
|
211
|
+
self._style = self._resolve_style(style)
|
|
212
|
+
self._position = self._resolve_position(position)
|
|
213
|
+
self._animation_type = self._resolve_animation_type(animation_type)
|
|
214
|
+
self._interval = interval
|
|
215
|
+
self._spinner_style = spinner_style
|
|
216
|
+
self._text_style = text_style
|
|
217
|
+
self._done_character = done_character
|
|
218
|
+
self._done_style = done_style
|
|
219
|
+
self._fail_character = fail_character
|
|
220
|
+
self._fail_style = fail_style
|
|
221
|
+
self._file = file or sys.stderr
|
|
222
|
+
self._console = console or Console(file=self._file, force_terminal=True)
|
|
223
|
+
|
|
224
|
+
self._running = False
|
|
225
|
+
self._thread: threading.Thread | None = None
|
|
226
|
+
self._lock = threading.Lock()
|
|
227
|
+
self._frame_index = 0
|
|
228
|
+
self._bounce_position = 0
|
|
229
|
+
self._bounce_direction = 1
|
|
230
|
+
self._color_offset = 0
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def from_preset(
|
|
234
|
+
cls,
|
|
235
|
+
preset: str,
|
|
236
|
+
message: str = "",
|
|
237
|
+
*,
|
|
238
|
+
console: Console | None = None,
|
|
239
|
+
**overrides: Any,
|
|
240
|
+
) -> Spinner:
|
|
241
|
+
"""Create a spinner from a named preset.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
preset: Name of the preset (e.g., "minimal", "fancy", "bounce").
|
|
245
|
+
message: Text to display alongside the spinner.
|
|
246
|
+
console: Optional Rich console instance.
|
|
247
|
+
**overrides: Additional parameters to override preset values.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Configured Spinner instance.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
SpinnerError: If preset name is not found.
|
|
254
|
+
|
|
255
|
+
Examples:
|
|
256
|
+
Create from a built-in preset:
|
|
257
|
+
|
|
258
|
+
>>> spinner = Spinner.from_preset("minimal", "Loading...")
|
|
259
|
+
>>> spinner = Spinner.from_preset("fancy", "Processing data")
|
|
260
|
+
|
|
261
|
+
Override preset values:
|
|
262
|
+
|
|
263
|
+
>>> spinner = Spinner.from_preset("bounce", "Building", interval=0.05)
|
|
264
|
+
|
|
265
|
+
Invalid preset raises error:
|
|
266
|
+
|
|
267
|
+
>>> Spinner.from_preset("nonexistent") # doctest: +ELLIPSIS
|
|
268
|
+
Traceback (most recent call last):
|
|
269
|
+
...
|
|
270
|
+
kstlib.ui.exceptions.SpinnerError: Unknown preset 'nonexistent'. ...
|
|
271
|
+
"""
|
|
272
|
+
config = _load_spinner_config()
|
|
273
|
+
presets = config.get("presets", {})
|
|
274
|
+
if preset not in presets:
|
|
275
|
+
available = ", ".join(presets.keys())
|
|
276
|
+
raise SpinnerError(f"Unknown preset '{preset}'. Available: {available}")
|
|
277
|
+
|
|
278
|
+
defaults = config.get("defaults", {}).copy()
|
|
279
|
+
preset_config = presets[preset].copy()
|
|
280
|
+
merged = {**defaults, **preset_config, **overrides}
|
|
281
|
+
|
|
282
|
+
return cls(
|
|
283
|
+
message,
|
|
284
|
+
style=merged.get("style", SpinnerStyle.BRAILLE),
|
|
285
|
+
position=merged.get("position", SpinnerPosition.BEFORE),
|
|
286
|
+
animation_type=merged.get("animation_type", SpinnerAnimationType.SPIN),
|
|
287
|
+
interval=merged.get("interval", 0.08),
|
|
288
|
+
spinner_style=merged.get("spinner_style"),
|
|
289
|
+
text_style=merged.get("text_style"),
|
|
290
|
+
done_character=merged.get("done_character", "✓"),
|
|
291
|
+
done_style=merged.get("done_style", "green"),
|
|
292
|
+
fail_character=merged.get("fail_character", "✗"),
|
|
293
|
+
fail_style=merged.get("fail_style", "red"),
|
|
294
|
+
console=console,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def start(self) -> None:
|
|
298
|
+
"""Start the spinner animation in a background thread."""
|
|
299
|
+
if self._running:
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
self._running = True
|
|
303
|
+
self._hide_cursor()
|
|
304
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
305
|
+
self._thread.start()
|
|
306
|
+
|
|
307
|
+
def stop(self, *, success: bool = True, final_message: str | None = None) -> None:
|
|
308
|
+
"""Stop the spinner animation.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
success: If True, show done character; if False, show fail character.
|
|
312
|
+
final_message: Optional message to display after stopping.
|
|
313
|
+
"""
|
|
314
|
+
if not self._running:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
self._running = False
|
|
318
|
+
if self._thread is not None:
|
|
319
|
+
self._thread.join(timeout=1.0)
|
|
320
|
+
self._thread = None
|
|
321
|
+
|
|
322
|
+
self._clear_line()
|
|
323
|
+
self._show_cursor()
|
|
324
|
+
self._render_final(success=success, final_message=final_message)
|
|
325
|
+
|
|
326
|
+
def update(self, message: str) -> None:
|
|
327
|
+
"""Update the spinner message while running.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
message: New message to display.
|
|
331
|
+
"""
|
|
332
|
+
with self._lock:
|
|
333
|
+
self._message = message
|
|
334
|
+
|
|
335
|
+
def log(self, message: str, style: str | None = None) -> None:
|
|
336
|
+
"""Print a message above the spinner without disrupting animation.
|
|
337
|
+
|
|
338
|
+
Use this to display logs, progress info, or any output while the
|
|
339
|
+
spinner continues running on the bottom line.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
message: Text to print above the spinner.
|
|
343
|
+
style: Optional Rich style for the message.
|
|
344
|
+
"""
|
|
345
|
+
with self._lock:
|
|
346
|
+
# Clear spinner line
|
|
347
|
+
self._file.write("\r\033[K")
|
|
348
|
+
self._file.flush()
|
|
349
|
+
# Print the log message
|
|
350
|
+
if style:
|
|
351
|
+
self._console.print(f"[{style}]{message}[/{style}]")
|
|
352
|
+
else:
|
|
353
|
+
self._console.print(message)
|
|
354
|
+
# Spinner will redraw on next frame
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def message(self) -> str:
|
|
358
|
+
"""Current spinner message."""
|
|
359
|
+
with self._lock:
|
|
360
|
+
return self._message
|
|
361
|
+
|
|
362
|
+
def __enter__(self) -> Self:
|
|
363
|
+
"""Start spinner when entering context."""
|
|
364
|
+
self.start()
|
|
365
|
+
return self
|
|
366
|
+
|
|
367
|
+
def __exit__(
|
|
368
|
+
self,
|
|
369
|
+
exc_type: type[BaseException] | None,
|
|
370
|
+
exc_val: BaseException | None,
|
|
371
|
+
exc_tb: types.TracebackType | None,
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Stop spinner when exiting context."""
|
|
374
|
+
success = exc_type is None
|
|
375
|
+
self.stop(success=success)
|
|
376
|
+
|
|
377
|
+
# ------------------------------------------------------------------
|
|
378
|
+
# Animation loop
|
|
379
|
+
# ------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
def _animate(self) -> None:
|
|
382
|
+
"""Main animation loop running in background thread."""
|
|
383
|
+
while self._running:
|
|
384
|
+
self._render_frame()
|
|
385
|
+
time.sleep(self._interval)
|
|
386
|
+
|
|
387
|
+
def _render_frame(self) -> None:
|
|
388
|
+
"""Render a single animation frame."""
|
|
389
|
+
self._move_to_line_start()
|
|
390
|
+
|
|
391
|
+
if self._animation_type == SpinnerAnimationType.SPIN:
|
|
392
|
+
self._render_spin_frame()
|
|
393
|
+
elif self._animation_type == SpinnerAnimationType.BOUNCE:
|
|
394
|
+
self._render_bounce_frame()
|
|
395
|
+
elif self._animation_type == SpinnerAnimationType.COLOR_WAVE:
|
|
396
|
+
self._render_color_wave_frame()
|
|
397
|
+
|
|
398
|
+
def _render_spin_frame(self) -> None:
|
|
399
|
+
"""Render classic spinner animation frame."""
|
|
400
|
+
frames = self._style.value
|
|
401
|
+
frame_char = frames[self._frame_index % len(frames)]
|
|
402
|
+
self._frame_index += 1
|
|
403
|
+
|
|
404
|
+
spinner_text = Text(frame_char, style=self._spinner_style or "")
|
|
405
|
+
|
|
406
|
+
with self._lock:
|
|
407
|
+
message = self._message
|
|
408
|
+
|
|
409
|
+
message_text = self._styled_message(message)
|
|
410
|
+
if self._position == SpinnerPosition.BEFORE:
|
|
411
|
+
output = Text.assemble(spinner_text, " ", message_text)
|
|
412
|
+
else:
|
|
413
|
+
output = Text.assemble(message_text, " ", spinner_text)
|
|
414
|
+
|
|
415
|
+
self._console.print(output, end="")
|
|
416
|
+
|
|
417
|
+
def _render_bounce_frame(self) -> None:
|
|
418
|
+
"""Render bouncing bar animation frame."""
|
|
419
|
+
# Build the bar: [= ] with = bouncing
|
|
420
|
+
bar_inner = [" "] * BOUNCE_WIDTH
|
|
421
|
+
bar_inner[self._bounce_position] = BOUNCE_CHAR
|
|
422
|
+
|
|
423
|
+
# Update bounce position
|
|
424
|
+
self._bounce_position += self._bounce_direction
|
|
425
|
+
if self._bounce_position >= BOUNCE_WIDTH - 1:
|
|
426
|
+
self._bounce_direction = -1
|
|
427
|
+
elif self._bounce_position <= 0:
|
|
428
|
+
self._bounce_direction = 1
|
|
429
|
+
|
|
430
|
+
bar = BOUNCE_BRACKET_LEFT + "".join(bar_inner) + BOUNCE_BRACKET_RIGHT
|
|
431
|
+
bar_text = Text(bar, style=self._spinner_style or "")
|
|
432
|
+
|
|
433
|
+
with self._lock:
|
|
434
|
+
message = self._message
|
|
435
|
+
|
|
436
|
+
message_text = self._styled_message(message)
|
|
437
|
+
if self._position == SpinnerPosition.BEFORE:
|
|
438
|
+
output = Text.assemble(bar_text, " ", message_text)
|
|
439
|
+
else:
|
|
440
|
+
output = Text.assemble(message_text, " ", bar_text)
|
|
441
|
+
|
|
442
|
+
self._console.print(output, end="")
|
|
443
|
+
|
|
444
|
+
def _render_color_wave_frame(self) -> None:
|
|
445
|
+
"""Render color wave animation through text."""
|
|
446
|
+
with self._lock:
|
|
447
|
+
message = self._message
|
|
448
|
+
|
|
449
|
+
if not message:
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
output = Text()
|
|
453
|
+
for i, char in enumerate(message):
|
|
454
|
+
color_index = (i + self._color_offset) % len(COLOR_WAVE_COLORS)
|
|
455
|
+
output.append(char, style=COLOR_WAVE_COLORS[color_index])
|
|
456
|
+
|
|
457
|
+
self._color_offset += 1
|
|
458
|
+
self._console.print(output, end="")
|
|
459
|
+
|
|
460
|
+
def _render_final(self, *, success: bool, final_message: str | None) -> None:
|
|
461
|
+
"""Render final state after stopping."""
|
|
462
|
+
if self._animation_type == SpinnerAnimationType.COLOR_WAVE:
|
|
463
|
+
# For color wave, just print the message normally
|
|
464
|
+
with self._lock:
|
|
465
|
+
message = final_message or self._message
|
|
466
|
+
if message:
|
|
467
|
+
style = self._done_style if success else self._fail_style
|
|
468
|
+
self._console.print(Text(message, style=style or ""))
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
char = self._done_character if success else self._fail_character
|
|
472
|
+
char_style = self._done_style if success else self._fail_style
|
|
473
|
+
final_char = Text(char, style=char_style or "")
|
|
474
|
+
|
|
475
|
+
with self._lock:
|
|
476
|
+
message = final_message or self._message
|
|
477
|
+
|
|
478
|
+
message_text = self._styled_message(message)
|
|
479
|
+
if self._position == SpinnerPosition.BEFORE:
|
|
480
|
+
output = Text.assemble(final_char, " ", message_text)
|
|
481
|
+
else:
|
|
482
|
+
output = Text.assemble(message_text, " ", final_char)
|
|
483
|
+
|
|
484
|
+
self._console.print(output)
|
|
485
|
+
|
|
486
|
+
def _styled_message(self, message: str) -> Text:
|
|
487
|
+
"""Create a styled Text object for the message."""
|
|
488
|
+
if self._text_style:
|
|
489
|
+
return Text(message, style=self._text_style)
|
|
490
|
+
return Text(message)
|
|
491
|
+
|
|
492
|
+
def _move_to_line_start(self) -> None:
|
|
493
|
+
"""Move cursor to beginning of line without clearing."""
|
|
494
|
+
self._file.write("\r")
|
|
495
|
+
self._file.flush()
|
|
496
|
+
|
|
497
|
+
def _clear_line(self) -> None:
|
|
498
|
+
"""Clear the current console line."""
|
|
499
|
+
# Write ANSI sequences directly to file descriptor (Rich doesn't pass them through)
|
|
500
|
+
self._file.write("\r\033[K")
|
|
501
|
+
self._file.flush()
|
|
502
|
+
|
|
503
|
+
def _hide_cursor(self) -> None:
|
|
504
|
+
"""Hide terminal cursor to reduce flickering."""
|
|
505
|
+
self._file.write("\033[?25l")
|
|
506
|
+
self._file.flush()
|
|
507
|
+
|
|
508
|
+
def _show_cursor(self) -> None:
|
|
509
|
+
"""Show terminal cursor."""
|
|
510
|
+
self._file.write("\033[?25h")
|
|
511
|
+
self._file.flush()
|
|
512
|
+
|
|
513
|
+
# ------------------------------------------------------------------
|
|
514
|
+
# Resolution helpers
|
|
515
|
+
# ------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
@staticmethod
|
|
518
|
+
def _resolve_style(style: SpinnerStyle | str) -> SpinnerStyle:
|
|
519
|
+
"""Convert string to SpinnerStyle enum if needed."""
|
|
520
|
+
if isinstance(style, SpinnerStyle):
|
|
521
|
+
return style
|
|
522
|
+
try:
|
|
523
|
+
return SpinnerStyle[style.upper()]
|
|
524
|
+
except KeyError as exc:
|
|
525
|
+
available = ", ".join(s.name for s in SpinnerStyle)
|
|
526
|
+
raise SpinnerError(f"Unknown spinner style '{style}'. Available: {available}") from exc
|
|
527
|
+
|
|
528
|
+
@staticmethod
|
|
529
|
+
def _resolve_position(position: SpinnerPosition | str) -> SpinnerPosition:
|
|
530
|
+
"""Convert string to SpinnerPosition enum if needed."""
|
|
531
|
+
if isinstance(position, SpinnerPosition):
|
|
532
|
+
return position
|
|
533
|
+
try:
|
|
534
|
+
return SpinnerPosition(position.lower())
|
|
535
|
+
except ValueError as exc:
|
|
536
|
+
raise SpinnerError(f"Invalid position '{position}'. Use 'before' or 'after'.") from exc
|
|
537
|
+
|
|
538
|
+
@staticmethod
|
|
539
|
+
def _resolve_animation_type(animation_type: SpinnerAnimationType | str) -> SpinnerAnimationType:
|
|
540
|
+
"""Convert string to SpinnerAnimationType enum if needed."""
|
|
541
|
+
if isinstance(animation_type, SpinnerAnimationType):
|
|
542
|
+
return animation_type
|
|
543
|
+
try:
|
|
544
|
+
return SpinnerAnimationType(animation_type.lower())
|
|
545
|
+
except ValueError as exc:
|
|
546
|
+
available = ", ".join(t.value for t in SpinnerAnimationType)
|
|
547
|
+
raise SpinnerError(f"Invalid animation type '{animation_type}'. Available: {available}") from exc
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# ==============================================================================
|
|
551
|
+
# Decorator for capturing prints
|
|
552
|
+
# ==============================================================================
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class _PrintCapture(io.StringIO):
|
|
556
|
+
"""Captures print output and redirects to spinner.log()."""
|
|
557
|
+
|
|
558
|
+
def __init__(
|
|
559
|
+
self,
|
|
560
|
+
spinner: Spinner | SpinnerWithLogZone,
|
|
561
|
+
style: str | None = None,
|
|
562
|
+
) -> None:
|
|
563
|
+
super().__init__()
|
|
564
|
+
self._spinner: Spinner | SpinnerWithLogZone = spinner
|
|
565
|
+
self._style = style
|
|
566
|
+
|
|
567
|
+
def write(self, text: str) -> int:
|
|
568
|
+
"""Intercept write calls and send to spinner.log()."""
|
|
569
|
+
# Filter out empty strings and lone newlines
|
|
570
|
+
stripped = text.rstrip("\n")
|
|
571
|
+
if stripped:
|
|
572
|
+
self._spinner.log(stripped, style=self._style)
|
|
573
|
+
return len(text)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def with_spinner(
|
|
577
|
+
message: str = "Processing...",
|
|
578
|
+
*,
|
|
579
|
+
style: SpinnerStyle | str = SpinnerStyle.BRAILLE,
|
|
580
|
+
log_style: str | None = "dim",
|
|
581
|
+
capture_prints: bool = True,
|
|
582
|
+
log_zone_height: int | None = None,
|
|
583
|
+
**spinner_kwargs: Any,
|
|
584
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
585
|
+
"""Decorator that wraps a function with a spinner, capturing its prints.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
message: Spinner message to display.
|
|
589
|
+
style: Spinner animation style.
|
|
590
|
+
log_style: Style for captured print output (None for no style).
|
|
591
|
+
capture_prints: If True, redirect stdout to spinner.log().
|
|
592
|
+
log_zone_height: If set, use SpinnerWithLogZone with fixed height.
|
|
593
|
+
The spinner stays at top, logs scroll in bounded zone below.
|
|
594
|
+
**spinner_kwargs: Additional arguments passed to Spinner.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
Decorated function.
|
|
598
|
+
|
|
599
|
+
Examples:
|
|
600
|
+
Basic decorator usage (terminal I/O):
|
|
601
|
+
|
|
602
|
+
>>> @with_spinner("Loading data...") # doctest: +SKIP
|
|
603
|
+
... def load_data():
|
|
604
|
+
... return {"data": [1, 2, 3]}
|
|
605
|
+
>>> result = load_data() # doctest: +SKIP
|
|
606
|
+
|
|
607
|
+
With log capture (prints appear above spinner):
|
|
608
|
+
|
|
609
|
+
>>> @with_spinner("Processing...", log_style="cyan") # doctest: +SKIP
|
|
610
|
+
... def process():
|
|
611
|
+
... print("Step 1 complete") # Appears above spinner
|
|
612
|
+
... print("Step 2 complete")
|
|
613
|
+
... return True
|
|
614
|
+
|
|
615
|
+
Fixed log zone with bounded scrolling:
|
|
616
|
+
|
|
617
|
+
>>> @with_spinner("Building...", log_zone_height=5) # doctest: +SKIP
|
|
618
|
+
... def build():
|
|
619
|
+
... for i in range(10):
|
|
620
|
+
... print(f"Step {i}") # Scrolls in 5-line zone
|
|
621
|
+
... return True
|
|
622
|
+
"""
|
|
623
|
+
|
|
624
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
625
|
+
@functools.wraps(func)
|
|
626
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
627
|
+
# Choose spinner type based on log_zone_height
|
|
628
|
+
if log_zone_height is not None:
|
|
629
|
+
spinner: Spinner | SpinnerWithLogZone = SpinnerWithLogZone(
|
|
630
|
+
message,
|
|
631
|
+
style=style,
|
|
632
|
+
log_zone_height=log_zone_height,
|
|
633
|
+
**spinner_kwargs,
|
|
634
|
+
)
|
|
635
|
+
else:
|
|
636
|
+
spinner = Spinner(message, style=style, **spinner_kwargs)
|
|
637
|
+
|
|
638
|
+
with spinner:
|
|
639
|
+
if capture_prints:
|
|
640
|
+
capture = _PrintCapture(spinner, style=log_style)
|
|
641
|
+
old_stdout = sys.stdout
|
|
642
|
+
sys.stdout = capture
|
|
643
|
+
try:
|
|
644
|
+
return func(*args, **kwargs)
|
|
645
|
+
finally:
|
|
646
|
+
sys.stdout = old_stdout
|
|
647
|
+
else:
|
|
648
|
+
return func(*args, **kwargs)
|
|
649
|
+
|
|
650
|
+
return wrapper
|
|
651
|
+
|
|
652
|
+
return decorator
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# ==============================================================================
|
|
656
|
+
# Spinner with fixed log zone
|
|
657
|
+
# ==============================================================================
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class SpinnerWithLogZone:
|
|
661
|
+
"""Spinner with a fixed position and a scrollable log zone.
|
|
662
|
+
|
|
663
|
+
The spinner stays fixed at the top while logs scroll in a zone below.
|
|
664
|
+
When the zone is full, old logs are pushed out automatically.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
message: Spinner message.
|
|
668
|
+
log_zone_height: Number of lines for the log zone (default 10).
|
|
669
|
+
style: Spinner animation style.
|
|
670
|
+
spinner_style: Rich style for spinner character.
|
|
671
|
+
console: Optional Rich console.
|
|
672
|
+
**kwargs: Additional Spinner arguments.
|
|
673
|
+
|
|
674
|
+
Examples:
|
|
675
|
+
Create with custom log zone height:
|
|
676
|
+
|
|
677
|
+
>>> sz = SpinnerWithLogZone("Building...", log_zone_height=5)
|
|
678
|
+
>>> sz._log_zone_height
|
|
679
|
+
5
|
|
680
|
+
|
|
681
|
+
Usage as context manager (terminal I/O):
|
|
682
|
+
|
|
683
|
+
>>> with SpinnerWithLogZone("Processing", log_zone_height=3) as sz: # doctest: +SKIP
|
|
684
|
+
... sz.log("Step 1 done")
|
|
685
|
+
... sz.log("Step 2 done")
|
|
686
|
+
... sz.update("Almost finished...")
|
|
687
|
+
"""
|
|
688
|
+
|
|
689
|
+
def __init__(
|
|
690
|
+
self,
|
|
691
|
+
message: str = "",
|
|
692
|
+
*,
|
|
693
|
+
log_zone_height: int = 10,
|
|
694
|
+
style: SpinnerStyle | str = SpinnerStyle.BRAILLE,
|
|
695
|
+
spinner_style: str | None = "cyan",
|
|
696
|
+
console: Console | None = None,
|
|
697
|
+
file: IO[str] | None = None,
|
|
698
|
+
interval: float = 0.08,
|
|
699
|
+
) -> None:
|
|
700
|
+
self._message = message
|
|
701
|
+
self._log_zone_height = log_zone_height
|
|
702
|
+
self._style = Spinner._resolve_style(style)
|
|
703
|
+
self._spinner_style = spinner_style
|
|
704
|
+
self._interval = interval
|
|
705
|
+
self._file = file or sys.stderr
|
|
706
|
+
self._console = console or Console(file=self._file, force_terminal=True)
|
|
707
|
+
|
|
708
|
+
self._logs: deque[str] = deque(maxlen=log_zone_height)
|
|
709
|
+
self._running = False
|
|
710
|
+
self._thread: threading.Thread | None = None
|
|
711
|
+
self._lock = threading.Lock()
|
|
712
|
+
self._frame_index = 0
|
|
713
|
+
self._initialized = False
|
|
714
|
+
self._logs_dirty = False # Track if logs need redraw
|
|
715
|
+
self._last_message = "" # Track message changes
|
|
716
|
+
|
|
717
|
+
def start(self) -> None:
|
|
718
|
+
"""Start the spinner animation."""
|
|
719
|
+
if self._running:
|
|
720
|
+
return
|
|
721
|
+
|
|
722
|
+
self._running = True
|
|
723
|
+
self._setup_zone()
|
|
724
|
+
self._hide_cursor()
|
|
725
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
726
|
+
self._thread.start()
|
|
727
|
+
|
|
728
|
+
def stop(self, *, success: bool = True, final_message: str | None = None) -> None:
|
|
729
|
+
"""Stop the spinner and clean up the display."""
|
|
730
|
+
if not self._running:
|
|
731
|
+
return
|
|
732
|
+
|
|
733
|
+
self._running = False
|
|
734
|
+
if self._thread is not None:
|
|
735
|
+
self._thread.join(timeout=1.0)
|
|
736
|
+
self._thread = None
|
|
737
|
+
|
|
738
|
+
self._show_cursor()
|
|
739
|
+
self._render_final(success, final_message)
|
|
740
|
+
|
|
741
|
+
def update(self, message: str) -> None:
|
|
742
|
+
"""Update the spinner message."""
|
|
743
|
+
with self._lock:
|
|
744
|
+
self._message = message
|
|
745
|
+
|
|
746
|
+
def log(self, message: str, style: str | None = None) -> None:
|
|
747
|
+
"""Add a log entry to the scrolling zone."""
|
|
748
|
+
with self._lock:
|
|
749
|
+
if style:
|
|
750
|
+
self._logs.append(f"[{style}]{message}[/{style}]")
|
|
751
|
+
else:
|
|
752
|
+
self._logs.append(message)
|
|
753
|
+
self._logs_dirty = True
|
|
754
|
+
|
|
755
|
+
def __enter__(self) -> Self:
|
|
756
|
+
"""Start spinner when entering context."""
|
|
757
|
+
self.start()
|
|
758
|
+
return self
|
|
759
|
+
|
|
760
|
+
def __exit__(
|
|
761
|
+
self,
|
|
762
|
+
exc_type: type[BaseException] | None,
|
|
763
|
+
exc_val: BaseException | None,
|
|
764
|
+
exc_tb: types.TracebackType | None,
|
|
765
|
+
) -> None:
|
|
766
|
+
"""Stop spinner when exiting context."""
|
|
767
|
+
self.stop(success=exc_type is None)
|
|
768
|
+
|
|
769
|
+
def _setup_zone(self) -> None:
|
|
770
|
+
"""Reserve space for the log zone by printing newlines."""
|
|
771
|
+
if self._initialized:
|
|
772
|
+
return
|
|
773
|
+
# Print empty lines to reserve space
|
|
774
|
+
# +1 for spinner line at top
|
|
775
|
+
self._file.write("\n" * (self._log_zone_height + 1))
|
|
776
|
+
self._file.flush()
|
|
777
|
+
self._initialized = True
|
|
778
|
+
|
|
779
|
+
def _animate(self) -> None:
|
|
780
|
+
"""Animation loop."""
|
|
781
|
+
while self._running:
|
|
782
|
+
self._render_frame()
|
|
783
|
+
time.sleep(self._interval)
|
|
784
|
+
|
|
785
|
+
def _render_frame(self) -> None:
|
|
786
|
+
"""Render spinner and log zone (optimized: only redraw what changed)."""
|
|
787
|
+
frames = self._style.value
|
|
788
|
+
frame_char = frames[self._frame_index % len(frames)]
|
|
789
|
+
self._frame_index += 1
|
|
790
|
+
|
|
791
|
+
with self._lock:
|
|
792
|
+
message = self._message
|
|
793
|
+
logs_dirty = self._logs_dirty
|
|
794
|
+
logs = list(self._logs) if logs_dirty else []
|
|
795
|
+
message_changed = message != self._last_message
|
|
796
|
+
self._logs_dirty = False
|
|
797
|
+
self._last_message = message
|
|
798
|
+
|
|
799
|
+
# Move cursor to spinner line (top of zone)
|
|
800
|
+
total_lines = self._log_zone_height + 1
|
|
801
|
+
self._file.write(f"\033[{total_lines}A") # Move up
|
|
802
|
+
|
|
803
|
+
# Always render spinner line (just overwrite, no clear needed for spinner char)
|
|
804
|
+
self._file.write("\r")
|
|
805
|
+
spinner_text = Text(frame_char, style=self._spinner_style or "")
|
|
806
|
+
msg_text = Text(f" {message}")
|
|
807
|
+
self._console.print(Text.assemble(spinner_text, msg_text), end="")
|
|
808
|
+
|
|
809
|
+
# Pad with spaces if message got shorter
|
|
810
|
+
if message_changed:
|
|
811
|
+
self._file.write("\033[K") # Clear rest of line
|
|
812
|
+
|
|
813
|
+
# Only redraw logs if they changed
|
|
814
|
+
if logs_dirty:
|
|
815
|
+
self._file.write("\n") # Move to first log line
|
|
816
|
+
for i in range(self._log_zone_height):
|
|
817
|
+
self._file.write("\033[K") # Clear line
|
|
818
|
+
if i < len(logs):
|
|
819
|
+
self._console.print(f" {logs[i]}", end="")
|
|
820
|
+
if i < self._log_zone_height - 1:
|
|
821
|
+
self._file.write("\n")
|
|
822
|
+
self._file.write("\n") # Final newline to position cursor at bottom
|
|
823
|
+
else:
|
|
824
|
+
# Just move cursor back to bottom without redrawing logs
|
|
825
|
+
self._file.write(f"\033[{self._log_zone_height}B") # Move down
|
|
826
|
+
self._file.write("\n")
|
|
827
|
+
|
|
828
|
+
self._file.flush()
|
|
829
|
+
|
|
830
|
+
def _render_final(self, success: bool, final_message: str | None) -> None:
|
|
831
|
+
"""Render final state."""
|
|
832
|
+
char = "✓" if success else "✗"
|
|
833
|
+
char_style = "green" if success else "red"
|
|
834
|
+
message = final_message or self._message
|
|
835
|
+
|
|
836
|
+
with self._lock:
|
|
837
|
+
logs = list(self._logs)
|
|
838
|
+
|
|
839
|
+
# Move to top of zone
|
|
840
|
+
total_lines = self._log_zone_height + 1
|
|
841
|
+
self._file.write(f"\033[{total_lines}A")
|
|
842
|
+
|
|
843
|
+
# Final spinner line
|
|
844
|
+
self._file.write("\r\033[K")
|
|
845
|
+
self._console.print(f"[{char_style}]{char}[/{char_style}] {message}")
|
|
846
|
+
|
|
847
|
+
# Render remaining logs
|
|
848
|
+
for i in range(self._log_zone_height):
|
|
849
|
+
self._file.write("\033[K")
|
|
850
|
+
if i < len(logs):
|
|
851
|
+
self._console.print(f" {logs[i]}", end="")
|
|
852
|
+
self._file.write("\n")
|
|
853
|
+
|
|
854
|
+
self._file.flush()
|
|
855
|
+
|
|
856
|
+
def _hide_cursor(self) -> None:
|
|
857
|
+
"""Hide cursor."""
|
|
858
|
+
self._file.write("\033[?25l")
|
|
859
|
+
self._file.flush()
|
|
860
|
+
|
|
861
|
+
def _show_cursor(self) -> None:
|
|
862
|
+
"""Show cursor."""
|
|
863
|
+
self._file.write("\033[?25h")
|
|
864
|
+
self._file.flush()
|