From b13f4c761e2f51a20a78d1bcec944fa1d2599eb1 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 15:32:57 -0400 Subject: [PATCH] modal minigame --- src/tui/widgets/command_modal.py | 49 ++++++++++- src/tui/widgets/waves.py | 146 +++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 src/tui/widgets/waves.py diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index 7ed8b30e..379c946d 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -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) diff --git a/src/tui/widgets/waves.py b/src/tui/widgets/waves.py new file mode 100644 index 00000000..5e3d7d8f --- /dev/null +++ b/src/tui/widgets/waves.py @@ -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)