modal minigame
This commit is contained in:
parent
5f70e6042a
commit
b13f4c761e
2 changed files with 193 additions and 2 deletions
|
|
@ -6,21 +6,36 @@ from typing import Callable, Optional, AsyncIterator
|
|||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Static, Label, TextArea
|
||||
from textual.widgets import Button, Static, Label, TextArea, Footer
|
||||
|
||||
from ..utils.clipboard import copy_text_to_clipboard
|
||||
from .waves import Waves
|
||||
|
||||
|
||||
class CommandOutputModal(ModalScreen):
|
||||
"""Modal dialog for displaying command output in real-time."""
|
||||
|
||||
BINDINGS = [
|
||||
("w,+", "add_wave", "Add"),
|
||||
("r,-", "remove_wave", "Remove"),
|
||||
("p", "pause_waves", "Pause"),
|
||||
("up", "speed_up", "Faster"),
|
||||
("down", "speed_down", "Slower"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
CommandOutputModal {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#waves-background {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
layer: background;
|
||||
}
|
||||
|
||||
#dialog {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
|
|
@ -173,6 +188,7 @@ class CommandOutputModal(ModalScreen):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create the modal dialog layout."""
|
||||
yield Waves(id="waves-background")
|
||||
with Container(id="dialog"):
|
||||
yield Label(self.title_text, id="title")
|
||||
yield TextArea(
|
||||
|
|
@ -187,6 +203,7 @@ class CommandOutputModal(ModalScreen):
|
|||
"Close", variant="primary", id="close-btn", disabled=True
|
||||
)
|
||||
yield Static("", id="copy-status")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Start the command when the modal is mounted."""
|
||||
|
|
@ -206,6 +223,34 @@ class CommandOutputModal(ModalScreen):
|
|||
elif event.button.id == "copy-btn":
|
||||
self.copy_to_clipboard()
|
||||
|
||||
def action_add_wave(self) -> None:
|
||||
"""Add a wave to the animation."""
|
||||
waves = self.query_one("#waves-background", Waves)
|
||||
waves._add_wavelet()
|
||||
|
||||
def action_remove_wave(self) -> None:
|
||||
"""Remove a wave from the animation."""
|
||||
waves = self.query_one("#waves-background", Waves)
|
||||
if waves.wavelets:
|
||||
waves.wavelets.pop()
|
||||
|
||||
def action_pause_waves(self) -> None:
|
||||
"""Pause/unpause the wave animation."""
|
||||
waves = self.query_one("#waves-background", Waves)
|
||||
waves.paused = not waves.paused
|
||||
|
||||
def action_speed_up(self) -> None:
|
||||
"""Increase wave speed."""
|
||||
waves = self.query_one("#waves-background", Waves)
|
||||
for w in waves.wavelets:
|
||||
w.speed = min(2.0, w.speed * 1.2)
|
||||
|
||||
def action_speed_down(self) -> None:
|
||||
"""Decrease wave speed."""
|
||||
waves = self.query_one("#waves-background", Waves)
|
||||
for w in waves.wavelets:
|
||||
w.speed = max(0.1, w.speed * 0.8)
|
||||
|
||||
async def _run_command(self) -> None:
|
||||
"""Run the command and update the output in real-time."""
|
||||
output = self.query_one("#command-output", TextArea)
|
||||
|
|
|
|||
146
src/tui/widgets/waves.py
Normal file
146
src/tui/widgets/waves.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Waves animation widget for command modals."""
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from textual import events
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
@dataclass
|
||||
class Wavelet:
|
||||
x: float
|
||||
lane: int # 0 or 1
|
||||
speed: float # chars/frame
|
||||
phase: float # for subtle vertical bob / color cycle
|
||||
hue: int # index in palette
|
||||
|
||||
|
||||
class Waves(Static):
|
||||
"""Animated waves widget that displays moving wavelets around the border."""
|
||||
|
||||
can_focus = False
|
||||
fps = 24
|
||||
paused = reactive(False)
|
||||
show_help = reactive(False)
|
||||
|
||||
def on_mount(self):
|
||||
self.palette = ["#93c5fd", "#60a5fa", "#38bdf8", "#a78bfa", "#f472b6"]
|
||||
self.wavelets: List[Wavelet] = []
|
||||
# Start with a few wavelets
|
||||
for _ in range(3):
|
||||
self._add_wavelet()
|
||||
self.set_interval(1 / self.fps, self._tick)
|
||||
|
||||
def _offset_for_lane(self, lane: int, width: int, height: int) -> int:
|
||||
max_offset = max(1, min(width, height) // 2 - 1)
|
||||
return min(1 + lane, max_offset)
|
||||
|
||||
def _build_path(
|
||||
self, width: int, height: int, offset: int
|
||||
) -> List[tuple[int, int]]:
|
||||
left = offset
|
||||
right = max(offset, width - offset - 1)
|
||||
top = offset
|
||||
bottom = max(offset, height - offset - 1)
|
||||
if right < left or bottom < top:
|
||||
return [(max(0, left), max(0, top))]
|
||||
|
||||
path: List[tuple[int, int]] = []
|
||||
# Top edge
|
||||
for x in range(left, right + 1):
|
||||
path.append((x, top))
|
||||
# Right edge (excluding corners already added)
|
||||
for y in range(top + 1, bottom):
|
||||
path.append((right, y))
|
||||
# Bottom edge (if distinct from top)
|
||||
if bottom != top:
|
||||
for x in range(right, left - 1, -1):
|
||||
path.append((x, bottom))
|
||||
# Left edge (excluding corners)
|
||||
if right != left:
|
||||
for y in range(bottom - 1, top, -1):
|
||||
path.append((left, y))
|
||||
return path or [(left, top)]
|
||||
|
||||
def _path_for_lane(
|
||||
self, width: int, height: int, lane: int
|
||||
) -> List[tuple[int, int]]:
|
||||
offset = self._offset_for_lane(lane, width, height)
|
||||
path = self._build_path(width, height, offset)
|
||||
if not path and offset > 1:
|
||||
offset = 1
|
||||
path = self._build_path(width, height, offset)
|
||||
return path
|
||||
|
||||
def _add_wavelet(self):
|
||||
w = max(10, self.size.width)
|
||||
self.wavelets.append(
|
||||
Wavelet(
|
||||
x=0,
|
||||
lane=random.choice([0, 1]),
|
||||
speed=0.25 + random.random() * 0.35, # slow & smooth
|
||||
phase=random.random() * math.tau,
|
||||
hue=random.randrange(len(self.palette)),
|
||||
)
|
||||
)
|
||||
# Initialize position once we know the current perimeter
|
||||
h = max(6, self.size.height)
|
||||
path = self._path_for_lane(w, h, self.wavelets[-1].lane)
|
||||
if path:
|
||||
self.wavelets[-1].x = random.uniform(0, len(path))
|
||||
|
||||
def set_throughput(self, bytes_per_sec: float):
|
||||
"""Modulate wavelet speed based on download throughput."""
|
||||
boost = min(1.8, 1.0 + math.log10(bytes_per_sec + 1) / 6.0)
|
||||
for w in self.wavelets:
|
||||
w.speed = min(1.2, w.speed * 0.7 + (0.25 * boost))
|
||||
|
||||
def _tick(self):
|
||||
if self.paused:
|
||||
self.refresh()
|
||||
return
|
||||
width = max(10, self.size.width)
|
||||
height = max(6, self.size.height)
|
||||
for w in self.wavelets:
|
||||
path = self._path_for_lane(width, height, w.lane)
|
||||
if not path:
|
||||
continue
|
||||
perimeter = len(path)
|
||||
if perimeter <= 0:
|
||||
continue
|
||||
w.x %= perimeter
|
||||
new_pos = w.x + w.speed
|
||||
wrapped = new_pos >= perimeter
|
||||
w.x = new_pos % perimeter
|
||||
if wrapped:
|
||||
# Tiny color/phase change on wrap
|
||||
w.hue = (w.hue + 1) % len(self.palette)
|
||||
w.phase = random.random() * math.tau
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> str:
|
||||
W = max(10, self.size.width)
|
||||
H = max(6, self.size.height)
|
||||
buf = [[" "] * W for _ in range(H)]
|
||||
|
||||
# Draw wavelets moving around the border
|
||||
for wv in self.wavelets:
|
||||
path = self._path_for_lane(W, H, wv.lane)
|
||||
if not path:
|
||||
continue
|
||||
perimeter = len(path)
|
||||
if perimeter <= 0:
|
||||
continue
|
||||
idx = int(wv.x) % perimeter
|
||||
x, y = path[idx]
|
||||
if 0 <= x < W and 0 <= y < H:
|
||||
col = self.palette[wv.hue]
|
||||
buf[y][x] = f"[{col}]≈[/]"
|
||||
|
||||
# No border - just wavelets on empty background
|
||||
|
||||
return "\n".join("".join(r) for r in buf)
|
||||
Loading…
Add table
Reference in a new issue