Merge pull request #2262 from 3b1b/video-work

Refactor scene creation
This commit is contained in:
Grant Sanderson 2024-12-10 09:53:18 -06:00 committed by GitHub
commit 1a14a6bd0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 378 additions and 446 deletions

View file

@ -3,7 +3,7 @@ from manimlib import __version__
import manimlib.config import manimlib.config
import manimlib.logger import manimlib.logger
import manimlib.utils.init_config import manimlib.utils.init_config
from manimlib.reload_manager import reload_manager from manimlib.reload_manager import ReloadManager
def main(): def main():
@ -22,7 +22,7 @@ def main():
manimlib.utils.init_config.init_customization() manimlib.utils.init_config.init_customization()
return return
reload_manager.args = args reload_manager = ReloadManager(args)
reload_manager.run() reload_manager.run()

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from argparse import Namespace
import colour import colour
import importlib import importlib
import inspect import inspect
@ -13,21 +12,15 @@ import yaml
from functools import lru_cache from functools import lru_cache
from manimlib.logger import log from manimlib.logger import log
from manimlib.module_loader import ModuleLoader
from manimlib.utils.dict_ops import merge_dicts_recursively from manimlib.utils.dict_ops import merge_dicts_recursively
from manimlib.utils.init_config import init_customization from manimlib.utils.init_config import init_customization
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
Module = importlib.util.types.ModuleType from argparse import Namespace
from typing import Optional from typing import Optional
# This has to be here instead of in constants.py
# due to its use in creating the camera configuration
FRAME_HEIGHT: float = 8.0
def parse_cli(): def parse_cli():
try: try:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@ -144,12 +137,8 @@ def parse_cli():
) )
parser.add_argument( parser.add_argument(
"-e", "--embed", "-e", "--embed",
nargs="?",
const="",
help="Creates a new file where the line `self.embed` is inserted " + \ help="Creates a new file where the line `self.embed` is inserted " + \
"into the Scenes construct method. " + \ "at the corresponding line number"
"If a string is passed in, the line will be inserted below the " + \
"last line of code including that string."
) )
parser.add_argument( parser.add_argument(
"-r", "--resolution", "-r", "--resolution",
@ -210,93 +199,6 @@ def get_manim_dir():
return os.path.abspath(os.path.join(manimlib_dir, "..")) return os.path.abspath(os.path.join(manimlib_dir, ".."))
def get_indent(line: str):
return len(line) - len(line.lstrip())
def get_module_with_inserted_embed_line(
file_name: str, scene_name: str, line_marker: str, is_during_reload
):
"""
This is hacky, but convenient. When user includes the argument "-e", it will try
to recreate a file that inserts the line `self.embed()` into the end of the scene's
construct method. If there is an argument passed in, it will insert the line after
the last line in the sourcefile which includes that string.
"""
with open(file_name, 'r') as fp:
lines = fp.readlines()
try:
scene_line_number = next(
i for i, line in enumerate(lines)
if line.startswith(f"class {scene_name}")
)
except StopIteration:
log.error(f"No scene {scene_name}")
return
prev_line_num = -1
n_spaces = None
if len(line_marker) == 0:
# Find the end of the construct method
in_construct = False
for index in range(scene_line_number, len(lines) - 1):
line = lines[index]
if line.lstrip().startswith("def construct"):
in_construct = True
n_spaces = get_indent(line) + 4
elif in_construct:
if len(line.strip()) > 0 and get_indent(line) < (n_spaces or 0):
prev_line_num = index - 1
break
if prev_line_num < 0:
prev_line_num = len(lines) - 1
elif line_marker.isdigit():
# Treat the argument as a line number
prev_line_num = int(line_marker) - 1
elif len(line_marker) > 0:
# Treat the argument as a string
try:
prev_line_num = next(
i
for i in range(scene_line_number, len(lines) - 1)
if line_marker in lines[i]
)
except StopIteration:
log.error(f"No lines matching {line_marker}")
sys.exit(2)
# Insert the embed line, rewrite file, then write it back when done
if n_spaces is None:
n_spaces = get_indent(lines[prev_line_num])
inserted_line = " " * n_spaces + "self.embed()\n"
new_lines = list(lines)
new_lines.insert(prev_line_num + 1, inserted_line)
new_file = file_name.replace(".py", "_insert_embed.py")
with open(new_file, 'w') as fp:
fp.writelines(new_lines)
module = ModuleLoader.get_module(new_file, is_during_reload)
# This is to pretend the module imported from the edited lines
# of code actually comes from the original file.
module.__file__ = file_name
os.remove(new_file)
return module
def get_scene_module(args: Namespace) -> Module:
if args.embed is None:
return ModuleLoader.get_module(args.file)
else:
is_reload = args.is_reload if hasattr(args, "is_reload") else False
return get_module_with_inserted_embed_line(
args.file, args.scene_names[0], args.embed, is_reload
)
def load_yaml(file_path: str): def load_yaml(file_path: str):
try: try:
with open(file_path, "r") as file: with open(file_path, "r") as file:
@ -343,8 +245,8 @@ def get_animations_numbers(args: Namespace) -> tuple[int | None, int | None]:
return int(stan), None return int(stan), None
def get_output_directory(args: Namespace, custom_config: dict) -> str: def get_output_directory(args: Namespace, global_config: dict) -> str:
dir_config = custom_config["directories"] dir_config = global_config["directories"]
output_directory = args.video_dir or dir_config["output"] output_directory = args.video_dir or dir_config["output"]
if dir_config["mirror_module_path"] and args.file: if dir_config["mirror_module_path"] and args.file:
to_cut = dir_config["removed_mirror_prefix"] to_cut = dir_config["removed_mirror_prefix"]
@ -356,7 +258,7 @@ def get_output_directory(args: Namespace, custom_config: dict) -> str:
return output_directory return output_directory
def get_file_writer_config(args: Namespace, custom_config: dict) -> dict: def get_file_writer_config(args: Namespace, global_config: dict) -> dict:
result = { result = {
"write_to_movie": not args.skip_animations and args.write_file, "write_to_movie": not args.skip_animations and args.write_file,
"save_last_frame": args.skip_animations and args.write_file, "save_last_frame": args.skip_animations and args.write_file,
@ -364,13 +266,13 @@ def get_file_writer_config(args: Namespace, custom_config: dict) -> dict:
# If -t is passed in (for transparent), this will be RGBA # If -t is passed in (for transparent), this will be RGBA
"png_mode": "RGBA" if args.transparent else "RGB", "png_mode": "RGBA" if args.transparent else "RGB",
"movie_file_extension": get_file_ext(args), "movie_file_extension": get_file_ext(args),
"output_directory": get_output_directory(args, custom_config), "output_directory": get_output_directory(args, global_config),
"file_name": args.file_name, "file_name": args.file_name,
"input_file_path": args.file or "", "input_file_path": args.file or "",
"open_file_upon_completion": args.open, "open_file_upon_completion": args.open,
"show_file_location_upon_completion": args.finder, "show_file_location_upon_completion": args.finder,
"quiet": args.quiet, "quiet": args.quiet,
**custom_config["file_writer_config"], **global_config["file_writer_config"],
} }
if args.vcodec: if args.vcodec:
@ -387,32 +289,11 @@ def get_file_writer_config(args: Namespace, custom_config: dict) -> dict:
return result return result
def get_window_config(args: Namespace, custom_config: dict, camera_config: dict) -> dict: def get_resolution(args: Optional[Namespace] = None, global_config: Optional[dict] = None):
# Default to making window half the screen size args = args or parse_cli()
# but make it full screen if -f is passed in global_config = global_config or get_global_config()
try:
monitors = screeninfo.get_monitors()
except screeninfo.ScreenInfoError:
# Default fallback
monitors = [screeninfo.Monitor(width=1920, height=1080)]
mon_index = custom_config["window_monitor"]
monitor = monitors[min(mon_index, len(monitors) - 1)]
aspect_ratio = camera_config["pixel_width"] / camera_config["pixel_height"]
window_width = monitor.width
if not (args.full_screen or custom_config["full_screen"]):
window_width //= 2
window_height = int(window_width / aspect_ratio)
return dict(size=(window_width, window_height))
camera_resolutions = global_config["camera_resolutions"]
def get_camera_config(args: Optional[Namespace] = None, custom_config: Optional[dict] = None) -> dict:
if args is None:
args = parse_cli()
if custom_config is None:
custom_config = get_global_config()
camera_config = dict()
camera_resolutions = custom_config["camera_resolutions"]
if args.resolution: if args.resolution:
resolution = args.resolution resolution = args.resolution
elif args.low_quality: elif args.low_quality:
@ -426,33 +307,53 @@ def get_camera_config(args: Optional[Namespace] = None, custom_config: Optional[
else: else:
resolution = camera_resolutions[camera_resolutions["default_resolution"]] resolution = camera_resolutions[camera_resolutions["default_resolution"]]
if args.fps:
fps = int(args.fps)
else:
fps = custom_config["fps"]
width_str, height_str = resolution.split("x") width_str, height_str = resolution.split("x")
width = int(width_str) return int(width_str), int(height_str)
height = int(height_str)
camera_config.update({
def get_window_config(args: Namespace, global_config: dict) -> dict:
# Default to making window half the screen size
# but make it full screen if -f is passed in
try:
monitors = screeninfo.get_monitors()
except screeninfo.ScreenInfoError:
# Default fallback
monitors = [screeninfo.Monitor(width=1920, height=1080)]
mon_index = global_config["window_monitor"]
monitor = monitors[min(mon_index, len(monitors) - 1)]
width, height = get_resolution(args, global_config)
aspect_ratio = width / height
window_width = monitor.width
if not (args.full_screen or global_config["full_screen"]):
window_width //= 2
window_height = int(window_width / aspect_ratio)
return dict(size=(window_width, window_height))
def get_camera_config(args: Optional[Namespace] = None, global_config: Optional[dict] = None) -> dict:
args = args or parse_cli()
global_config = global_config or get_global_config()
width, height = get_resolution(args, global_config)
fps = int(args.fps or global_config["fps"])
camera_config = {
"pixel_width": width, "pixel_width": width,
"pixel_height": height, "pixel_height": height,
"frame_config": {
"frame_shape": ((width / height) * FRAME_HEIGHT, FRAME_HEIGHT),
},
"fps": fps, "fps": fps,
}) }
try: try:
bg_color = args.color or custom_config["style"]["background_color"] bg_color = args.color or global_config["style"]["background_color"]
camera_config["background_color"] = colour.Color(bg_color) camera_config["background_color"] = colour.Color(bg_color)
except ValueError as err: except ValueError as err:
log.error("Please use a valid color") log.error("Please use a valid color")
log.error(err) log.error(err)
sys.exit(2) sys.exit(2)
# If rendering a transparent image/move, make sure the # If rendering a transparent image/movie, make sure the
# scene has a background opacity of 0 # scene has a background opacity of 0
if args.transparent: if args.transparent:
camera_config["background_opacity"] = 0 camera_config["background_opacity"] = 0
@ -466,17 +367,15 @@ def get_scene_config(args: Namespace) -> dict:
""" """
global_config = get_global_config() global_config = get_global_config()
camera_config = get_camera_config(args, global_config) camera_config = get_camera_config(args, global_config)
window_config = get_window_config(args, global_config, camera_config) file_writer_config = get_file_writer_config(args, global_config)
start, end = get_animations_numbers(args) start, end = get_animations_numbers(args)
return { return {
"file_writer_config": get_file_writer_config(args, global_config), "file_writer_config": file_writer_config,
"camera_config": camera_config, "camera_config": camera_config,
"window_config": window_config,
"skip_animations": args.skip_animations, "skip_animations": args.skip_animations,
"start_at_animation_number": start, "start_at_animation_number": start,
"end_at_animation_number": end, "end_at_animation_number": end,
"preview": not args.write_file,
"presenter_mode": args.presenter_mode, "presenter_mode": args.presenter_mode,
"leave_progress_bars": args.leave_progress_bars, "leave_progress_bars": args.leave_progress_bars,
"show_animation_progress": args.show_animation_progress, "show_animation_progress": args.show_animation_progress,
@ -486,10 +385,15 @@ def get_scene_config(args: Namespace) -> dict:
def get_run_config(args: Namespace): def get_run_config(args: Namespace):
window_config = get_window_config(args, get_global_config())
return { return {
"module": get_scene_module(args), "file_name": args.file,
"embed_line": int(args.embed) if args.embed is not None else None,
"is_reload": False,
"prerun": args.prerun, "prerun": args.prerun,
"scene_names": args.scene_names, "scene_names": args.scene_names,
"quiet": args.quiet or args.write_all, "quiet": args.quiet or args.write_all,
"write_all": args.write_all, "write_all": args.write_all,
"window_config": window_config,
"show_in_window": not args.write_file
} }

View file

@ -1,8 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from manimlib.config import get_camera_config from manimlib.config import get_resolution
from manimlib.config import FRAME_HEIGHT
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
@ -13,18 +12,19 @@ if TYPE_CHECKING:
# TODO, it feels a bit unprincipled to have some global constants # TODO, it feels a bit unprincipled to have some global constants
# depend on the output of this function, and for all that configuration # depend on the output of this function, and for all that configuration
# code to be run merely upon importing from this file. # code to be run merely upon importing from this file.
CAMERA_CONFIG = get_camera_config() DEFAULT_RESOLUTION: tuple[int, int] = get_resolution()
DEFAULT_PIXEL_WIDTH = DEFAULT_RESOLUTION[0]
DEFAULT_PIXEL_HEIGHT = DEFAULT_RESOLUTION[1]
DEFAULT_FPS: int = 30
# Sizes relevant to default camera frame # Sizes relevant to default camera frame
ASPECT_RATIO: float = CAMERA_CONFIG['pixel_width'] / CAMERA_CONFIG['pixel_height'] ASPECT_RATIO: float = DEFAULT_PIXEL_WIDTH / DEFAULT_PIXEL_HEIGHT
FRAME_HEIGHT: float = 8.0
FRAME_WIDTH: float = FRAME_HEIGHT * ASPECT_RATIO FRAME_WIDTH: float = FRAME_HEIGHT * ASPECT_RATIO
FRAME_SHAPE: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT) FRAME_SHAPE: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT)
FRAME_Y_RADIUS: float = FRAME_HEIGHT / 2 FRAME_Y_RADIUS: float = FRAME_HEIGHT / 2
FRAME_X_RADIUS: float = FRAME_WIDTH / 2 FRAME_X_RADIUS: float = FRAME_WIDTH / 2
DEFAULT_PIXEL_HEIGHT: int = CAMERA_CONFIG['pixel_height']
DEFAULT_PIXEL_WIDTH: int = CAMERA_CONFIG['pixel_width']
DEFAULT_FPS: int = 30
SMALL_BUFF: float = 0.1 SMALL_BUFF: float = 0.1
MED_SMALL_BUFF: float = 0.25 MED_SMALL_BUFF: float = 0.25

View file

@ -58,3 +58,4 @@ camera_resolutions:
fps: 30 fps: 30
embed_exception_mode: "Verbose" embed_exception_mode: "Verbose"
embed_error_sound: False embed_error_sound: False
ignore_manimlib_modules_on_reload: True

View file

@ -1,12 +1,21 @@
from __future__ import annotations
import copy import copy
import inspect import inspect
import sys import sys
from manimlib.module_loader import ModuleLoader
from manimlib.config import get_global_config from manimlib.config import get_global_config
from manimlib.logger import log from manimlib.logger import log
from manimlib.scene.interactive_scene import InteractiveScene from manimlib.scene.interactive_scene import InteractiveScene
from manimlib.scene.scene import Scene from manimlib.scene.scene import Scene
from typing import TYPE_CHECKING
if TYPE_CHECKING:
Module = importlib.util.types.ModuleType
from typing import Optional
class BlankScene(InteractiveScene): class BlankScene(InteractiveScene):
def construct(self): def construct(self):
@ -115,10 +124,60 @@ def get_scene_classes_from_module(module):
] ]
def get_indent(code_lines: list[str], line_number: int) -> str:
"""
Find the indent associated with a given line of python code,
as a string of spaces
"""
# Find most recent non-empty line
try:
line = next(filter(lambda line: line.strip(), code_lines[line_number - 1::-1]))
except StopIteration:
return ""
# Either return its leading spaces, or add for if it ends with colon
n_spaces = len(line) - len(line.lstrip())
if line.endswith(":"):
n_spaces += 4
return n_spaces * " "
def insert_embed_line_to_module(module: Module, line_number: int):
"""
This is hacky, but convenient. When user includes the argument "-e", it will try
to recreate a file that inserts the line `self.embed()` into the end of the scene's
construct method. If there is an argument passed in, it will insert the line after
the last line in the sourcefile which includes that string.
"""
lines = inspect.getsource(module).splitlines()
# Add the relevant embed line to the code
indent = get_indent(lines, line_number)
lines.insert(line_number, indent + "self.embed()")
new_code = "\n".join(lines)
# Execute the code, which presumably redefines the user's
# scene to include this embed line, within the relevant module.
code_object = compile(new_code, module.__name__, 'exec')
exec(code_object, module.__dict__)
def get_scene_module(file_name: Optional[str], embed_line: Optional[int], is_reload: bool = False) -> Module:
module = ModuleLoader.get_module(file_name, is_reload)
if embed_line:
insert_embed_line_to_module(module, embed_line)
return module
def main(scene_config, run_config): def main(scene_config, run_config):
if run_config["module"] is None: module = get_scene_module(
run_config["file_name"],
run_config["embed_line"],
run_config["is_reload"]
)
if module is None:
# If no module was passed in, just play the blank scene # If no module was passed in, just play the blank scene
return [BlankScene(**scene_config)] return [BlankScene(**scene_config)]
all_scene_classes = get_scene_classes_from_module(run_config["module"]) all_scene_classes = get_scene_classes_from_module(module)
return get_scenes_to_render(all_scene_classes, scene_config, run_config) return get_scenes_to_render(all_scene_classes, scene_config, run_config)

View file

@ -11,4 +11,4 @@ logging.basicConfig(
) )
log = logging.getLogger("manimgl") log = logging.getLogger("manimgl")
log.setLevel("DEBUG") log.setLevel("WARNING")

View file

@ -715,21 +715,6 @@ class Mobject(object):
self.become(self.saved_state) self.become(self.saved_state)
return self return self
def save_to_file(self, file_path: str) -> Self:
with open(file_path, "wb") as fp:
fp.write(self.serialize())
log.info(f"Saved mobject to {file_path}")
return self
@staticmethod
def load(file_path) -> Mobject:
if not os.path.exists(file_path):
log.error(f"No file found at {file_path}")
sys.exit(2)
with open(file_path, "rb") as fp:
mobject = pickle.load(fp)
return mobject
def become(self, mobject: Mobject, match_updaters=False) -> Self: def become(self, mobject: Mobject, match_updaters=False) -> Self:
""" """
Edit all data and submobjects to be idential Edit all data and submobjects to be idential

View file

@ -6,12 +6,11 @@ import os
import sys import sys
import sysconfig import sysconfig
from manimlib.config import get_global_config
from manimlib.logger import log from manimlib.logger import log
Module = importlib.util.types.ModuleType Module = importlib.util.types.ModuleType
IGNORE_MANIMLIB_MODULES = True
class ModuleLoader: class ModuleLoader:
""" """
@ -76,10 +75,7 @@ class ModuleLoader:
builtins.__import__ = tracked_import builtins.__import__ = tracked_import
try: try:
# Remove the "_insert_embed" suffix from the module name
module_name = module.__name__ module_name = module.__name__
if module.__name__.endswith("_insert_embed"):
module_name = module_name[:-13]
log.debug('Reloading module "%s"', module_name) log.debug('Reloading module "%s"', module_name)
spec.loader.exec_module(module) spec.loader.exec_module(module)
@ -146,7 +142,8 @@ class ModuleLoader:
Only user-defined modules are reloaded, see `is_user_defined_module()`. Only user-defined modules are reloaded, see `is_user_defined_module()`.
""" """
if IGNORE_MANIMLIB_MODULES and module.__name__.startswith("manimlib"): ignore_manimlib_modules = get_global_config()["ignore_manimlib_modules_on_reload"]
if ignore_manimlib_modules and module.__name__.startswith("manimlib"):
return return
if not hasattr(module, "__dict__"): if not hasattr(module, "__dict__"):

View file

@ -1,7 +1,20 @@
from __future__ import annotations
from typing import Any from typing import Any
from IPython.terminal.embed import KillEmbedded from IPython.terminal.embed import KillEmbedded
import manimlib.config
import manimlib.extract_scene
from manimlib.window import Window
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from argparse import Namespace
class ReloadManager: class ReloadManager:
""" """
Manages the loading and running of scenes and is called directly from the Manages the loading and running of scenes and is called directly from the
@ -12,20 +25,17 @@ class ReloadManager:
command in the IPython shell. command in the IPython shell.
""" """
args: Any = None
scenes: list[Any] = []
window = None window = None
# The line number to load the scene from when reloading
start_at_line = None
is_reload = False is_reload = False
def __init__(self, cli_args: Namespace):
self.args = cli_args
def set_new_start_at_line(self, start_at_line): def set_new_start_at_line(self, start_at_line):
""" """
Sets/Updates the line number to load the scene from when reloading. Sets/Updates the line number to load the scene from when reloading.
""" """
self.start_at_line = start_at_line self.args.embed = str(start_at_line)
def run(self): def run(self):
""" """
@ -34,61 +44,43 @@ class ReloadManager:
while True: while True:
try: try:
# blocking call since a scene will init an IPython shell() # blocking call since a scene will init an IPython shell()
self.retrieve_scenes_and_run(self.start_at_line) self.retrieve_scenes_and_run()
return return
except KillEmbedded: except KillEmbedded:
# Requested via the `exit_raise` IPython runline magic # Requested via the `exit_raise` IPython runline magic
# by means of our scene.reload() command # by means of our scene.reload() command
for scene in self.scenes: self.note_reload()
scene.tear_down()
self.scenes = []
self.is_reload = True
except KeyboardInterrupt: except KeyboardInterrupt:
break break
def retrieve_scenes_and_run(self, overwrite_start_at_line: int | None = None): def note_reload(self):
self.is_reload = True
print(" ".join([
"Reloading interactive session for",
f"\033[96m{self.args.scene_names[0]}\033[0m",
f"at line \033[96m{self.args.embed}\033[0m"
]))
def retrieve_scenes_and_run(self):
""" """
Creates a new configuration based on the CLI args and runs the scenes. Creates a new configuration based on the CLI args and runs the scenes.
""" """
import manimlib.config
import manimlib.extract_scene
# Args
if self.args is None:
raise RuntimeError("Fatal error: No args were passed to the ReloadManager")
if overwrite_start_at_line is not None:
self.args.embed = str(overwrite_start_at_line)
# Args to Config # Args to Config
self.args.is_reload = self.is_reload
scene_config = manimlib.config.get_scene_config(self.args) scene_config = manimlib.config.get_scene_config(self.args)
if self.window: scene_config.update(reload_manager=self)
scene_config["existing_window"] = self.window # see scene initialization
run_config = manimlib.config.get_run_config(self.args) run_config = manimlib.config.get_run_config(self.args)
run_config.update(is_reload=self.is_reload)
# Create or reuse window
if run_config["show_in_window"] and not self.window:
self.window = Window(**run_config["window_config"])
scene_config.update(window=self.window)
# Scenes # Scenes
self.scenes = manimlib.extract_scene.main(scene_config, run_config) scenes = manimlib.extract_scene.main(scene_config, run_config)
if len(self.scenes) == 0: if len(scenes) == 0:
print("No scenes found to run") print("No scenes found to run")
return
# Find first available window for scene in scenes:
for scene in self.scenes:
if scene.window is not None:
self.window = scene.window
break
for scene in self.scenes:
if self.args.embed:
print(" ".join([
"Loading interactive session for",
f"\033[96m{self.args.scene_names[0]}\033[0m",
f"in \033[96m{self.args.file}\033[0m",
f"at line \033[96m{self.args.embed}\033[0m"
]))
scene.run() scene.run()
reload_manager = ReloadManager()

View file

@ -460,12 +460,6 @@ class InteractiveScene(Scene):
nudge *= 10 nudge *= 10
self.selection.shift(nudge * vect) self.selection.shift(nudge * vect)
def save_selection_to_file(self):
if len(self.selection) == 1:
self.save_mobject_to_file(self.selection[0])
else:
self.save_mobject_to_file(self.selection)
# Key actions # Key actions
def on_key_press(self, symbol: int, modifiers: int) -> None: def on_key_press(self, symbol: int, modifiers: int) -> None:
super().on_key_press(symbol, modifiers) super().on_key_press(symbol, modifiers)
@ -503,8 +497,6 @@ class InteractiveScene(Scene):
self.ungroup_selection() self.ungroup_selection()
elif char == "t" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)): elif char == "t" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.toggle_selection_mode() self.toggle_selection_mode()
elif char == "s" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.save_selection_to_file()
elif char == "d" and (modifiers & PygletWindowKeys.MOD_SHIFT): elif char == "d" and (modifiers & PygletWindowKeys.MOD_SHIFT):
self.copy_frame_positioning() self.copy_frame_positioning()
elif char == "c" and (modifiers & PygletWindowKeys.MOD_SHIFT): elif char == "c" and (modifiers & PygletWindowKeys.MOD_SHIFT):

