From 155c04e957947bd10a20365bc5345e7a37c8ff32 Mon Sep 17 00:00:00 2001 From: Andrey Zhuchkov <765471+azhuchkov@users.noreply.github.com> Date: Sat, 9 Aug 2025 21:52:18 +0400 Subject: [PATCH 1/5] Replace moderngl_window with direct pyglet window --- manimlib/scene/scene.py | 14 ++++-- manimlib/window.py | 103 +++++++++++++++++++++++++++++----------- requirements.txt | 2 +- setup.cfg | 2 +- 4 files changed, 87 insertions(+), 34 deletions(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 6ca06c3faa..92d23ee2a0 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -11,6 +11,7 @@ import numpy as np from tqdm.auto import tqdm as ProgressDisplay from pyglet.window import key as PygletWindowKeys +import pyglet from manimlib.animation.animation import prepare_animation from manimlib.camera.camera import Camera @@ -242,11 +243,14 @@ def update_frame(self, dt: float = 0, force_draw: bool = False) -> None: if self.is_window_closing(): raise EndScene() - if self.window and dt == 0 and not self.window.has_undrawn_event() and not force_draw: - # In this case, there's no need for new rendering, but we - # shoudl still listen for new events - self.window._window.dispatch_events() - return + if self.window: + pyglet.app.platform_event_loop.dispatch_posted_events() + pyglet.app.platform_event_loop.step(0) + self.window.dispatch_events() + if dt == 0 and not self.window.has_undrawn_event() and not force_draw: + # In this case, there's no need for new rendering, but we + # should still listen for new events + return self.camera.capture(*self.render_groups) diff --git a/manimlib/window.py b/manimlib/window.py index ad2a92d3ee..e68fbed585 100644 --- a/manimlib/window.py +++ b/manimlib/window.py @@ -1,13 +1,14 @@ from __future__ import annotations -import numpy as np - -import moderngl_window as mglw -from moderngl_window.context.pyglet.window import Window as PygletWindow -from moderngl_window.timers.clock import Timer from functools import wraps + +import moderngl +import numpy as np +import pyglet import screeninfo +from pyglet.window import Window as PygletWindow + from manimlib.constants import ASPECT_RATIO from manimlib.constants import FRAME_SHAPE @@ -35,15 +36,37 @@ def __init__( full_screen: bool = False, size: Optional[tuple[int, int]] = None, position: Optional[tuple[int, int]] = None, - samples: int = 0 + samples: int = 0, ): self.scene = scene self.monitor = self.get_monitor(monitor_index) self.default_size = size or self.get_default_size(full_screen) self.default_position = position or self.position_from_string(position_string) self.pressed_keys = set() + self._has_undrawn_event = True - super().__init__(samples=samples) + config = pyglet.gl.Config( + sample_buffers=1 if samples > 0 else 0, + samples=samples, + major_version=self.gl_version[0], + minor_version=self.gl_version[1], + double_buffer=True, + depth_size=24, + stencil_size=8, + ) + + pyglet.app.platform_event_loop.start() + + super().__init__( + width=self.default_size[0], + height=self.default_size[1], + resizable=self.resizable, + vsync=self.vsync, + fullscreen=full_screen, + config=config, + ) + + self.set_mouse_visible(self.cursor) self.to_default_position() if self.scene: @@ -60,18 +83,34 @@ def init_for_scene(self, scene: Scene): self._has_undrawn_event = True self.scene = scene - self.title = str(scene) + self.set_caption(str(scene)) self.init_mgl_context() - self.timer = Timer() - self.config = mglw.WindowConfig(ctx=self.ctx, wnd=self, timer=self.timer) - mglw.activate_context(window=self, ctx=self.ctx) - self.timer.start() - # This line seems to resync the viewport self.on_resize(*self.size) + def init_mgl_context(self) -> None: + self.ctx = moderngl.create_context() + self.ctx.viewport = (0, 0, self.width, self.height) + + # Helper properties for width/height and position + @property + def size(self) -> tuple[int, int]: + return (self.width, self.height) + + @size.setter + def size(self, size: tuple[int, int]) -> None: + self.set_size(*size) + + @property + def position(self) -> tuple[int, int]: + return self.get_location() + + @position.setter + def position(self, pos: tuple[int, int]) -> None: + self.set_location(*pos) + def get_monitor(self, index): try: monitors = screeninfo.get_monitors() @@ -105,8 +144,8 @@ def focus(self): flicker on the window but at least reliably focuses it. It may also offset the window position slightly. """ - self._window.set_visible(False) - self._window.set_visible(True) + self.set_visible(False) + self.set_visible(True) def to_default_position(self): self.position = self.default_position @@ -139,8 +178,14 @@ def pixel_coords_to_space_coords( def has_undrawn_event(self) -> bool: return self._has_undrawn_event - def swap_buffers(self): - super().swap_buffers() + def clear(self, r: float = 0.0, g: float = 0.0, b: float = 0.0, a: float = 1.0) -> None: + if hasattr(self, "ctx"): + self.ctx.clear(r, g, b, a, depth=1.0) + else: + super().clear() + + def swap_buffers(self) -> None: + self.flip() self._has_undrawn_event = False @staticmethod @@ -153,7 +198,6 @@ def wrapper(self, *args, **kwargs): @note_undrawn_event def on_mouse_motion(self, x: int, y: int, dx: int, dy: int) -> None: - super().on_mouse_motion(x, y, dx, dy) if not self.scene: return point = self.pixel_coords_to_space_coords(x, y) @@ -162,7 +206,6 @@ def on_mouse_motion(self, x: int, y: int, dx: int, dy: int) -> None: @note_undrawn_event 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) if not self.scene: return point = self.pixel_coords_to_space_coords(x, y) @@ -171,7 +214,6 @@ def on_mouse_drag(self, x: int, y: int, dx: int, dy: int, buttons: int, modifier @note_undrawn_event def on_mouse_press(self, x: int, y: int, button: int, mods: int) -> None: - super().on_mouse_press(x, y, button, mods) if not self.scene: return point = self.pixel_coords_to_space_coords(x, y) @@ -179,7 +221,6 @@ def on_mouse_press(self, x: int, y: int, button: int, mods: int) -> None: @note_undrawn_event def on_mouse_release(self, x: int, y: int, button: int, mods: int) -> None: - super().on_mouse_release(x, y, button, mods) if not self.scene: return point = self.pixel_coords_to_space_coords(x, y) @@ -187,7 +228,6 @@ def on_mouse_release(self, x: int, y: int, button: int, mods: int) -> None: @note_undrawn_event 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) if not self.scene: return point = self.pixel_coords_to_space_coords(x, y) @@ -196,8 +236,8 @@ def on_mouse_scroll(self, x: int, y: int, x_offset: float, y_offset: float) -> N @note_undrawn_event def on_key_press(self, symbol: int, modifiers: int) -> None: - self.pressed_keys.add(symbol) # Modifiers? super().on_key_press(symbol, modifiers) + self.pressed_keys.add(symbol) # Modifiers? if not self.scene: return self.scene.on_key_press(symbol, modifiers) @@ -205,28 +245,26 @@ def on_key_press(self, symbol: int, modifiers: int) -> None: @note_undrawn_event def on_key_release(self, symbol: int, modifiers: int) -> None: self.pressed_keys.difference_update({symbol}) # Modifiers? - super().on_key_release(symbol, modifiers) if not self.scene: return self.scene.on_key_release(symbol, modifiers) @note_undrawn_event def on_resize(self, width: int, height: int) -> None: - super().on_resize(width, height) + if hasattr(self, 'ctx'): + self.ctx.viewport = (0, 0, width, height) if not self.scene: return self.scene.on_resize(width, height) @note_undrawn_event def on_show(self) -> None: - super().on_show() if not self.scene: return self.scene.on_show() @note_undrawn_event def on_hide(self) -> None: - super().on_hide() if not self.scene: return self.scene.on_hide() @@ -240,3 +278,14 @@ def on_close(self) -> None: def is_key_pressed(self, symbol: int) -> bool: return (symbol in self.pressed_keys) + + # Methods for compatibility with previous window wrapper + @property + def is_closing(self) -> bool: + return self.has_exit + + def destroy(self) -> None: + if hasattr(self, "ctx"): + self.ctx.release() + self.close() + pyglet.app.platform_event_loop.stop() diff --git a/requirements.txt b/requirements.txt index 5629de3a10..7569960b72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,10 +10,10 @@ manimpango>=0.6.0 mapbox-earcut matplotlib moderngl -moderngl_window numpy Pillow pydub +pyglet pygments PyOpenGL pyperclip diff --git a/setup.cfg b/setup.cfg index 5adbee3cec..2fd371a104 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,10 +41,10 @@ install_requires = mapbox-earcut matplotlib moderngl - moderngl_window numpy Pillow pydub + pyglet pygments PyOpenGL pyperclip From 9d212389665dc5ecc903abef78d354d9b988677a Mon Sep 17 00:00:00 2001 From: Andrey Zhuchkov Date: Wed, 6 Aug 2025 11:37:03 +0400 Subject: [PATCH 2/5] Switch to the latest Pyglet version to get macOS overlays --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 7569960b72..26e9c4224b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +pyglet @ git+https://github.com/pyglet/pyglet.git@f08debc addict appdirs audioop-lts; python_version>='3.13' From cb4cc76580f89de0775cff3047abe82471207565 Mon Sep 17 00:00:00 2001 From: Andrey Zhuchkov Date: Sat, 9 Aug 2025 22:03:25 +0400 Subject: [PATCH 3/5] Add support for custom window styles --- manimlib/window.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/manimlib/window.py b/manimlib/window.py index e68fbed585..fb7d84d949 100644 --- a/manimlib/window.py +++ b/manimlib/window.py @@ -34,6 +34,7 @@ def __init__( position_string: str = "UR", monitor_index: int = 1, full_screen: bool = False, + style: str = None, size: Optional[tuple[int, int]] = None, position: Optional[tuple[int, int]] = None, samples: int = 0, @@ -63,10 +64,12 @@ def __init__( resizable=self.resizable, vsync=self.vsync, fullscreen=full_screen, + style=style, config=config, ) self.set_mouse_visible(self.cursor) + self.set_mouse_passthrough(False) self.to_default_position() if self.scene: From b442a041b8bcb77dcdf854d875710608ff5fd625 Mon Sep 17 00:00:00 2001 From: Andrey Zhuchkov Date: Sat, 9 Aug 2025 22:13:56 +0400 Subject: [PATCH 4/5] Add F11 key support for toggling fullscreen mode --- manimlib/window.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/manimlib/window.py b/manimlib/window.py index fb7d84d949..5db95a54ce 100644 --- a/manimlib/window.py +++ b/manimlib/window.py @@ -240,6 +240,12 @@ def on_mouse_scroll(self, x: int, y: int, x_offset: float, y_offset: float) -> N @note_undrawn_event def on_key_press(self, symbol: int, modifiers: int) -> None: super().on_key_press(symbol, modifiers) + if symbol == pyglet.window.key.F11: + if self.width == self.screen.width * self.screen.get_scale(): + self.to_default_position() + else: + self.maximize() + return self.pressed_keys.add(symbol) # Modifiers? if not self.scene: return From 00103aeff0f0f05b8899715136fc79fc6d017536 Mon Sep 17 00:00:00 2001 From: Andrey Zhuchkov <765471+azhuchkov@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:59:36 +0400 Subject: [PATCH 5/5] Add scene aspect ratio lock option --- manimlib/default_config.yml | 1 + manimlib/scene/scene.py | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index 098c6c45d5..f5fed29500 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -69,6 +69,7 @@ scene: preview_while_skipping: True # How long does a scene pause on Scene.wait calls default_wait_time: 1.0 + fixed_aspect_ratio: False vmobject: default_stroke_width: 4.0 default_stroke_color: "#DDDDDD" # Default is GREY_A diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 92d23ee2a0..2652e7a994 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -76,6 +76,7 @@ def __init__( preview_while_skipping: bool = True, presenter_mode: bool = False, default_wait_time: float = 1.0, + fixed_aspect_ratio: bool = False, ): self.skip_animations = skip_animations self.always_update_mobjects = always_update_mobjects @@ -86,6 +87,7 @@ def __init__( self.preview_while_skipping = preview_while_skipping self.presenter_mode = presenter_mode self.default_wait_time = default_wait_time + self.fixed_aspect_ratio = fixed_aspect_ratio self.camera_config = merge_dicts_recursively( manim_config.camera, # Global default @@ -110,6 +112,9 @@ def __init__( samples=self.samples, **self.camera_config ) + if self.window: + # Ensure viewport or frame matches current window size + self.on_resize(*self.window.size) self.frame: CameraFrame = self.camera.frame self.frame.reorient(*self.default_frame_orientation) self.frame.make_orientation_default() @@ -858,7 +863,22 @@ def on_key_press( self.hold_on_wait = False def on_resize(self, width: int, height: int) -> None: - pass + if not hasattr(self, 'camera'): + return + + if self.fixed_aspect_ratio: + aspect = self.camera.frame.get_aspect_ratio() + window_aspect = width / height + if window_aspect > aspect: + vp_height = height + vp_width = int(vp_height * aspect) + else: + vp_width = width + vp_height = int(vp_width / aspect) + vp_x = (width - vp_width) // 2 + vp_y = (height - vp_height) // 2 + if self.window: + self.window.ctx.viewport = (vp_x, vp_y, vp_width, vp_height) def on_show(self) -> None: pass