Skip to content

Commit 6f959cd

Browse files
czeiclaude
andcommitted
feat(transitions): DropFromSky can slide in from any edge (default top)
DropFromSky gains a `direction` parameter -- "top" (default, unchanged), "bottom", "left", or "right" -- so new content can slide onto the screen from any edge instead of only the top. top/bottom animate content.y, left/right animate content.x, each from an off-screen edge to the content's natural position; an unknown direction falls back to "top". Feasibility and the default behaviour are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 977bbf7 commit 6f959cd

2 files changed

Lines changed: 101 additions & 15 deletions

File tree

‎src/scrollkit/effects/transitions.py‎

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -707,26 +707,36 @@ async def _paint_reveal(self, progress):
707707

708708

709709
class DropFromSky:
710-
"""New content slides in from the top: text starts at y=0 and falls to its
711-
natural y position over FALL_FRAMES frames.
710+
"""New content slides into place from an edge — by default from the TOP (text
711+
starts above its row and falls to its natural y over FALL_FRAMES frames). Set
712+
``direction`` to "top" (default), "bottom", "left", or "right" to slide it in
713+
from that edge instead.
712714
713715
Works by hooking into _display_process *before* content.render() via
714-
pre_render_hook(), which temporarily sets content.y to the animated
715-
position. render() then restores y so state never drifts.
716+
pre_render_hook(), which temporarily sets content.x / content.y to the animated
717+
position. render() then restores it so state never drifts.
716718
717-
No OverlayMask, no double-rendering, no layer-order issues. The Labels
718-
are positioned correctly from the first draw call each frame.
719+
No OverlayMask, no double-rendering, no layer-order issues. The Labels are
720+
positioned correctly from the first draw call each frame.
719721
"""
720722

721723
FALL_FRAMES = 10
724+
_DIRECTIONS = ("top", "bottom", "left", "right")
722725

723-
def __init__(self):
726+
def __init__(self, direction="top"):
727+
self.direction = direction if direction in self._DIRECTIONS else "top"
724728
self._frame = 0
729+
self._target_x = None
725730
self._target_y = None
731+
self._w = 0
732+
self._h = 0
726733
self._is_complete = False
727734

728735
async def start(self, display, swap_callback):
736+
self._w = display.width
737+
self._h = display.height
729738
self._frame = 0
739+
self._target_x = None
730740
self._target_y = None
731741
self._is_complete = False
732742

@@ -735,24 +745,36 @@ def is_complete(self):
735745
return self._is_complete
736746

737747
def pre_render_hook(self, content):
738-
"""Set content.y to the animated position BEFORE content.render()."""
748+
"""Set content.x/y to the animated entry position BEFORE content.render()."""
739749
if self._is_complete:
740750
return
741751
if self._target_y is None:
742752
self._target_y = getattr(content, 'y', 0)
743-
# y travels from 0 (frame 0) to _target_y (frame FALL_FRAMES)
753+
self._target_x = getattr(content, 'x', 0)
754+
# 0 -> 1 over the slide; only the axis for `direction` moves, from an
755+
# off-screen edge to the content's natural position.
744756
progress = min(1.0, self._frame / self.FALL_FRAMES)
745-
if hasattr(content, 'y'):
746-
content.y = int(self._target_y * progress)
757+
d = self.direction
758+
if d == "top" or d == "bottom":
759+
start_y = 0 if d == "top" else self._h
760+
if hasattr(content, 'y'):
761+
content.y = int(start_y + (self._target_y - start_y) * progress)
762+
else: # left / right
763+
start_x = -self._w if d == "left" else self._w
764+
if hasattr(content, 'x'):
765+
content.x = int(start_x + (self._target_x - start_x) * progress)
747766

748767
async def render(self, display, content=None):
749-
"""Restore content.y after render and advance frame counter."""
768+
"""Restore content.x/y after render and advance the frame counter."""
750769
if self._is_complete:
751770
return
752-
# Restore y so the content object always carries its natural position.
771+
# Restore whichever axis we animated to its natural value.
753772
if content is not None and self._target_y is not None:
754-
if hasattr(content, 'y'):
755-
content.y = self._target_y
773+
if self.direction == "top" or self.direction == "bottom":
774+
if hasattr(content, 'y'):
775+
content.y = self._target_y
776+
elif hasattr(content, 'x') and self._target_x is not None:
777+
content.x = self._target_x
756778
self._frame += 1
757779
if self._frame > self.FALL_FRAMES:
758780
self._is_complete = True
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""DropFromSky slides new content in from an edge — default top, plus bottom/left/right."""
2+
3+
import os
4+
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
5+
6+
import asyncio
7+
8+
from scrollkit.effects.transitions import DropFromSky
9+
10+
11+
class _Display:
12+
width = 64
13+
height = 32
14+
15+
16+
class _Content:
17+
def __init__(self):
18+
self.x = 10
19+
self.y = 20
20+
21+
22+
def _slide(direction=None):
23+
"""Return (first on-screen position, final position, completed) for a slide."""
24+
display = _Display()
25+
content = _Content()
26+
t = DropFromSky() if direction is None else DropFromSky(direction=direction)
27+
28+
async def run():
29+
await t.start(display, lambda: None)
30+
t.pre_render_hook(content) # frame 0: content sits at the edge
31+
first = (content.x, content.y)
32+
await t.render(display, content)
33+
for _ in range(DropFromSky.FALL_FRAMES + 2):
34+
t.pre_render_hook(content)
35+
await t.render(display, content)
36+
return first, (content.x, content.y), t.is_complete
37+
38+
return asyncio.run(run())
39+
40+
41+
def test_default_is_from_the_top():
42+
first, last, done = _slide() # no direction -> top
43+
assert first == (10, 0) # x natural, y at the top edge
44+
assert last == (10, 20) and done # restored to its natural position
45+
46+
47+
def test_from_bottom_starts_below():
48+
first, last, done = _slide("bottom")
49+
assert first == (10, 32) # y starts at the bottom edge
50+
assert last == (10, 20) and done
51+
52+
53+
def test_from_left_and_right_start_off_screen_horizontally():
54+
fl, last_l, done_l = _slide("left")
55+
assert fl[0] < 0 and fl[1] == 20 # x off the left edge, y natural
56+
assert last_l == (10, 20) and done_l
57+
58+
fr, last_r, done_r = _slide("right")
59+
assert fr[0] >= 64 and fr[1] == 20 # x off the right edge, y natural
60+
assert last_r == (10, 20) and done_r
61+
62+
63+
def test_unknown_direction_falls_back_to_top():
64+
assert DropFromSky(direction="sideways").direction == "top"

0 commit comments

Comments
 (0)