View file

@ -1,41 +1,33 @@
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
import inspect
import os import os
import platform import platform
import pyperclip
import random import random
import time import time
import re
from functools import wraps from functools import wraps
from IPython.terminal import pt_inputhooks
from IPython.terminal.embed import InteractiveShellEmbed
from pyglet.window import key as PygletWindowKeys from pyglet.window import key as PygletWindowKeys
import numpy as np import numpy as np
from tqdm.auto import tqdm as ProgressDisplay from tqdm.auto import tqdm as ProgressDisplay
from manimlib.animation.animation import prepare_animation from manimlib.animation.animation import prepare_animation
from manimlib.animation.fading import VFadeInThenOut
from manimlib.camera.camera import Camera from manimlib.camera.camera import Camera
from manimlib.camera.camera_frame import CameraFrame from manimlib.camera.camera_frame import CameraFrame
from manimlib.module_loader import ModuleLoader
from manimlib.constants import ARROW_SYMBOLS from manimlib.constants import ARROW_SYMBOLS
from manimlib.constants import DEFAULT_WAIT_TIME from manimlib.constants import DEFAULT_WAIT_TIME
from manimlib.constants import RED
from manimlib.event_handler import EVENT_DISPATCHER from manimlib.event_handler import EVENT_DISPATCHER
from manimlib.event_handler.event_type import EventType from manimlib.event_handler.event_type import EventType
from manimlib.logger import log from manimlib.logger import log
from manimlib.reload_manager import reload_manager
from manimlib.mobject.frame import FullScreenRectangle
from manimlib.mobject.mobject import _AnimationBuilder from manimlib.mobject.mobject import _AnimationBuilder
from manimlib.mobject.mobject import Group from manimlib.mobject.mobject import Group
from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Mobject
from manimlib.mobject.mobject import Point from manimlib.mobject.mobject import Point
from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.scene.scene_embed import interactive_scene_embed
from manimlib.scene.scene_embed import CheckpointManager
from manimlib.scene.scene_file_writer import SceneFileWriter from manimlib.scene.scene_file_writer import SceneFileWriter
from manimlib.utils.family_ops import extract_mobject_family_members from manimlib.utils.family_ops import extract_mobject_family_members
from manimlib.utils.family_ops import recursive_mobject_remove from manimlib.utils.family_ops import recursive_mobject_remove
@ -45,13 +37,14 @@ from manimlib.window import Window
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable, Iterable, TypeVar from typing import Callable, Iterable, TypeVar, Optional
from manimlib.typing import Vect3 from manimlib.typing import Vect3
T = TypeVar('T') T = TypeVar('T')
from PIL.Image import Image from PIL.Image import Image
from manimlib.reload_manager import ReloadManager
from manimlib.animation.animation import Animation from manimlib.animation.animation import Animation
@ -68,7 +61,6 @@ class Scene(object):
drag_to_pan: bool = True drag_to_pan: bool = True
max_num_saved_states: int = 50 max_num_saved_states: int = 50
default_camera_config: dict = dict() default_camera_config: dict = dict()
default_window_config: dict = dict()
default_file_writer_config: dict = dict() default_file_writer_config: dict = dict()
samples = 0 samples = 0
# Euler angles, in degrees # Euler angles, in degrees
@ -76,7 +68,6 @@ class Scene(object):
def __init__( def __init__(
self, self,
window_config: dict = dict(),
camera_config: dict = dict(), camera_config: dict = dict(),
file_writer_config: dict = dict(), file_writer_config: dict = dict(),
skip_animations: bool = False, skip_animations: bool = False,
@ -84,45 +75,39 @@ class Scene(object):
start_at_animation_number: int | None = None, start_at_animation_number: int | None = None,
end_at_animation_number: int | None = None, end_at_animation_number: int | None = None,
leave_progress_bars: bool = False, leave_progress_bars: bool = False,
preview: bool = True, window: Optional[Window] = None,
reload_manager: Optional[ReloadManager] = None,
presenter_mode: bool = False, presenter_mode: bool = False,
show_animation_progress: bool = False, show_animation_progress: bool = False,
embed_exception_mode: str = "", embed_exception_mode: str = "",
embed_error_sound: bool = False, embed_error_sound: bool = False,
existing_window: Window | None = None,
): ):
self.skip_animations = skip_animations self.skip_animations = skip_animations
self.always_update_mobjects = always_update_mobjects self.always_update_mobjects = always_update_mobjects
self.start_at_animation_number = start_at_animation_number self.start_at_animation_number = start_at_animation_number
self.end_at_animation_number = end_at_animation_number self.end_at_animation_number = end_at_animation_number
self.leave_progress_bars = leave_progress_bars self.leave_progress_bars = leave_progress_bars
self.preview = preview
self.presenter_mode = presenter_mode self.presenter_mode = presenter_mode
self.show_animation_progress = show_animation_progress self.show_animation_progress = show_animation_progress
self.embed_exception_mode = embed_exception_mode self.embed_exception_mode = embed_exception_mode
self.embed_error_sound = embed_error_sound self.embed_error_sound = embed_error_sound
self.reload_manager = reload_manager
self.camera_config = {**self.default_camera_config, **camera_config} self.camera_config = {**self.default_camera_config, **camera_config}
self.window_config = {**self.default_window_config, **window_config}
for config in self.camera_config, self.window_config:
config["samples"] = self.samples
self.file_writer_config = {**self.default_file_writer_config, **file_writer_config} self.file_writer_config = {**self.default_file_writer_config, **file_writer_config}
# Initialize window, if applicable (and reuse window if provided during self.window = window
# reload by means of the ReloadManager) if self.window:
if self.preview: self.window.init_for_scene(self)
if existing_window: # Make sure camera and Pyglet window sync
self.window = existing_window self.camera_config["fps"] = 30
self.window.update_scene(self)
else:
self.window = Window(scene=self, **self.window_config)
self.camera_config["fps"] = 30 # Where's that 30 from?
self.camera_config["window"] = self.window
else:
self.window = None
# Core state of the scene # Core state of the scene
self.camera: Camera = Camera(**self.camera_config) self.camera: Camera = Camera(
window=self.window,
samples=self.samples,
**self.camera_config
)
self.frame: CameraFrame = self.camera.frame self.frame: CameraFrame = self.camera.frame
self.frame.reorient(*self.default_frame_orientation) self.frame.reorient(*self.default_frame_orientation)
self.frame.make_orientation_default() self.frame.make_orientation_default()
@ -136,6 +121,7 @@ class Scene(object):
self.skip_time: float = 0 self.skip_time: float = 0
self.original_skipping_status: bool = self.skip_animations self.original_skipping_status: bool = self.skip_animations
self.checkpoint_states: dict[str, list[tuple[Mobject, Mobject]]] = dict() self.checkpoint_states: dict[str, list[tuple[Mobject, Mobject]]] = dict()
self.checkpoint_manager: CheckpointManager = CheckpointManager()
self.undo_stack = [] self.undo_stack = []
self.redo_stack = [] self.redo_stack = []
@ -220,80 +206,12 @@ class Scene(object):
close_scene_on_exit: bool = True, close_scene_on_exit: bool = True,
show_animation_progress: bool = False, show_animation_progress: bool = False,
) -> None: ) -> None:
if not self.preview: if not self.window:
# Embed is only relevant with a preview # Embed is only relevant for interactive development with a Window
return return
self.stop_skipping()
self.update_frame(force_draw=True)
self.save_state()
self.show_animation_progress = show_animation_progress self.show_animation_progress = show_animation_progress
# Create embedded IPython terminal configured to have access to interactive_scene_embed(self)
# the local namespace of the caller
caller_frame = inspect.currentframe().f_back
module = ModuleLoader.get_module(caller_frame.f_globals["__file__"])
shell = InteractiveShellEmbed(
user_module=module,
display_banner=False,
xmode=self.embed_exception_mode
)
self.shell = shell
# Add a few custom shortcuts to that local namespace
local_ns = dict(caller_frame.f_locals)
local_ns.update(
play=self.play,
wait=self.wait,
add=self.add,
remove=self.remove,
clear=self.clear,
focus=self.focus,
save_state=self.save_state,
reload=self.reload,
undo=self.undo,
redo=self.redo,
i2g=self.i2g,
i2m=self.i2m,
checkpoint_paste=self.checkpoint_paste,
touch=lambda: shell.enable_gui("manim"),
notouch=lambda: shell.enable_gui(None),
)
# Update the shell module with the caller's locals + shortcuts
module.__dict__.update(local_ns)
# Enables gui interactions during the embed
def inputhook(context):
while not context.input_is_ready():
if not self.is_window_closing():
self.update_frame(dt=0)
if self.is_window_closing():
shell.ask_exit()
pt_inputhooks.register("manim", inputhook)
shell.enable_gui("manim")
# Operation to run after each ipython command
def post_cell_func(*args, **kwargs):
if not self.is_window_closing():
self.update_frame(dt=0, force_draw=True)
shell.events.register("post_run_cell", post_cell_func)
# Flash border, and potentially play sound, on exceptions
def custom_exc(shell, etype, evalue, tb, tb_offset=None):
# Show the error don't just swallow it
shell.showtraceback((etype, evalue, tb), tb_offset=tb_offset)
if self.embed_error_sound:
os.system("printf '\a'")
rect = FullScreenRectangle().set_stroke(RED, 30).set_fill(opacity=0)
rect.fix_in_frame()
self.play(VFadeInThenOut(rect, run_time=0.5))
shell.set_custom_exc((Exception,), custom_exc)
# Launch shell
shell()
# End scene when exiting an embed # End scene when exiting an embed
if close_scene_on_exit: if close_scene_on_exit:
@ -616,6 +534,7 @@ class Scene(object):
self.num_plays += 1 self.num_plays += 1
def begin_animations(self, animations: Iterable[Animation]) -> None: def begin_animations(self, animations: Iterable[Animation]) -> None:
all_mobjects = set(self.get_mobject_family_members())
for animation in animations: for animation in animations:
animation.begin() animation.begin()
# Anything animated that's not already in the # Anything animated that's not already in the
@ -623,8 +542,9 @@ class Scene(object):
# animated mobjects that are in the family of # animated mobjects that are in the family of
# those on screen, this can result in a restructuring # those on screen, this can result in a restructuring
# of the scene.mobjects list, which is usually desired. # of the scene.mobjects list, which is usually desired.
if animation.mobject not in self.get_mobject_family_members(): if animation.mobject not in all_mobjects:
self.add(animation.mobject) self.add(animation.mobject)
all_mobjects = all_mobjects.union(animation.mobject.get_family())
def progress_through_animations(self, animations: Iterable[Animation]) -> None: def progress_through_animations(self, animations: Iterable[Animation]) -> None:
last_t = 0 last_t = 0
@ -736,8 +656,6 @@ class Scene(object):
scene_state.restore_scene(self) scene_state.restore_scene(self)
def save_state(self) -> None: def save_state(self) -> None:
if not self.preview:
return
state = self.get_state() state = self.get_state()
if self.undo_stack and state.mobjects_match(self.undo_stack[-1]): if self.undo_stack and state.mobjects_match(self.undo_stack[-1]):
return return
@ -770,37 +688,6 @@ class Scene(object):
revert to the state of the scene the first time this function revert to the state of the scene the first time this function
was called on a block of code starting with that comment. was called on a block of code starting with that comment.
""" """
if self.shell is None or self.window is None:
raise Exception(
"Scene.checkpoint_paste cannot be called outside of " +
"an ipython shell"
)
pasted = pyperclip.paste()
lines = pasted.split("\n")
# Commented lines trigger saved checkpoints
if lines[0].lstrip().startswith("#"):
if lines[0] not in self.checkpoint_states:
self.checkpoint(lines[0])
else:
self.revert_to_checkpoint(lines[0])
# Copied methods of a scene are handled specially
# A bit hacky, yes, but convenient
method_pattern = r"^def\s+([a-zA-Z_]\w*)\s*\(self.*\):"
method_names = re.findall(method_pattern ,lines[0].strip())
if method_names:
method_name = method_names[0]
indent = " " * lines[0].index(lines[0].strip())
pasted = "\n".join([
# Remove self from function signature
re.sub(r"self(,\s*)?", "", lines[0]),
*lines[1:],
# Attach to scene via self.func_name = func_name
f"{indent}self.{method_name} = {method_name}"
])
# Keep track of skipping and progress bar status # Keep track of skipping and progress bar status
self.skip_animations = skip self.skip_animations = skip
@ -811,7 +698,7 @@ class Scene(object):
self.camera.use_window_fbo(False) self.camera.use_window_fbo(False)
self.file_writer.begin_insert() self.file_writer.begin_insert()
self.shell.run_cell(pasted) self.checkpoint_manager.checkpoint_paste(self)
if record: if record:
self.file_writer.end_insert() self.file_writer.end_insert()
@ -820,37 +707,8 @@ class Scene(object):
self.stop_skipping() self.stop_skipping()
self.show_animation_progress = prev_progress self.show_animation_progress = prev_progress
def checkpoint(self, key: str):
self.checkpoint_states[key] = self.get_state()
def revert_to_checkpoint(self, key: str):
if key not in self.checkpoint_states:
log.error(f"No checkpoint at {key}")
return
all_keys = list(self.checkpoint_states.keys())
index = all_keys.index(key)
for later_key in all_keys[index + 1:]:
self.checkpoint_states.pop(later_key)
self.restore_state(self.checkpoint_states[key])
def clear_checkpoints(self): def clear_checkpoints(self):
self.checkpoint_states = dict() self.checkpoint_manager.clear_checkpoints()
def save_mobject_to_file(self, mobject: Mobject, file_path: str | None = None) -> None:
if file_path is None:
file_path = self.file_writer.get_saved_mobject_path(mobject)
if file_path is None:
return
mobject.save_to_file(file_path)
def load_mobject(self, file_name):
if os.path.exists(file_name):
path = file_name
else:
directory = self.file_writer.get_saved_mobject_directory()
path = os.path.join(directory, file_name)
return Mobject.load(path)
def is_window_closing(self): def is_window_closing(self):
return self.window and (self.window.is_closing or self.quit_interaction) return self.window and (self.window.is_closing or self.quit_interaction)
@ -1006,20 +864,23 @@ class Scene(object):
If `start_at_line` is provided, the scene will be reloaded at that line If `start_at_line` is provided, the scene will be reloaded at that line
number. This corresponds to the `linemarker` param of the number. This corresponds to the `linemarker` param of the
`config.get_module_with_inserted_embed_line()` method. `extract_scene.insert_embed_line_to_module()` method.
Before reload, the scene is cleared and the entire state is reset, such Before reload, the scene is cleared and the entire state is reset, such
that we can start from a clean slate. This is taken care of by the that we can start from a clean slate. This is taken care of by the
ReloadManager, which will catch the error raised by the `exit_raise` ReloadManager, which will catch the error raised by the `exit_raise`
magic command that we invoke here. magic command that we invoke here.
Note that we cannot define a custom exception class for this error, Note that we cannot define a custom exception class for this error,
since the IPython kernel will swallow any exception. While we can catch since the IPython kernel will swallow any exception. While we can catch
such an exception in our custom exception handler registered with the such an exception in our custom exception handler registered with the
`set_custom_exc` method, we cannot break out of the IPython shell by `set_custom_exc` method, we cannot break out of the IPython shell by
this means. this means.
""" """
reload_manager.set_new_start_at_line(start_at_line) self.reload_manager.set_new_start_at_line(start_at_line)
self.shell.run_line_magic("exit_raise", "") shell = get_ipython()
if shell:
shell.run_line_magic("exit_raise", "")
def focus(self) -> None: def focus(self) -> None:
""" """

