Rsan0948
fix: path-traversal guards, undefined-name cleanup, mypy backlog, real-bug fixes
9b70494
Raw
History Blame Contribute Delete
37.2 kB
"""SlideEngine - main engine: manages slides, themes, rendering, navigation, and animations."""
import copy
import io
import logging
import math
import os
from collections.abc import Callable
import pygame
from ._drawing import _draw_gradient_rect, _draw_rounded_rect, _draw_shadow, _parse_color
from ._elements import (
BulletList, CodeBlock, Divider, DynamicGraphic, ImageBox,
ProgressBar, Shape, Slide, TextBox,
)
from ._enums import EntryAnim, Transition
from ._font import _font_cache, _render_text_surface
from ._themes import THEMES
logger = logging.getLogger(__name__)
class SlideEngine:
"""Main engine: manages slides, themes, rendering, navigation, and animations."""
def __init__(self, width=1280, height=720, theme="modern_dark",
fps=60, title="SlideEngine Presentation",
show_progress_bar=True, show_slide_count=True,
loop=False, bg_surface=None, auto_init_pygame=True):
self.width = width
self.height = height
self.theme_name = theme
self.theme = copy.deepcopy(THEMES.get(theme, THEMES["modern_dark"]))
self.fps = fps
self.title = title
self.show_progress_bar = show_progress_bar
self.show_slide_count = show_slide_count
self.loop = loop
self.bg_surface = bg_surface
self.auto_init_pygame = auto_init_pygame
self.slides: list[Slide] = []
self.current_index = 0
self._running = False
self._clock = None
self._screen = None
self._slide_surface = None
self._target_surface = None
self._prev_surface = None
self._transition_progress = 1.0
self._transitioning = False
self._transition_dir = 1
self._bg_cache = None
self._headless = False
self._owns_pygame = False
# external hooks
self.on_slide_change: Callable | None = None
self.on_key: Callable | None = None
# -- public API --
def add_slide(self, slide: Slide):
self.slides.append(slide)
return self
def register_font(self, name, path):
_font_cache.register_font(name, path)
return self
def set_theme(self, name_or_dict):
if isinstance(name_or_dict, str):
self.theme = copy.deepcopy(THEMES.get(name_or_dict, THEMES["modern_dark"]))
self.theme_name = name_or_dict
else:
self.theme = copy.deepcopy(name_or_dict)
self.theme_name = "custom"
self._bg_cache = None
def goto_slide(self, index):
index = max(0, min(index, len(self.slides) - 1))
if index != self.current_index:
self._start_transition(index)
def next_slide(self):
if self.current_index < len(self.slides) - 1:
self._start_transition(self.current_index + 1)
elif self.loop and self.slides:
self._start_transition(0)
def prev_slide(self):
if self.current_index > 0:
self._start_transition(self.current_index - 1)
elif self.loop and self.slides:
self._start_transition(len(self.slides) - 1)
def set_size(self, width, height):
self.width, self.height = int(width), int(height)
if not self._headless and self._screen is not None:
self._screen = pygame.display.set_mode((self.width, self.height), pygame.RESIZABLE)
self._slide_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
self._bg_cache = None
def set_target_surface(self, surface):
self._target_surface = surface
if surface is not None:
self.width, self.height = surface.get_size()
self._slide_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
self._bg_cache = None
def initialize(self, headless=False, size=None, surface=None):
if self.auto_init_pygame and not pygame.get_init():
pygame.init()
self._owns_pygame = True
if not pygame.font.get_init():
pygame.font.init()
self._headless = bool(headless)
if size:
self.width, self.height = int(size[0]), int(size[1])
if surface is not None:
self.set_target_surface(surface)
elif self._headless:
self._target_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
self._slide_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
else:
self._screen = pygame.display.set_mode((self.width, self.height), pygame.RESIZABLE)
pygame.display.set_caption(self.title)
self._target_surface = self._screen
self._slide_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
self._clock = self._clock or pygame.time.Clock()
self._reset_slide_anims()
return self
def shutdown(self):
self._running = False
if self._owns_pygame:
pygame.quit()
self._owns_pygame = False
def handle_action(self, action):
action = str(action or "").lower()
if action in ("next", "next_slide", "forward"):
self.next_slide()
return True
if action in ("prev", "prev_slide", "back"):
self.prev_slide()
return True
if action in ("home", "first"):
self.goto_slide(0)
return True
if action in ("end", "last") and self.slides:
self.goto_slide(len(self.slides) - 1)
return True
return False
def handle_event(self, event):
if event.type == pygame.QUIT:
self._running = False
return True
if event.type == pygame.VIDEORESIZE:
self.set_size(event.w, event.h)
return True
if event.type == pygame.KEYDOWN:
if self.on_key and self.on_key(event):
return True
if event.key in (pygame.K_RIGHT, pygame.K_SPACE, pygame.K_PAGEDOWN):
self.next_slide()
return True
if event.key in (pygame.K_LEFT, pygame.K_BACKSPACE, pygame.K_PAGEUP):
self.prev_slide()
return True
if event.key == pygame.K_HOME:
self.goto_slide(0)
return True
if event.key == pygame.K_END:
self.goto_slide(len(self.slides) - 1)
return True
if event.key == pygame.K_ESCAPE:
self._running = False
return True
if event.key == pygame.K_f and not self._headless and self._screen is not None:
pygame.display.toggle_fullscreen()
return True
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if event.pos[0] > self.width * 0.5:
self.next_slide()
else:
self.prev_slide()
return True
return False
def update(self, dt):
self._update(dt)
def render(self, target_surface=None):
if target_surface is not None:
self.set_target_surface(target_surface)
if self._slide_surface is None:
self._slide_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
target = self._target_surface or self._screen
if target is None:
target = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
self._target_surface = target
self._render_to_target(target)
return target
def run(self):
"""Start the presentation loop. Blocks until window is closed."""
self.initialize(headless=False)
self._running = True
while self._running:
dt = self._clock.tick(self.fps) / 1000.0
for event in pygame.event.get():
self.handle_event(event)
self.update(dt)
self.render(self._screen)
pygame.display.flip()
self.shutdown()
def render_slide_to_surface(self, index=None, width=None, height=None):
"""Render a single slide to a pygame.Surface (for embedding in other apps)."""
if self.auto_init_pygame and not pygame.get_init():
pygame.init()
self._owns_pygame = True
if not pygame.font.get_init():
pygame.font.init()
w = width or self.width
h = height or self.height
idx = index if index is not None else self.current_index
if idx < 0 or idx >= len(self.slides):
surf = pygame.Surface((w, h), pygame.SRCALPHA)
surf.fill((0, 0, 0))
return surf
surf = pygame.Surface((w, h), pygame.SRCALPHA)
self._render_slide(self.slides[idx], surf, w, h, force_full=True)
return surf
def export_slide_image(self, index, path, width=None, height=None):
"""Export a slide as a PNG image."""
surf = self.render_slide_to_surface(index, width, height)
pygame.image.save(surf, path)
# -- internal --
def _start_transition(self, new_index):
old_slide = self.slides[self.current_index] if self.slides else None
if old_slide and old_slide.on_exit:
old_slide.on_exit(self.current_index)
self._prev_surface = self._slide_surface.copy()
self._transition_dir = 1 if new_index > self.current_index else -1
self.current_index = new_index
self._transition_progress = 0.0
self._transitioning = True
self._reset_slide_anims()
new_slide = self.slides[self.current_index]
new_slide._elapsed = 0.0
if new_slide.on_enter:
new_slide.on_enter(self.current_index)
if self.on_slide_change:
self.on_slide_change(self.current_index)
def _reset_slide_anims(self):
if not self.slides:
return
slide = self.slides[self.current_index]
for e in slide.elements:
if e.entry_anim != EntryAnim.NONE:
e._anim_progress = 0.0
e._started = False
else:
e._anim_progress = 1.0
e._started = True
def _update(self, dt):
if not self.slides:
return
slide = self.slides[self.current_index]
slide._elapsed += dt
# update transition
if self._transitioning:
td = slide.transition_duration
self._transition_progress += dt / max(0.01, td)
if self._transition_progress >= 1.0:
self._transition_progress = 1.0
self._transitioning = False
# update element anims
for e in slide.elements:
if e.entry_anim == EntryAnim.NONE:
e._anim_progress = 1.0
continue
if slide._elapsed >= e.anim_delay:
if not e._started:
e._started = True
e._anim_progress += dt / e.anim_duration
if e._anim_progress > 1.0:
e._anim_progress = 1.0
# auto-advance
if slide.auto_advance and slide.duration and slide._elapsed >= slide.duration:
self.next_slide()
def _render_to_target(self, target):
if not self.slides:
target.fill((0, 0, 0))
return
slide = self.slides[self.current_index]
self._slide_surface.fill((0, 0, 0, 0))
self._render_slide(slide, self._slide_surface, self.width, self.height)
if self._transitioning and self._prev_surface:
self._render_transition(slide, target)
else:
target.blit(self._slide_surface, (0, 0))
# progress bar at bottom
if self.show_progress_bar and len(self.slides) > 1:
bar_h = 3
frac = (self.current_index) / max(1, len(self.slides) - 1)
accent = _parse_color(self.theme.get("accent", "#e94560"))
bar_w = int(self.width * frac)
pygame.draw.rect(target, (*accent[:3], 120), (0, self.height - bar_h, self.width, bar_h))
pygame.draw.rect(target, accent, (0, self.height - bar_h, bar_w, bar_h))
def _render_transition(self, slide, target):
t = self._ease_transition(self._transition_progress)
tr = slide.transition
if tr == Transition.FADE:
target.blit(self._prev_surface, (0, 0))
self._slide_surface.set_alpha(int(255 * t))
target.blit(self._slide_surface, (0, 0))
self._slide_surface.set_alpha(255)
elif tr == Transition.SLIDE_LEFT:
d = self._transition_dir
offset = int((1 - t) * self.width)
target.blit(self._prev_surface, (-offset * d, 0))
target.blit(self._slide_surface, (self.width * (1 - t) * (1 if d > 0 else -1), 0))
elif tr == Transition.SLIDE_RIGHT:
d = -self._transition_dir
offset = int((1 - t) * self.width)
target.blit(self._prev_surface, (-offset * d, 0))
target.blit(self._slide_surface, (self.width * (1 - t) * (1 if d > 0 else -1), 0))
elif tr in (Transition.SLIDE_UP, Transition.SLIDE_DOWN):
d = 1 if tr == Transition.SLIDE_UP else -1
offset = int((1 - t) * self.height)
target.blit(self._prev_surface, (0, -offset * d))
target.blit(self._slide_surface, (0, self.height * (1 - t) * d))
else:
target.blit(self._slide_surface, (0, 0))
def _ease_transition(self, t):
return t * t * (3 - 2 * t) # smoothstep
def _render_slide(self, slide, surface, w, h, force_full=False):
# background
self._render_background(slide, surface, w, h)
# accent bar
if slide.accent_bar:
accent = _parse_color(self.theme.get("accent", "#e94560"))
t = slide.accent_bar_thickness
if slide.accent_bar_pos == "top":
pygame.draw.rect(surface, accent, (0, 0, w, t))
elif slide.accent_bar_pos == "bottom":
pygame.draw.rect(surface, accent, (0, h - t, w, t))
elif slide.accent_bar_pos == "left":
pygame.draw.rect(surface, accent, (0, 0, t, h))
elif slide.accent_bar_pos == "right":
pygame.draw.rect(surface, accent, (w - t, 0, t, h))
# sort by z_order
sorted_elems = sorted(slide.elements, key=lambda e: e.z_order)
for elem in sorted_elems:
if not elem.visible:
continue
p = elem._anim_progress if not force_full else 1.0
if p <= 0 and not force_full:
continue
self._render_element(elem, surface, w, h, p, slide)
# slide number
if slide.slide_number and self.show_slide_count:
idx = self.slides.index(slide) if slide in self.slides else 0
txt = f"{idx + 1} / {len(self.slides)}"
nc = _parse_color(self.theme.get("slide_number_color", "#606080"))
font = _font_cache.get(size=max(12, int(h * 0.02)))
ts = font.render(txt, True, nc)
surface.blit(ts, (w - ts.get_width() - 16, h - ts.get_height() - 12))
# footer
if slide.footer_text:
nc = _parse_color(self.theme.get("muted_color", "#808080"))
font = _font_cache.get(size=max(12, int(h * 0.018)))
ts = font.render(slide.footer_text, True, nc)
surface.blit(ts, (16, h - ts.get_height() - 12))
def _render_background(self, slide, surface, w, h):
slide_key = slide.name or id(slide)
bg_key = (slide_key, w, h, str(slide.bg_gradient), str(slide.bg_color), str(slide.bg_image), self.theme_name)
if isinstance(self._bg_cache, tuple) and self._bg_cache[0] == bg_key:
surface.blit(self._bg_cache[1], (0, 0))
return
bg_surf = pygame.Surface((w, h), pygame.SRCALPHA)
grad = slide.bg_gradient
if not grad:
grad = self.theme.get("bg_gradient")
if grad:
colors = grad if isinstance(grad, (list, tuple)) else [grad]
colors = [_parse_color(c) for c in colors]
if len(colors) == 1:
bg_surf.fill(colors[0])
elif len(colors) == 2:
_draw_gradient_rect(bg_surf, (0, 0, w, h), colors[0], colors[1])
else:
seg = len(colors) - 1
seg_h = h // seg
for i in range(seg):
sy = i * seg_h
sh = seg_h if i < seg - 1 else h - sy
_draw_gradient_rect(bg_surf, (0, sy, w, sh), colors[i], colors[i + 1])
elif slide.bg_color:
bg_surf.fill(slide.bg_color)
else:
bg_surf.fill(_parse_color(self.theme.get("bg_gradient", ["#1a1a2e"])[0]
if isinstance(self.theme.get("bg_gradient"), (list, tuple))
else self.theme.get("bg_gradient", "#1a1a2e")))
if slide.bg_image:
try:
if isinstance(slide.bg_image, pygame.Surface):
img = slide.bg_image
else:
img = pygame.image.load(slide.bg_image).convert_alpha()
img = pygame.transform.smoothscale(img, (w, h))
bg_surf.blit(img, (0, 0))
except (OSError, ValueError, RuntimeError, TypeError) as exc:
logger.debug("bg_image load/scale failed: %s", exc)
self._bg_cache = (bg_key, bg_surf.copy())
surface.blit(bg_surf, (0, 0))
def _render_element(self, elem, surface, w, h, progress, slide):
if isinstance(elem, TextBox):
self._render_textbox(elem, surface, w, h, progress)
elif isinstance(elem, ImageBox):
self._render_imagebox(elem, surface, w, h, progress)
elif isinstance(elem, Shape):
self._render_shape(elem, surface, w, h, progress)
elif isinstance(elem, DynamicGraphic):
self._render_dynamic(elem, surface, w, h, progress, slide)
elif isinstance(elem, BulletList):
self._render_bulletlist(elem, surface, w, h, progress)
elif isinstance(elem, CodeBlock):
self._render_codeblock(elem, surface, w, h, progress)
elif isinstance(elem, ProgressBar):
self._render_progressbar(elem, surface, w, h, progress)
elif isinstance(elem, Divider):
self._render_divider(elem, surface, w, h, progress)
def _get_font_for_style(self, style_name, h, size_override=None, bold_override=None, italic=False):
fstyles = self.theme.get("fonts", {})
fdef = fstyles.get(style_name, fstyles.get("body", {"size_ratio": 0.028, "bold": False}))
ratio = fdef.get("size_ratio", 0.028)
if size_override is not None:
if size_override < 1:
ratio = size_override
else:
return _font_cache.get(
name=fdef.get("name"),
size=int(size_override),
bold=bold_override if bold_override is not None else fdef.get("bold", False),
italic=italic,
monospace=fdef.get("monospace", False),
)
sz = max(10, int(h * ratio))
return _font_cache.get(
name=fdef.get("name"),
size=sz,
bold=bold_override if bold_override is not None else fdef.get("bold", False),
italic=italic,
monospace=fdef.get("monospace", False),
)
def _color_for_style(self, style_name):
mapping = {
"title": "title_color", "subtitle": "subtitle_color",
"heading": "title_color", "subheading": "title_color",
"body": "body_color", "caption": "muted_color",
"code": "code_color", "label": "body_color",
}
key = mapping.get(style_name, "body_color")
return _parse_color(self.theme.get(key, "#ffffff"))
def _render_textbox(self, tb: TextBox, surface, w, h, progress):
font = self._get_font_for_style(tb.style, h, tb.font_size, tb.bold, tb.italic)
color = tb.color or self._color_for_style(tb.style)
max_w = int(tb.width * w) if tb.width else 0
# handle typewriter
display_text = tb.text
if tb.entry_anim == EntryAnim.TYPEWRITER and progress < 1.0:
chars = max(0, int(len(tb.text) * progress))
display_text = tb.text[:chars]
# bullet prefix
prefix = ""
indent_px = 0
if tb.bullet:
prefix = tb.bullet + " "
indent_px = int(tb.bullet_indent * w)
ts = _render_text_surface(
prefix + display_text, font, color,
max_width=max_w - indent_px if max_w else 0,
line_spacing=tb.line_spacing, align=tb.align,
underline=tb.underline, max_lines=tb.max_lines,
)
ew, eh = ts.get_width() + indent_px, ts.get_height()
padx, pady = tb.bg_padding
total_w = ew + 2 * padx
total_h = eh + 2 * pady
dx, dy = tb._resolve_rect(total_w, total_h, w, h)
# shadow
if tb.shadow:
_draw_shadow(surface, (dx, dy, total_w, total_h), tb.bg_radius,
self.theme.get("shadow_color", (0, 0, 0, 40)))
# background
if tb.bg_color:
bg_surf = pygame.Surface((total_w, total_h), pygame.SRCALPHA)
_draw_rounded_rect(bg_surf, tb.bg_color, (0, 0, total_w, total_h), tb.bg_radius)
if tb.border_color:
_draw_rounded_rect(bg_surf, tb.border_color, (0, 0, total_w, total_h),
tb.bg_radius, tb.border_width)
surface.blit(bg_surf, (int(dx), int(dy)))
elem_surf = pygame.Surface((ew, eh), pygame.SRCALPHA)
elem_surf.blit(ts, (indent_px, 0))
final_surf, fx, fy = tb._apply_anim(elem_surf, dx + padx, dy + pady, w, h, progress)
surface.blit(final_surf, (int(fx), int(fy)))
def _render_imagebox(self, ib: ImageBox, surface, w, h, progress):
# load/cache
target_w = int(ib.size[0] * w) if ib.size else None
target_h = int(ib.size[1] * h) if ib.size else None
cache_key = (ib.source if isinstance(ib.source, str) else id(ib.source),
target_w, target_h, ib.maintain_aspect, ib.border_radius)
if ib._cached_key != cache_key:
try:
if isinstance(ib.source, pygame.Surface):
img = ib.source.copy()
elif isinstance(ib.source, (bytes, bytearray)):
img = pygame.image.load(io.BytesIO(ib.source)).convert_alpha()
elif isinstance(ib.source, io.BytesIO):
img = pygame.image.load(ib.source).convert_alpha()
elif isinstance(ib.source, str) and os.path.isfile(ib.source):
img = pygame.image.load(ib.source).convert_alpha()
else:
# placeholder
img = pygame.Surface((target_w or 100, target_h or 100), pygame.SRCALPHA)
img.fill((80, 80, 80))
f = _font_cache.get(size=14)
txt = f.render("Image not found", True, (200, 200, 200))
img.blit(txt, (10, 10))
if target_w and target_h:
if ib.maintain_aspect:
iw, ih = img.get_size()
scale = min(target_w / iw, target_h / ih)
nw, nh = int(iw * scale), int(ih * scale)
else:
nw, nh = target_w, target_h
img = pygame.transform.smoothscale(img, (nw, nh))
# rounded corners via mask
if ib.border_radius > 0:
mask = pygame.Surface(img.get_size(), pygame.SRCALPHA)
_draw_rounded_rect(mask, (255, 255, 255, 255),
(0, 0, img.get_width(), img.get_height()),
ib.border_radius)
img.blit(mask, (0, 0), special_flags=pygame.BLEND_RGBA_MIN)
ib._cached_surface = img
ib._cached_key = cache_key
except Exception:
ib._cached_surface = pygame.Surface((50, 50), pygame.SRCALPHA)
ib._cached_surface.fill((80, 80, 80))
ib._cached_key = cache_key
img = ib._cached_surface
iw, ih = img.get_size()
dx, dy = ib._resolve_rect(iw, ih, w, h)
if ib.shadow:
_draw_shadow(surface, (dx, dy, iw, ih), ib.border_radius,
self.theme.get("shadow_color", (0, 0, 0, 50)),
offset=(5, 5), blur=10)
final_surf, fx, fy = ib._apply_anim(img.copy(), dx, dy, w, h, progress)
surface.blit(final_surf, (int(fx), int(fy)))
if ib.border_color:
_draw_rounded_rect(surface, ib.border_color,
(int(fx), int(fy), iw, ih),
ib.border_radius, ib.border_width)
def _render_shape(self, sh: Shape, surface, w, h, progress):
sw, s_h = int(sh.size[0] * w), int(sh.size[1] * h)
dx, dy = sh._resolve_rect(sw, s_h, w, h)
elem_surf = pygame.Surface((sw, s_h), pygame.SRCALPHA)
if sh.shadow:
_draw_shadow(surface, (dx, dy, sw, s_h), sh.radius,
self.theme.get("shadow_color", (0, 0, 0, 40)))
stype = sh.shape_type.lower()
if stype in ("rect", "rectangle"):
if sh.gradient:
gc = [_parse_color(c) for c in sh.gradient]
_draw_gradient_rect(elem_surf, (0, 0, sw, s_h), gc[0], gc[-1])
elif sh.fill:
pygame.draw.rect(elem_surf, sh.color, (0, 0, sw, s_h))
if sh.border_color:
pygame.draw.rect(elem_surf, sh.border_color, (0, 0, sw, s_h), sh.border_width)
elif stype in ("rounded_rect", "roundedrect", "card"):
if sh.gradient:
gc = [_parse_color(c) for c in sh.gradient]
tmp = pygame.Surface((sw, s_h), pygame.SRCALPHA)
_draw_gradient_rect(tmp, (0, 0, sw, s_h), gc[0], gc[-1])
mask = pygame.Surface((sw, s_h), pygame.SRCALPHA)
_draw_rounded_rect(mask, (255, 255, 255, 255), (0, 0, sw, s_h), sh.radius)
tmp.blit(mask, (0, 0), special_flags=pygame.BLEND_RGBA_MIN)
elem_surf.blit(tmp, (0, 0))
elif sh.fill:
_draw_rounded_rect(elem_surf, sh.color, (0, 0, sw, s_h), sh.radius)
if sh.border_color:
_draw_rounded_rect(elem_surf, sh.border_color, (0, 0, sw, s_h),
sh.radius, sh.border_width)
elif stype in ("circle", "ellipse"):
cx, cy = sw // 2, s_h // 2
if stype == "circle":
r = min(cx, cy)
if sh.fill:
pygame.draw.circle(elem_surf, sh.color, (cx, cy), r)
if sh.border_color:
pygame.draw.circle(elem_surf, sh.border_color, (cx, cy), r, sh.border_width)
else:
if sh.fill:
pygame.draw.ellipse(elem_surf, sh.color, (0, 0, sw, s_h))
if sh.border_color:
pygame.draw.ellipse(elem_surf, sh.border_color, (0, 0, sw, s_h), sh.border_width)
elif stype in ("line",):
pts = sh.points or [(0, 0.5), (1, 0.5)]
if len(pts) >= 2:
px = [(int(p[0] * sw), int(p[1] * s_h)) for p in pts]
pygame.draw.lines(elem_surf, sh.color, False, px, sh.border_width or 2)
elif stype in ("polygon",):
pts = sh.points or []
if len(pts) >= 3:
px = [(int(p[0] * sw), int(p[1] * s_h)) for p in pts]
if sh.fill:
pygame.draw.polygon(elem_surf, sh.color, px)
if sh.border_color:
pygame.draw.polygon(elem_surf, sh.border_color, px, sh.border_width)
final_surf, fx, fy = sh._apply_anim(elem_surf, dx, dy, w, h, progress)
surface.blit(final_surf, (int(fx), int(fy)))
def _render_dynamic(self, dg: DynamicGraphic, surface, w, h, progress, slide):
dw, dh = int(dg.size[0] * w), int(dg.size[1] * h)
dx, dy = dg._resolve_rect(dw, dh, w, h)
if dg.render_fn:
try:
elem_surf = pygame.Surface((dw, dh), pygame.SRCALPHA)
dt = 1.0 / max(1, self.fps)
dg.render_fn(elem_surf, (0, 0, dw, dh), dt, slide._elapsed,
self.theme, **dg.user_data)
final_surf, fx, fy = dg._apply_anim(elem_surf, dx, dy, w, h, progress)
surface.blit(final_surf, (int(fx), int(fy)))
except Exception as exc: # noqa: BLE001 — render_fn is user-supplied; failure must not break the slide.
logger.debug("dynamic render_fn raised: %s", exc)
def _render_bulletlist(self, bl: BulletList, surface, w, h, progress):
font = self._get_font_for_style(bl.style, h, bl.font_size, bl.bold)
color = bl.color or self._color_for_style(bl.style)
bullet_color = bl.bullet_color or _parse_color(self.theme.get("bullet_color", "#e94560"))
max_w = int(bl.width * w) if bl.width else int(w * 0.8)
indent_px = int(bl.indent * w)
nested_indent_px = int(bl.nested_indent * w)
lh = int(font.get_height() * bl.item_spacing)
all_items = []
for item in bl.items:
if isinstance(item, (list, tuple)):
all_items.append((0, str(item[0])))
for sub in item[1:]:
all_items.append((1, str(sub)))
else:
all_items.append((0, str(item)))
total_h = lh * len(all_items)
total_w = max_w + indent_px
base_dx, base_dy = bl._resolve_rect(total_w, total_h, w, h)
for i, (level, text) in enumerate(all_items):
item_progress = progress
if bl.stagger_delay > 0 and bl.entry_anim != EntryAnim.NONE:
delay = bl.anim_delay + i * bl.stagger_delay
slide = None
for s in self.slides:
if bl in s.elements:
slide = s
break
if slide:
elapsed = slide._elapsed
if elapsed < delay:
continue
item_progress = min(1.0, (elapsed - delay) / bl.anim_duration)
ep = bl._ease(item_progress)
alpha = int(255 * bl.opacity * ep)
bchar = bl.bullet_char if level == 0 else bl.sub_bullet_char
extra_indent = nested_indent_px * level
bfont = _font_cache.get(size=font.get_height() - 2)
bs = bfont.render(bchar, True, bullet_color)
ts = _render_text_surface(text, font, color,
max_width=max_w - indent_px - extra_indent,
line_spacing=1.2, align="left")
y = base_dy + i * lh
x_bullet = base_dx + extra_indent
x_text = base_dx + indent_px + extra_indent
if alpha < 255:
bs.set_alpha(alpha)
ts.set_alpha(alpha)
ox = 0
if bl.entry_anim == EntryAnim.SLIDE_IN_LEFT:
ox = -(1 - ep) * w * 0.1
elif bl.entry_anim == EntryAnim.SLIDE_IN_RIGHT:
ox = (1 - ep) * w * 0.1
surface.blit(bs, (int(x_bullet + ox), int(y + (lh - bs.get_height()) / 2)))
surface.blit(ts, (int(x_text + ox), int(y + (lh - ts.get_height()) / 2)))
def _render_codeblock(self, cb: CodeBlock, surface, w, h, progress):
mono_font = self._get_font_for_style("code", h, cb.font_size)
bg = cb.bg_color or _parse_color(self.theme.get("code_bg", (15, 15, 30, 240)))
fg = cb.text_color or _parse_color(self.theme.get("code_color", "#80ffaa"))
lines = cb.code.split("\n")
lh = int(mono_font.get_height() * 1.5)
pad = 16
title_h = 0
code_w = int(cb.width * w) if cb.width else int(w * 0.8)
code_h = int(cb.height * h) if cb.height else (lh * len(lines) + 2 * pad + (32 if cb.title else 0))
dx, dy = cb._resolve_rect(code_w, code_h, w, h)
elem_surf = pygame.Surface((code_w, code_h), pygame.SRCALPHA)
if cb.shadow:
_draw_shadow(surface, (dx, dy, code_w, code_h), cb.border_radius,
self.theme.get("shadow_color", (0, 0, 0, 50)))
_draw_rounded_rect(elem_surf, bg, (0, 0, code_w, code_h), cb.border_radius)
# title bar
if cb.title:
title_h = 32
bar_color = tuple(min(255, c + 15) for c in bg[:3]) + (bg[3] if len(bg) > 3 else 255,)
_draw_rounded_rect(elem_surf, bar_color, (0, 0, code_w, title_h + cb.border_radius), cb.border_radius)
_draw_rounded_rect(elem_surf, bg, (0, title_h, code_w, cb.border_radius + 2), 0)
# dots
for ci, cc in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
pygame.draw.circle(elem_surf, cc, (16 + ci * 22, title_h // 2), 6)
# title text
tf = _font_cache.get(size=max(10, int(h * 0.018)), bold=True)
tc = cb.title_color or _parse_color(self.theme.get("muted_color", "#808080"))
tt = tf.render(cb.title, True, tc)
elem_surf.blit(tt, (82, (title_h - tt.get_height()) // 2))
# line numbers + code
ln_w = 0
if cb.line_numbers:
ln_digits = len(str(len(lines)))
ln_w = mono_font.size("0" * (ln_digits + 1))[0] + 8
muted = _parse_color(self.theme.get("muted_color", "#606080"))
for i, line in enumerate(lines):
y = title_h + pad + i * lh
if y + lh > code_h:
break
if cb.line_numbers:
lns = mono_font.render(str(i + 1).rjust(len(str(len(lines)))), True, muted)
elem_surf.blit(lns, (8, y))
ls = mono_font.render(line, True, fg)
elem_surf.blit(ls, (pad + ln_w, y))
final_surf, fx, fy = cb._apply_anim(elem_surf, dx, dy, w, h, progress)
surface.blit(final_surf, (int(fx), int(fy)))
def _render_progressbar(self, pb: ProgressBar, surface, w, h, progress):
pw, ph = int(pb.size[0] * w), int(pb.size[1] * h)
dx, dy = pb._resolve_rect(pw, ph + (20 if pb.label else 0), w, h)
accent = _parse_color(self.theme.get("accent", "#e94560"))
bg = pb.bg_color or _parse_color(self.theme.get("muted_color", "#404060"))
fill = pb.fill_color or accent
actual_val = pb.value * (progress if pb.animated else 1.0)
elem_surf = pygame.Surface((pw, ph), pygame.SRCALPHA)
_draw_rounded_rect(elem_surf, bg, (0, 0, pw, ph), pb.border_radius)
fill_w = int(pw * actual_val)
if fill_w > 0:
_draw_rounded_rect(elem_surf, fill, (0, 0, fill_w, ph), pb.border_radius)
label_surf = None
label_h = 0
if pb.label:
lf = _font_cache.get(size=max(10, int(h * 0.018)), bold=True)
lc = pb.label_color or _parse_color(self.theme.get("body_color", "#d0d0e0"))
label_surf = lf.render(pb.label, True, lc)
label_h = label_surf.get_height() + 4
if label_surf:
surface.blit(label_surf, (int(dx), int(dy)))
final_surf, fx, fy = pb._apply_anim(elem_surf, dx, dy + label_h, w, h, progress)
surface.blit(final_surf, (int(fx), int(fy)))
def _render_divider(self, dv: Divider, surface, w, h, progress):
color = dv.color or _parse_color(self.theme.get("muted_color", "#606080"))
horiz = dv.orientation == "horizontal"
length_px = int(dv.length * (w if horiz else h))
thick = dv.thickness
if horiz:
ew, eh = length_px, thick
else:
ew, eh = thick, length_px
dx, dy = dv._resolve_rect(ew, eh, w, h)
elem_surf = pygame.Surface((ew, eh), pygame.SRCALPHA)
if dv.gradient_fade:
for i in range(length_px):
t = i / max(1, length_px - 1)
fade = math.sin(t * math.pi)
c = (*color[:3], int((color[3] if len(color) > 3 else 255) * fade * dv.opacity))
if horiz:
pygame.draw.line(elem_surf, c, (i, 0), (i, thick - 1))
else:
pygame.draw.line(elem_surf, c, (0, i), (thick - 1, i))
else:
c = (*color[:3], int(255 * dv.opacity))
elem_surf.fill(c)
final_surf, fx, fy = dv._apply_anim(elem_surf, dx, dy, w, h, progress)
surface.blit(final_surf, (int(fx), int(fy)))