Skip to content

Commit 5f53b10

Browse files
Fix temporal effects to perform continuous looping animations
- FadeInEffect: Now loops fade in/out and only affects opacity - WipeEffect: Enabled looping by default for continuous wipe animations - StaggerEffect: Added looping for continuous staggered reveals
1 parent 30fbdf9 commit 5f53b10

2 files changed

Lines changed: 70 additions & 94 deletions

File tree

bangen/effects/temporal.py

Lines changed: 69 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -32,35 +32,32 @@ def precompute(self, banner) -> None:
3232
self._total_chars = len(self._full_text)
3333

3434
def apply(self, lines: list[str], t: float) -> list[str]:
35-
total_chars = self._total_chars
36-
if total_chars == 0:
35+
if self._total_chars == 0:
3736
return lines
3837

39-
typing_time = total_chars / (
40-
self.chars_per_second * max(self.config.speed, 0.1)
41-
)
38+
speed = max(self.config.speed, 0.1)
39+
t = t * speed
40+
41+
typing_time = self._total_chars / self.chars_per_second
4242
cycle_time = typing_time + self.pause
4343

4444
if self.loop:
45-
t = t % max(cycle_time, 0.001)
45+
t = t % cycle_time
4646
else:
4747
t = min(t, typing_time)
4848

49-
if t >= typing_time:
50-
revealed = total_chars
51-
else:
52-
revealed = int((t * self.chars_per_second * self.config.speed) + 0.5)
49+
revealed = min(self._total_chars, int(t * self.chars_per_second))
5350

54-
visible = self._full_text[:revealed]
55-
split = visible.split("\n")
51+
visible = self._full_text[:revealed].split("\n")
5652

5753
result: list[str] = []
5854
for row, line in enumerate(lines):
59-
if row < len(split):
60-
current = split[row]
61-
result.append(current + (" " * max(0, len(line) - len(current))))
55+
if row < len(visible):
56+
current = visible[row]
57+
result.append(current + " " * max(0, len(line) - len(current)))
6258
else:
6359
result.append(" " * len(line))
60+
6461
return result
6562

6663

@@ -71,32 +68,25 @@ class FadeInEffect(Effect):
7168
def name(self) -> str:
7269
return "fade_in"
7370

71+
def _alpha(self, t: float) -> float:
72+
speed = max(self.config.speed, 0.1)
73+
progress = (t * speed) % 2.0
74+
if progress > 1.0:
75+
progress = 2.0 - progress
76+
return progress
77+
7478
def apply(self, lines: list[str], t: float) -> list[str]:
7579
return lines
7680

7781
def brightness(
78-
self,
79-
t: float,
80-
*,
81-
row: int,
82-
col: int,
83-
char: str,
84-
lines: list[str],
82+
self, t: float, *, row: int, col: int, char: str, lines: list[str]
8583
) -> float:
86-
del row, col, char, lines
87-
return clamp(t * self.config.speed * 0.9, 0.0, 1.0)
84+
return 1.0
8885

8986
def opacity(
90-
self,
91-
t: float,
92-
*,
93-
row: int,
94-
col: int,
95-
char: str,
96-
lines: list[str],
87+
self, t: float, *, row: int, col: int, char: str, lines: list[str]
9788
) -> float:
98-
del row, col, char, lines
99-
return clamp(t * self.config.speed * 0.9, 0.0, 1.0)
89+
return self._alpha(t)
10090

10191

10292
class WipeEffect(Effect):
@@ -106,7 +96,7 @@ def __init__(
10696
self,
10797
config=None,
10898
direction: str = "horizontal",
109-
loop: bool = False,
99+
loop: bool = True,
110100
**_: object,
111101
) -> None:
112102
super().__init__(config)
@@ -119,69 +109,42 @@ def name(self) -> str:
119109

120110
def precompute(self, banner) -> None:
121111
super().precompute(banner)
122-
# Compute the bounding box of visible (non-space) characters so the wipe
123-
# doesn't spend most of its time "revealing" indentation.
124-
min_row = None
125-
max_row = None
126-
min_col = None
127-
max_col = None
128-
for row, line in enumerate(self._base_lines):
129-
if not line:
130-
continue
131-
for col, ch in enumerate(line):
132-
if ch == " ":
133-
continue
134-
if min_row is None or row < min_row:
135-
min_row = row
136-
if max_row is None or row > max_row:
137-
max_row = row
138-
if min_col is None or col < min_col:
139-
min_col = col
140-
if max_col is None or col > max_col:
141-
max_col = col
142-
143-
self._min_row = min_row if min_row is not None else 0
144-
self._max_row = (
145-
max_row if max_row is not None else max(0, len(self._base_lines) - 1)
146-
)
147-
self._min_col = min_col if min_col is not None else 0
148-
self._max_col = max_col if max_col is not None else max(0, self._base_width - 1)
112+
113+
rows = len(self._base_lines)
114+
cols = self._base_width
115+
116+
self._min_row = 0
117+
self._max_row = max(0, rows - 1)
118+
self._min_col = 0
119+
self._max_col = max(0, cols - 1)
149120