View file

@ -0,0 +1,151 @@
import inspect
import pyperclip
import re
from IPython.terminal import pt_inputhooks
from IPython.terminal.embed import InteractiveShellEmbed
from manimlib.animation.fading import VFadeInThenOut
from manimlib.constants import RED
from manimlib.mobject.mobject import Mobject
from manimlib.mobject.frame import FullScreenRectangle
from manimlib.module_loader import ModuleLoader
def interactive_scene_embed(scene):
scene.stop_skipping()
scene.update_frame(force_draw=True)
shell = get_ipython_shell_for_embedded_scene(scene)
enable_gui(shell, scene)
ensure_frame_update_post_cell(shell, scene)
ensure_flash_on_error(shell, scene)
# Launch shell
shell()
def get_ipython_shell_for_embedded_scene(scene):
"""
Create embedded IPython terminal configured to have access to
the local namespace of the caller
"""
# Triple back should take us to the context in a user's scene definition
# which is calling "self.embed"
caller_frame = inspect.currentframe().f_back.f_back.f_back
# Update the module's namespace to include local variables
module = ModuleLoader.get_module(caller_frame.f_globals["__file__"])
module.__dict__.update(caller_frame.f_locals)
module.__dict__.update(get_shortcuts(scene))
return InteractiveShellEmbed(
user_module=module,
display_banner=False,
xmode=scene.embed_exception_mode
)
def get_shortcuts(scene):
"""
A few custom shortcuts useful to have in the interactive shell namespace
"""
return dict(
play=scene.play,
wait=scene.wait,
add=scene.add,
remove=scene.remove,
clear=scene.clear,
focus=scene.focus,
save_state=scene.save_state,
reload=scene.reload,
undo=scene.undo,
redo=scene.redo,
i2g=scene.i2g,
i2m=scene.i2m,
checkpoint_paste=scene.checkpoint_paste,
)
def enable_gui(shell, scene):
"""Enables gui interactions during the embed"""
def inputhook(context):
while not context.input_is_ready():
if not scene.is_window_closing():
scene.update_frame(dt=0)
if scene.is_window_closing():
shell.ask_exit()
pt_inputhooks.register("manim", inputhook)
shell.enable_gui("manim")
def ensure_frame_update_post_cell(shell, scene):
"""Ensure the scene updates its frame after each ipython cell"""
def post_cell_func(*args, **kwargs):
if not scene.is_window_closing():
scene.update_frame(dt=0, force_draw=True)
shell.events.register("post_run_cell", post_cell_func)
def ensure_flash_on_error(shell, scene):
"""Flash border, and potentially play sound, on exceptions"""
def custom_exc(shell, etype, evalue, tb, tb_offset=None):
# Show the error don't just swallow it
shell.showtraceback((etype, evalue, tb), tb_offset=tb_offset)
if scene.embed_error_sound:
os.system("printf '\a'")
rect = FullScreenRectangle().set_stroke(RED, 30).set_fill(opacity=0)
rect.fix_in_frame()
scene.play(VFadeInThenOut(rect, run_time=0.5))
shell.set_custom_exc((Exception,), custom_exc)
class CheckpointManager:
checkpoint_states: dict[str, list[tuple[Mobject, Mobject]]] = dict()
def checkpoint_paste(self, scene):
"""
Used during interactive development to run (or re-run)
a block of scene code.
If the copied selection starts with a comment, this will
revert to the state of the scene the first time this function
was called on a block of code starting with that comment.
"""
shell = get_ipython()
if shell is None:
return
code_string = pyperclip.paste()
checkpoint_key = self.get_leading_comment(code_string)
self.handle_checkpoint_key(scene, checkpoint_key)
shell.run_cell(code_string)
@staticmethod
def get_leading_comment(code_string: str):
leading_line = code_string.partition("\n")[0].lstrip()
if leading_line.startswith("#"):
return leading_line
return None
def handle_checkpoint_key(self, scene, key: str):
if key is None:
return
elif key in self.checkpoint_states:
# Revert to checkpoint
scene.restore_state(self.checkpoint_states[key])
# Clear out any saved states that show up later
all_keys = list(self.checkpoint_states.keys())
index = all_keys.index(key)
for later_key in all_keys[index + 1:]:
self.checkpoint_states.pop(later_key)
else:
self.checkpoint_states[key] = scene.get_state()
def clear_checkpoints(self):
self.checkpoint_states = dict()

