Skip to content

Commit b9948f0

Browse files
authored
Merge pull request #4 from Lanznx/task/lazy-2
[feat] add ASCII animations and core playback engine
2 parents 86eaba6 + d5cc97f commit b9948f0

File tree

15 files changed

+1431
-36
lines changed

15 files changed

+1431
-36
lines changed

README.md

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@ pip install lazy-b
1212

1313
# Using uv
1414
uv pip install lazy-b
15-
16-
# For macOS users (to enable dock icon hiding)
17-
pip install "lazy-b[macos]"
18-
# or with uv
19-
uv pip install "lazy-b[macos]"
2015
```
2116

2217
## Usage
@@ -31,12 +26,6 @@ lazy-b
3126

3227
# Customize the interval (e.g., every 30 seconds)
3328
lazy-b --interval 30
34-
35-
# Run in quiet mode (no console output)
36-
lazy-b --quiet
37-
38-
# Run in foreground mode (shows dock icon - macOS only)
39-
lazy-b --foreground
4029
```
4130

4231
#### Platform-specific behavior
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
o
2+
/|\
3+
/ \
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
\o/
2+
|
3+
/ \
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
o/
2+
/|
3+
/ \
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
^_^
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
O_O
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
-_-

src/lazy_b/animations/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""ASCII Animation System for Terminal Interface."""
2+
3+
from .types import AnimationMetadata, AnimationFrame, AnimationSequence
4+
from .core import AnimationEngine
5+
from .interactive_menu import InteractiveMenu
6+
7+
__all__ = [
8+
"AnimationMetadata",
9+
"AnimationFrame",
10+
"AnimationSequence",
11+
"AnimationEngine",
12+
"InteractiveMenu",
13+
]

src/lazy_b/animations/core.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Core animation engine - Domain Layer."""
2+
3+
import os
4+
import time
5+
import platform
6+
from typing import List, Optional
7+
import logging
8+
9+
from .types import (
10+
AnimationSequence,
11+
AnimationFrame,
12+
AnimationConfig,
13+
AnimationRenderer,
14+
PlaybackResult,
15+
)
16+
from .repository import FileSystemAnimationRepository
17+
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class TerminalRenderer:
23+
"""Terminal-specific animation renderer."""
24+
25+
def __init__(self) -> None:
26+
self._clear_command = "cls" if platform.system() == "Windows" else "clear"
27+
28+
def render_frame(self, frame: AnimationFrame) -> None:
29+
"""Render a single frame to terminal."""
30+
print(frame.content)
31+
32+
def clear_screen(self) -> None:
33+
"""Clear the terminal screen."""
34+
os.system(self._clear_command)
35+
36+
37+
class AnimationEngine:
38+
"""Core animation playback engine."""
39+
40+
def __init__(
41+
self,
42+
config: Optional[AnimationConfig] = None,
43+
renderer: Optional[AnimationRenderer] = None,
44+
) -> None:
45+
self._config = config or AnimationConfig()
46+
self._renderer = renderer or TerminalRenderer()
47+
self._repository = FileSystemAnimationRepository(self._config)
48+
self._is_playing = False
49+
50+
def discover_animations(self) -> List[str]:
51+
"""Discover all available animation names."""
52+
animation_dirs = self._repository.discover_animations()
53+
return [directory.name for directory in animation_dirs]
54+
55+
def load_animation(self, animation_id: str) -> Optional[AnimationSequence]:
56+
"""Load a specific animation by ID."""
57+
animation_dirs = self._repository.discover_animations()
58+
59+
for directory in animation_dirs:
60+
if directory.name == animation_id:
61+
try:
62+
return self._repository.load_animation(directory)
63+
except Exception as e:
64+
logger.error(f"Failed to load animation {animation_id}: {e}")
65+
return None
66+
67+
logger.warning(f"Animation {animation_id} not found")
68+
return None
69+
70+
def play_animation(
71+
self, animation: AnimationSequence, loop: bool = True
72+
) -> PlaybackResult:
73+
"""Play an animation sequence."""
74+
if not animation.frames:
75+
return ValueError("Animation has no frames")
76+
77+
self._is_playing = True
78+
79+
try:
80+
while self._is_playing:
81+
for frame in animation.frames:
82+
if not self._is_playing:
83+
break
84+
85+
self._renderer.clear_screen()
86+
self._renderer.render_frame(frame)
87+
88+
# Use frame-specific duration or default
89+
duration = frame.duration or animation.metadata.frame_rate
90+
time.sleep(duration)
91+
92+
if not loop:
93+
break
94+
95+
except KeyboardInterrupt:
96+
self.stop_animation()
97+
except Exception as e:
98+
logger.error(f"Error during animation playback: {e}")
99+
return e
100+
101+
return True
102+
103+
def stop_animation(self) -> None:
104+
"""Stop current animation playback."""
105+
self._is_playing = False
106+
107+
def preview_animation(
108+
self, animation: AnimationSequence, frames_to_show: int = 3
109+
) -> PlaybackResult:
110+
"""Preview first few frames of an animation."""
111+
if not animation.frames:
112+
return ValueError("Animation has no frames")
113+
114+
preview_frames = animation.frames[:frames_to_show]
115+
116+
try:
117+
for frame in preview_frames:
118+
self._renderer.clear_screen()
119+
self._renderer.render_frame(frame)
120+
time.sleep(animation.metadata.frame_rate)
121+
122+
except Exception as e:
123+
logger.error(f"Error during animation preview: {e}")
124+
return e
125+
126+
return True
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Interactive menu system for animation selection."""
2+
3+
import sys
4+
import os
5+
import time
6+
import threading
7+
import platform
8+
from typing import List, Optional
9+
10+
from .types import AnimationSequence, AnimationConfig
11+
from .repository import FileSystemAnimationRepository
12+
from pathlib import Path
13+
14+
15+
class InteractiveMenu:
16+
"""Interactive menu with arrow key navigation and animation preview."""
17+
18+
def __init__(self, config: AnimationConfig) -> None:
19+
self.config = config
20+
self.repository = FileSystemAnimationRepository(config)
21+
self.animations: List[AnimationSequence] = []
22+
self.current_index = 0
23+
self.preview_thread: Optional[threading.Thread] = None
24+
self.stop_preview = False
25+
26+
def _get_char(self) -> str:
27+
"""Get a single character from stdin."""
28+
try:
29+
if platform.system() == "Windows":
30+
import msvcrt
31+
32+
char = msvcrt.getch().decode("utf-8")
33+
if char == "\xe0": # Special key prefix on Windows
34+
char += msvcrt.getch().decode("utf-8")
35+
return char
36+
else:
37+
import termios
38+
import tty
39+
40+
fd = sys.stdin.fileno()
41+
old_settings = termios.tcgetattr(fd)
42+
try:
43+
tty.setraw(fd)
44+
char = sys.stdin.read(1)
45+
if char == "\x1b": # ESC sequence
46+
char += sys.stdin.read(2)
47+
return char
48+
finally:
49+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
50+
except Exception:
51+
# Fallback to regular input
52+
return input()
53+
54+
def _clear_screen(self) -> None:
55+
"""Clear the terminal screen."""
56+
os.system("clear" if os.name != "nt" else "cls")
57+
58+
def _load_animations(self) -> bool:
59+
"""Load all available animations."""
60+
animation_dirs = self.repository.discover_animations()
61+
62+
if not animation_dirs:
63+
return False
64+
65+
self.animations = []
66+
for directory in animation_dirs:
67+
try:
68+
animation = self.repository.load_animation(directory)
69+
self.animations.append(animation)
70+
except Exception:
71+
continue # Skip invalid animations
72+
73+
return len(self.animations) > 0
74+
75+
def _stop_current_preview(self) -> None:
76+
"""Stop the current preview animation."""
77+
self.stop_preview = True
78+
if self.preview_thread and self.preview_thread.is_alive():
79+
self.preview_thread.join(timeout=0.5)
80+
81+
def _start_preview(self, animation: AnimationSequence) -> None:
82+
"""Start preview animation in a separate thread."""
83+
self._stop_current_preview()
84+
self.stop_preview = False
85+
86+
def preview_loop():
87+
frame_index = 0
88+
while not self.stop_preview:
89+
if frame_index >= len(animation.frames):
90+
frame_index = 0
91+
92+
frame = animation.frames[frame_index]
93+
94+
# Calculate preview area position
95+
preview_start_line = 6 + len(self.animations)
96+
97+
# Clear preview area more thoroughly - clear 15 lines
98+
for i in range(15):
99+
print(
100+
f"\033[{preview_start_line + i};1H\033[2K", end=""
101+
) # Clear entire line
102+
103+
# Position cursor and show title
104+
print(f"\033[{preview_start_line};1H", end="")
105+
print("Preview:")
106+
107+
# Clean and display frame content
108+
# Remove trailing whitespace and newlines
109+
clean_content = frame.content.rstrip()
110+
frame_lines = clean_content.split("\n") if clean_content else []
111+
112+
# Display each line, ensuring we clear any potential overlap
113+
for i, line in enumerate(frame_lines):
114+
line_num = preview_start_line + 1 + i
115+
# Move to line and clear it completely, then write content
116+
print(f"\033[{line_num};1H\033[2K{line.rstrip()}", end="")
117+
118+
sys.stdout.flush()
119+
120+
frame_index += 1
121+
time.sleep(animation.metadata.frame_rate)
122+
123+
self.preview_thread = threading.Thread(target=preview_loop, daemon=True)
124+
self.preview_thread.start()
125+
126+
def _render_menu(self) -> None:
127+
"""Render the interactive menu."""
128+
self._clear_screen()
129+
130+
print("🎭 \033[1mWelcome to LazyB!\033[0m")
131+
print("Use ↑↓ arrows to select animation, Enter to start, or 'q' to quit")
132+
print("LazyB will press Shift every 3 minutes to keep your apps active.\n")
133+
134+
for i, animation in enumerate(self.animations):
135+
if i == self.current_index:
136+
# Highlighted option
137+
print(f"🎬 \033[1;36m► {animation.metadata.name}\033[0m")
138+
else:
139+
# Normal option
140+
print(f" {animation.metadata.name}")
141+
142+
print()
143+
144+
# Start preview for current selection
145+
if self.animations:
146+
current_animation = self.animations[self.current_index]
147+
self._start_preview(current_animation)
148+
149+
def _handle_input(self) -> Optional[AnimationSequence]:
150+
"""Handle user input and return selected animation or None if quit."""
151+
while True:
152+
char = self._get_char()
153+
154+
if char == "q":
155+
return None
156+
elif char == "\r" or char == "\n": # Enter
157+
return self.animations[self.current_index]
158+
elif char == "\x1b[A" or char == "\xe0H": # Up arrow (Unix/Windows)
159+
self.current_index = (self.current_index - 1) % len(self.animations)
160+
self._render_menu()
161+
elif char == "\x1b[B" or char == "\xe0P": # Down arrow (Unix/Windows)
162+
self.current_index = (self.current_index + 1) % len(self.animations)
163+
self._render_menu()
164+
165+
def show_menu(self) -> Optional[AnimationSequence]:
166+
"""Show interactive menu and return selected animation."""
167+
if not self._load_animations():
168+
print("❌ No animations found!")
169+
return None
170+
171+
try:
172+
self._render_menu()
173+
selected = self._handle_input()
174+
self._stop_current_preview()
175+
return selected
176+
except KeyboardInterrupt:
177+
self._stop_current_preview()
178+
print("\n👋 Goodbye!")
179+
return None
180+
finally:
181+
self._clear_screen()
182+
183+
184+
def create_interactive_menu(
185+
config: Optional[AnimationConfig] = None,
186+
) -> InteractiveMenu:
187+
"""Create and return an interactive menu instance."""
188+
if config is None:
189+
config = AnimationConfig(animation_root_dir=Path("animations"))
190+
return InteractiveMenu(config)

0 commit comments

Comments
 (0)