Spaces:
Sleeping
Sleeping
| """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))) | |