View file

@ -146,39 +146,6 @@ class SceneFileWriter(object):
def get_movie_file_path(self) -> str: def get_movie_file_path(self) -> str:
return self.movie_file_path return self.movie_file_path
def get_saved_mobject_directory(self) -> str:
return guarantee_existence(self.saved_mobject_directory)
def get_saved_mobject_path(self, mobject: Mobject) -> str | None:
directory = self.get_saved_mobject_directory()
files = os.listdir(directory)
default_name = str(mobject) + "_0.mob"
index = 0
while default_name in files:
default_name = default_name.replace(str(index), str(index + 1))
index += 1
if platform.system() == 'Darwin':
cmds = [
"osascript", "-e",
f"""
set chosenfile to (choose file name default name "{default_name}" default location "{directory}")
POSIX path of chosenfile
""",
]
process = sp.Popen(cmds, stdout=sp.PIPE)
file_path = process.stdout.read().decode("utf-8").split("\n")[0]
if not file_path:
return
else:
user_name = input(f"Enter mobject file name (default is {default_name}): ")
file_path = os.path.join(directory, user_name or default_name)
if os.path.exists(file_path) or os.path.exists(file_path + ".mob"):
if input(f"{file_path} already exists. Overwrite (y/n)? ") != "y":
return
if not file_path.endswith(".mob"):
file_path = file_path + ".mob"
return file_path
# Sound # Sound
def init_audio(self) -> None: def init_audio(self) -> None:
self.includes_sound: bool = False self.includes_sound: bool = False