150121
def apply(self, lines: list[str], t: float) -> list[str]:
151-
progress = t * self.config.speed
122+
speed = max(self.config.speed, 0.1)
123+
t = t * speed
124+
152125
if self.loop:
153-
progress = progress % 1.0
126+
progress = t % 1.0
154127
else:
155-
progress = clamp(progress)
128+
progress = clamp(t, 0.0, 1.0)
156129

157130
if self.direction == "vertical":
158-
start = self._min_row
159-
height = max(1, (self._max_row - self._min_row) + 1)
160-
cutoff = start + round(height * progress)
161-
result: list[str] = []
162-
for row, line in enumerate(lines):
163-
if row < start or row > self._max_row:
164-
result.append(line)
165-
elif row < cutoff:
166-
result.append(line)
167-
else:
168-
result.append(" " * len(line))
169-
return result
170-
171-
horizontal_result: list[str] = []
172-
start = self._min_col
173-
width = max(1, (self._max_col - self._min_col) + 1)
174-
cutoff_col = start + round(width * progress)
131+
height = self._max_row - self._min_row + 1
132+
cutoff = self._min_row + int(height * progress)
133+
134+
return [
135+
line if row <= cutoff else " " * len(line)
136+
for row, line in enumerate(lines)
137+
]
138+
139+
width = self._max_col - self._min_col + 1
140+
cutoff_col = self._min_col + int(width * progress)
141+
142+
result = []
175143
for line in lines:
176-
if not line:
177-
horizontal_result.append(line)
178-
continue
179144
padded = line.ljust(self._base_width)
180-
cutoff = max(0, min(len(padded), cutoff_col))
181-
horizontal_result.append(
182-
padded[:cutoff] + (" " * max(0, len(padded) - cutoff))
183-
)
184-
return horizontal_result
145+
result.append(padded[:cutoff_col] + " " * max(0, len(padded) - cutoff_col))
146+
147+
return result
185148

186149

187150
class StaggerEffect(Effect):
@@ -192,23 +155,36 @@ def __init__(
192155
config=None,
193156
line_delay: float = 0.16,
194157
chars_per_second: float = 120.0,
158+
loop: bool = True,
195159
**_: object,
196160
) -> None:
197161
super().__init__(config)
198162
self.line_delay = line_delay
199163
self.chars_per_second = chars_per_second
164+
self.loop = loop
200165

201166
@property
202167
def name(self) -> str:
203168
return "stagger"
204169

205170
def apply(self, lines: list[str], t: float) -> list[str]:
171+
speed = max(self.config.speed, 0.1)
172+
t = t * speed
173+
174+
if self.loop:
175+
# Calculate cycle time
176+
num_lines = len(lines)
177+
max_line_length = max(len(line) for line in lines) if lines else 0
178+
reveal_time = max_line_length / self.chars_per_second
179+
total_delay = (num_lines - 1) * self.line_delay
180+
cycle_time = total_delay + reveal_time
181+
t = t % cycle_time
182+
206183
result: list[str] = []
207-
cps = self.chars_per_second * max(self.config.speed, 0.1)
208184

209185
for row, line in enumerate(lines):
210-
local_t = max(0.0, t - (row * self.line_delay))
211-
visible = min(len(line), int(local_t * cps))
212-
result.append(line[:visible] + (" " * max(0, len(line) - visible)))
186+
local_t = max(0.0, t - row * self.line_delay)
187+
visible = min(len(line), int(local_t * self.chars_per_second))
188+
result.append(line[:visible] + " " * (len(line) - visible))
213189

214190
return result

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "bangen"
7-
version = "2.2.1"
7+
version = "2.2.2"
88
description = "ASCII banner rendering engine with a tiered effect library, live TUI, and transparent PNG/GIF export."
99
readme = "README.md"
1010
requires-python = ">=3.11"

0 commit comments

Comments
 (0)