View file

@ -14,7 +14,7 @@ from manimlib.constants import FRAME_SHAPE
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable, TypeVar from typing import Callable, TypeVar, Optional
from manimlib.scene.scene import Scene from manimlib.scene.scene import Scene
T = TypeVar("T") T = TypeVar("T")
@ -29,21 +29,22 @@ class Window(PygletWindow):
def __init__( def __init__(
self, self,
scene: Scene, scene: Optional[Scene] = None,
size: tuple[int, int] = (1280, 720), size: tuple[int, int] = (1280, 720),
samples: int = 0 samples: int = 0
): ):
scene.window = self
super().__init__(size=size, samples=samples) super().__init__(size=size, samples=samples)
self.scene = scene
self.default_size = size self.default_size = size
self.default_position = self.find_initial_position(size) self.default_position = self.find_initial_position(size)
self.pressed_keys = set() self.pressed_keys = set()
self.size = size self.size = size
self.update_scene(scene) if self.scene:
self.init_for_scene(scene)
def update_scene(self, scene: Scene): def init_for_scene(self, scene: Scene):
""" """
Resets the state and updates the scene associated to this window. Resets the state and updates the scene associated to this window.
@ -114,7 +115,7 @@ class Window(PygletWindow):
py: int, py: int,
relative: bool = False relative: bool = False
) -> np.ndarray: ) -> np.ndarray:
if not hasattr(self.scene, "frame"): if self.scene is None or not hasattr(self.scene, "frame"):
return np.zeros(3) return np.zeros(3)
pixel_shape = np.array(self.size) pixel_shape = np.array(self.size)
@ -145,6 +146,8 @@ class Window(PygletWindow):
@note_undrawn_event @note_undrawn_event
def on_mouse_motion(self, x: int, y: int, dx: int, dy: int) -> None: def on_mouse_motion(self, x: int, y: int, dx: int, dy: int) -> None:
super().on_mouse_motion(x, y, dx, dy) super().on_mouse_motion(x, y, dx, dy)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y) point = self.pixel_coords_to_space_coords(x, y)
d_point = self.pixel_coords_to_space_coords(dx, dy, relative=True) d_point = self.pixel_coords_to_space_coords(dx, dy, relative=True)
self.scene.on_mouse_motion(point, d_point) self.scene.on_mouse_motion(point, d_point)
@ -152,6 +155,8 @@ class Window(PygletWindow):
@note_undrawn_event @note_undrawn_event
def on_mouse_drag(self, x: int, y: int, dx: int, dy: int, buttons: int, modifiers: int) -> None: def on_mouse_drag(self, x: int, y: int, dx: int, dy: int, buttons: int, modifiers: int) -> None:
super().on_mouse_drag(x, y, dx, dy, buttons, modifiers) super().on_mouse_drag(x, y, dx, dy, buttons, modifiers)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y) point = self.pixel_coords_to_space_coords(x, y)
d_point = self.pixel_coords_to_space_coords(dx, dy, relative=True) d_point = self.pixel_coords_to_space_coords(dx, dy, relative=True)
self.scene.on_mouse_drag(point, d_point, buttons, modifiers) self.scene.on_mouse_drag(point, d_point, buttons, modifiers)
@ -159,18 +164,24 @@ class Window(PygletWindow):
@note_undrawn_event @note_undrawn_event
def on_mouse_press(self, x: int, y: int, button: int, mods: int) -> None: def on_mouse_press(self, x: int, y: int, button: int, mods: int) -> None:
super().on_mouse_press(x, y, button, mods) super().on_mouse_press(x, y, button, mods)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y) point = self.pixel_coords_to_space_coords(x, y)
self.scene.on_mouse_press(point, button, mods) self.scene.on_mouse_press(point, button, mods)
@note_undrawn_event @note_undrawn_event
def on_mouse_release(self, x: int, y: int, button: int, mods: int) -> None: def on_mouse_release(self, x: int, y: int, button: int, mods: int) -> None:
super().on_mouse_release(x, y, button, mods) super().on_mouse_release(x, y, button, mods)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y) point = self.pixel_coords_to_space_coords(x, y)
self.scene.on_mouse_release(point, button, mods) self.scene.on_mouse_release(point, button, mods)
@note_undrawn_event @note_undrawn_event
def on_mouse_scroll(self, x: int, y: int, x_offset: float, y_offset: float) -> None: def on_mouse_scroll(self, x: int, y: int, x_offset: float, y_offset: float) -> None:
super().on_mouse_scroll(x, y, x_offset, y_offset) super().on_mouse_scroll(x, y, x_offset, y_offset)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y) point = self.pixel_coords_to_space_coords(x, y)
offset = self.pixel_coords_to_space_coords(x_offset, y_offset, relative=True) offset = self.pixel_coords_to_space_coords(x_offset, y_offset, relative=True)
self.scene.on_mouse_scroll(point, offset, x_offset, y_offset) self.scene.on_mouse_scroll(point, offset, x_offset, y_offset)
@ -179,32 +190,44 @@ class Window(PygletWindow):
def on_key_press(self, symbol: int, modifiers: int) -> None: def on_key_press(self, symbol: int, modifiers: int) -> None:
self.pressed_keys.add(symbol) # Modifiers? self.pressed_keys.add(symbol) # Modifiers?
super().on_key_press(symbol, modifiers) super().on_key_press(symbol, modifiers)
if not self.scene:
return
self.scene.on_key_press(symbol, modifiers) self.scene.on_key_press(symbol, modifiers)
@note_undrawn_event @note_undrawn_event
def on_key_release(self, symbol: int, modifiers: int) -> None: def on_key_release(self, symbol: int, modifiers: int) -> None:
self.pressed_keys.difference_update({symbol}) # Modifiers? self.pressed_keys.difference_update({symbol}) # Modifiers?
super().on_key_release(symbol, modifiers) super().on_key_release(symbol, modifiers)
if not self.scene:
return
self.scene.on_key_release(symbol, modifiers) self.scene.on_key_release(symbol, modifiers)
@note_undrawn_event @note_undrawn_event
def on_resize(self, width: int, height: int) -> None: def on_resize(self, width: int, height: int) -> None:
super().on_resize(width, height) super().on_resize(width, height)
if not self.scene:
return
self.scene.on_resize(width, height) self.scene.on_resize(width, height)
@note_undrawn_event @note_undrawn_event
def on_show(self) -> None: def on_show(self) -> None:
super().on_show() super().on_show()
if not self.scene:
return
self.scene.on_show() self.scene.on_show()
@note_undrawn_event @note_undrawn_event
def on_hide(self) -> None: def on_hide(self) -> None:
super().on_hide() super().on_hide()
if not self.scene:
return
self.scene.on_hide() self.scene.on_hide()
@note_undrawn_event @note_undrawn_event
def on_close(self) -> None: def on_close(self) -> None:
super().on_close() super().on_close()
if not self.scene:
return
self.scene.on_close() self.scene.on_close()
def is_key_pressed(self, symbol: int) -> bool: def is_key_pressed(self, symbol: int) -> bool: