A GUI to easier make Manim animations
from manim import *


config.pixel_width = 1280
config.pixel_height = 640
config.frame_width = config.frame_height * \
    config.pixel_width / config.pixel_height


class ManimStudio(Scene):
    # add_guide = True
    add_guide = False

    def construct(self):
        banner = ManimBanner()
        studio = Tex("Studio")
        studio.scale(4)

        if self.add_guide:
            guide = ImageMobject("repository-open-graph-template.png")
            guide.scale_to_fit_height(config.frame_height)
            self.add(guide)

        vg = VGroup(banner, studio).arrange(RIGHT, buff=0.5)
        studio.set_y(banner.M.get_bottom()[1] + studio.get_height() / 2)
        self.add(vg)

branding/branding.py
MathItYT/manim-studio
from manim_studio import *
from manim import *
from time import sleep


class CheckboxWidgetExample(LiveScene):
    def construct(self):
        self.add_checkbox_command("dark_theme", True)
        sleep(1)  # wait for the checkbox widget to be created

        def updater(circle):
            if self.dark_theme.get_value():
                circle.become(Circle().set_color(WHITE))
                self.camera.background_color = BLACK
            else:
                circle.become(Circle().set_color(BLACK))
                self.camera.background_color = WHITE

        circ = VMobject().add_updater(updater)
        self.add(circ)

        super().construct()

examples/checkbox_widget_example.py
MathItYT/manim-studio
from manim_studio import *
from manim import *
from time import sleep


class ColorWidgetExample(LiveScene):
    def construct(self):
        self.add_color_widget_command("fill_color", np.array([0, 0, 0, 255]))
        sleep(1)  # wait for the color widget to be created

        def updater(circle):
            fill_color, opacity = self.fill_color.get_value()
            circle.become(Circle(stroke_color=WHITE).set_fill(
                fill_color, opacity=opacity))

        self.circle = VMobject().add_updater(updater)
        self.add(self.circle)

        super().construct()


if __name__ == "__main__":
    run_manim_studio(ColorWidgetExample)

examples/color_widget_example.py
MathItYT/manim-studio
from manim_studio import *
from manim import *
from time import sleep


class DropdownExample(LiveScene):
    def construct(self):
        self.add_dropdown_command(
            "dropdown", ["np.cos", "np.sin", "np.exp", "np.log", "np.sqrt"])
        sleep(1)  # wait for the dropdown widget to be created

        def get_domain(func):
            if func == "np.log":
                return [np.exp(-config.frame_y_radius), config.frame_x_radius]
            elif func == "np.sqrt":
                return [0, config.frame_x_radius]
            else:
                return [-config.frame_x_radius, config.frame_x_radius]

        def updater(graph):
            func = eval(self.dropdown.get_value())
            graph.become(FunctionGraph(
                func, color=YELLOW, x_range=get_domain(self.dropdown.get_value())))

        plane = NumberPlane()
        graph = VMobject().add_updater(updater)

        self.play(Write(plane))
        self.add(graph)

        super().construct()


if __name__ == "__main__":
    run_manim_studio(DropdownExample)

examples/dropdown_example.py
MathItYT/manim-studio
from manim_studio import *
from manim import *
from time import sleep


class LineEditExample(LiveScene):
    def construct(self):
        self.add_line_edit_command("line_edit", "Hello, World!")
        sleep(1)  # wait for the line edit widget to be created

        def updater(text):
            text.become(Text(self.line_edit.get_value()))

        text = VMobject().add_updater(updater)
        self.add(text)

        super().construct()


if __name__ == "__main__":
    run_manim_studio(LineEditExample)

examples/line_edit_example.py
MathItYT/manim-studio
from manim import *
from manim_studio import *


class LoadMobjectExample(Scene):
    def construct(self):
        figure = load_mobject("mobjects_pkl/figure.pkl")
        self.play(FadeIn(figure))

examples/load_mobject_example.py
MathItYT/manim-studio
from manim import *
from manim_studio import *


class LoadSnippetExample(Scene):
    def construct(self):
        load_snippet(self, "snippets/scene1.mss")
        self.play(self.x_times_1000.animate.set_value(1000))

examples/load_snippet_example.py
MathItYT/manim-studio
from manim_studio import *
from manim import *


class ManimStudioLogo(VGroup):
    def __init__(self):
        self.banner = ManimBanner()
        self.studio = Tex("Studio")
        self.studio.scale(4)
        super().__init__(self.banner, self.studio)
        self.arrange(RIGHT, buff=0.5)
        self.studio.set_y(self.banner.M.get_bottom()[
                          1] + self.studio.get_height() / 2)

    @override_animation(Create)
    def create(self):
        return AnimationGroup(Create(self.banner), LaggedStartMap(FadeIn, self.studio, scale=2))

    @override_animate(Uncreate)
    def uncreate(self):
        return FadeOut(self, scale=5)


class ManimStudioPresentation(LiveScene):
    def presentation_intro(self):
        self.logo = ManimStudioLogo()
        self.play(Create(self.logo))
        self.pause_slide()

        self.play(self.logo.animate.scale_to_fit_width(2.5).to_corner(DR))
        self.pause_slide()

        self.title = Text("Manim Studio First Presentation")
        self.author = Text("by @CodeIt_YT")
        self.title_page = VGroup(self.title, self.author)
        self.title_page.arrange(DOWN)

        self.play(DrawBorderThenFill(self.title_page))
        self.pause_slide()

    def whats_manim_studio(self):
        self.what_is_it = Text("What is Manim Studio?").scale(2)
        self.play(FadeOut(self.title_page, scale=5), FadeIn(self.what_is_it))
        self.pause_slide()

        self.answer = Text("Manim Studio is basically a GUI for Manim.", t2s={
                           "basically": ITALIC})
        self.play(LaggedStart(self.what_is_it.animate.to_corner(
            UL), FadeIn(self.answer), lag_ratio=0.5))
        self.pause_slide()

        self.is_it_all = Text("Is it all?", weight=BOLD).to_edge(DOWN)
        self.play(FadeIn(self.is_it_all))
        self.pause_slide()

        self.yes_joke = Text("Yes, it is all.\nThanks for watching!", t2w={
                             "Yes": BOLD}, t2c={"Thanks for watching": YELLOW}).scale(2)
        self.play(FadeOut(self.answer), FadeOut(self.is_it_all),
                  FadeIn(self.yes_joke))
        self.pause_slide()

        self.its_a_joke = Text("It's a joke, of course.", t2w={
            "joke": BOLD}).scale(2)
        self.play(FadeOut(self.yes_joke), FadeIn(self.its_a_joke))
        self.pause_slide()

    def whats_manim_studio_2(self):
        self.github_description = Text(
            "\"A GUI to easier make Manim animations\"", t2w={"easier": BOLD}).scale(1.5)
        self.play(FadeTransform(self.its_a_joke, self.github_description))
        self.pause_slide()

        self.github_link = Text(
            "https://github.com/MathItYT/manim-studio").next_to(self.github_description, DOWN)
        self.github_link.set_color(GREY)
        self.play(Write(self.github_link))
        self.pause_slide()

    def features(self):
        self.play(FadeOut(self.what_is_it), FadeOut(
            self.github_description), FadeOut(self.github_link))

    def construct(self):
        self.presentation_intro()
        self.whats_manim_studio()
        self.whats_manim_studio_2()
        self.features()
        super().construct()

examples/manim_studio_presentation.py
MathItYT/manim-studio
from manim_studio import *
from manim import *
from time import sleep


class MathJaxExample(LiveScene):
    def construct(self):
        self.current_code = "from manim_studio import *"
        self.run_instruction()
        self.current_code = "self.x_times_1000 = IntValueTracker(0)"
        self.run_instruction()

        self.add_slider_command("x_times_1000", "0", "0", "10000", "10")
        sleep(1)  # Wait for the slider to be added to the scene
        self.current_code = "current_x = 0.0"
        self.run_instruction()

        self.current_code = """
def updater(mobject: MathJax):
    global current_x
    if self.x_times_1000.get_value() / 1000 != current_x:
        current_x = self.x_times_1000.get_value() / 1000
        mobject.become(MathJax(
            f"{mobject.get_colored_expr('x', YELLOW)} = {mobject.get_colored_expr(f'{current_x:.2f}', BLUE)}"))
"""
        self.run_instruction()

        self.current_code = """mathjax = MathJax("x = 0.00")\n"""
        self.current_code += """mathjax.add_updater(updater)\n"""
        self.current_code += """self.add(mathjax)"""
        self.run_instruction()

        super().construct()

examples/mathjax_mobject_example.py
MathItYT/manim-studio
from manim_studio import *
from manim import *
from time import sleep


class SliderExample(LiveScene):
    def construct(self):
        self.add_slider_command(
            "a", "1", "-10", "10", "1")
        self.add_slider_command(
            "b", "1", "-10", "10", "1")
        self.add_slider_command(
            "c", "1", "-10", "10", "1")
        sleep(1)  # wait for the slider widget to be created
        plane = NumberPlane()
        self.play(Write(plane))
        self.wait()

        def update_quadratic(m):
            m.become(plane.plot(lambda x: self.a.get_value() * x *
                     x + self.b.get_value() * x + self.c.get_value(), color=YELLOW))

        plane_graph = VMobject().add_updater(update_quadratic)
        self.add(plane_graph)

        super().construct()


if __name__ == "__main__":
    run_manim_studio(SliderExample)

examples/slider_example.py
MathItYT/manim-studio
from manim_studio import *
from manim import *


class Slide(LiveScene):
    def construct(self):
        slides = [
            Text("Slide 1"),
            Text("Slide 2"),
            Text("Slide 3"),
            Text("Slide 4"),
            Text("Slide 5"),
        ]
        vg = VGroup(*slides)
        self.now_slide = vg[0]
        self.play(Write(self.now_slide))
        self.wait()

        for i in range(1, len(slides)):
            self.pause_slide()
            self.play(Transform(self.now_slide, vg[i]))

        super().construct()


if __name__ == "__main__":
    run_manim_studio(Slide)

examples/slides.py
MathItYT/manim-studio
from manim_studio import *
from manim import *
from time import sleep


class TextEditExample(LiveScene):
    def construct(self):
        self.add_text_editor_command("text_editor", "Hello, World!")
        sleep(1)  # wait for the text editor widget to be created

        paragraph = Paragraph("Hello, World!")
        content = self.text_editor.get_value()

        def update_paragraph(m):
            nonlocal content
            if content != self.text_editor.get_value():
                content = self.text_editor.get_value()
                m.become(Paragraph(*content.split("\n")))

        self.add(paragraph)
        paragraph.add_updater(update_paragraph)

        super().construct()


if __name__ == "__main__":
    run_manim_studio(TextEditExample)

examples/text_edit_example.py
MathItYT/manim-studio
from .main import main as run_manim_studio
from .live_scene import LiveScene
from .load_mobject import load_mobject
from .load_snippet import load_snippet
from .prerender_latex import prerender_latex
from .int_value_tracker import IntValueTracker
from .color_value_tracker import ColorValueTracker
from .string_value_tracker import StringValueTracker
from .boolean_value_tracker import BooleanValueTracker
from .mathjax_mobject.mathjax_mobject import MathJax

src/manim_studio/__init__.py
MathItYT/manim-studio
from .main import main as run_manim_studio
from .live_scene import LiveScene
from .client.dialog import ClientDialog
from .client.client import ManimStudioClient
from PyQt6.QtWidgets import QApplication
from manim import logger
import argparse
import sys


def get_live_scene_classes_from_file(file_name):
    from importlib import import_module
    import sys
    sys.path.append(".")
    module_name = file_name.replace(
        "/", ".").replace("\\", ".").replace(".py", "")
    module = import_module(module_name)
    return [getattr(module, name)
            for name in dir(module)
            if name != "LiveScene"
            and isinstance(getattr(module, name), type)
            and issubclass(getattr(module, name), LiveScene)]


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--file", "-f", help="file to render",
                        default=None, required=False)
    parser.add_argument("--scene", "-s", help="scene to render",
                        default=None, required=False)
    parser.add_argument("--server", "-S", help="run server",
                        action="store_true", required=False)
    parser.add_argument("--connect", "-c", help="connect to server",
                        action="store_true", required=False)
    args = parser.parse_args()
    if args.connect:
        app = QApplication([])
        client_dialog = ClientDialog()
        client_dialog.exec()
        host, port, password = client_dialog.get_server_info()
        client = ManimStudioClient(host, port, password)
        client.show()
        sys.exit(app.exec())
    if args.file is None:
        run_manim_studio(LiveScene, args.server)
    else:
        live_scenes = get_live_scene_classes_from_file(args.file)
        if args.scene is None:
            if len(live_scenes) == 0:
                logger.error(
                    f"No live scene found in file {args.file}")
            elif len(live_scenes) == 1:
                run_manim_studio(live_scenes[0], args.server)
            else:
                logger.info(
                    "More than one live scene found in file. Select one:")
                for i, live_scene in enumerate(live_scenes):
                    logger.info(f"{i+1}. {live_scene.__name__}")
                while True:
                    try:
                        i = int(input("Enter number: "))
                        if i < 1 or i > len(live_scenes):
                            raise ValueError()
                        break
                    except ValueError:
                        logger.info("Invalid input")
                run_manim_studio(live_scenes[i-1], args.server)
        else:
            for live_scene in live_scenes:
                if live_scene.__name__ == args.scene:
                    run_manim_studio(live_scene, args.server)
                    break
            else:
                logger.error(
                    f"Live scene {args.scene} not found in file {args.file}")


if __name__ == "__main__":
    main()

src/manim_studio/__main__.py
MathItYT/manim-studio
try:
    import openai
except ImportError:
    AIWidget = None
else:
    from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QTextEdit, QDialog, QLabel, QLineEdit
    from PyQt6.QtGui import QIntValidator
    from .communicate import Communicate

    SYSTEM_INSTRUCTIONS = "Generate a Python code snippet to generate a Manim animation according to the prompt. " \
        + "The response must contain only one snippet. " \
        + "Never explain the code that you are generating. " \
        + "The code must be valid Python code. " \
        + "The code must not contain any comments. " \
        + "The code must not contain any imports unless it is necessary. " \
        + "Assume you already import Manim and you defined the construct() method of a scene. That means " \
        + "that you won't indent nor write 'from manim import *', nor 'class <SceneName>(Scene):', nor 'def construct(self):'. " \
        + "Your answer must be a code block, it starts with ```python and ends with ```."

    class AIWidget(QWidget):
        def __init__(self, communicate: Communicate, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.setWindowTitle("Manim Studio - AI Power")
            self.openai_api_key_is_set = False
            self.communicate = communicate
            self.layout_ = QVBoxLayout()
            self.send_code_button = QPushButton("Send code")
            self.send_code_button.clicked.connect(self.send_code)
            self.response_edit = QTextEdit()
            self.response_edit.setPlaceholderText("Response")
            self.response_edit.setReadOnly(True)
            self.layout_.addWidget(self.response_edit)
            self.prompt_edit = QTextEdit()
            self.prompt_edit.setPlaceholderText("Enter the prompt")
            self.layout_.addWidget(self.prompt_edit)
            self.tokens_edit = QLineEdit()
            self.tokens_edit.setValidator(QIntValidator())
            self.tokens_edit.setPlaceholderText("Enter the max tokens")
            self.layout_.addWidget(self.tokens_edit)
            self.send_prompt_button = QPushButton("Send prompt")
            self.send_prompt_button.clicked.connect(self.send_prompt)
            self.layout_.addWidget(self.send_prompt_button)
            self.layout_.addWidget(self.send_code_button)
            self.setLayout(self.layout_)

        def send_prompt(self):
            if self.openai_api_key_is_set is False:
                dialog = QDialog()
                self.api_key_dialog = dialog
                dialog.setWindowTitle("OpenAI API key")
                dialog.layout_ = QVBoxLayout()
                dialog.api_key_edit = QTextEdit()
                dialog.api_key_edit.setPlaceholderText(
                    "Enter your OpenAI API key")
                dialog.layout_.addWidget(dialog.api_key_edit)
                dialog.warn_label = QLabel(
                    text="Please set your OpenAI API key. Once done, send the prompt again.")
                dialog.layout_.addWidget(dialog.warn_label)
                dialog.ok_button = QPushButton("OK")
                dialog.ok_button.clicked.connect(
                    lambda: self.set_api_key(dialog.api_key_edit.toPlainText()))
                dialog.layout_.addWidget(dialog.ok_button)
                dialog.setLayout(dialog.layout_)
                dialog.exec()
                return
            try:
                code_completion = openai.ChatCompletion.create(
                    model="gpt-3.5-turbo",
                    messages=[
                        {"role": "system",
                         "content": SYSTEM_INSTRUCTIONS},
                        {"role": "user",
                         "content": self.prompt_edit.toPlainText()}
                    ],
                    max_tokens=int(self.tokens_edit.text()),
                )
            except openai.OpenAIError as e:
                self.openai_api_key_is_set = False
                self.communicate.alert.emit(e)
            else:
                response = code_completion.choices[0].message.content
                if response.startswith("```python") and response.endswith("```"):
                    response = response[len("```python"): -len("```")]
                self.response_edit.setReadOnly(False)
                self.response_edit.setText(response)

        def send_code(self):
            self.communicate.update_scene.emit(
                self.response_edit.toPlainText())
            self.response_edit.setReadOnly(True)

        def set_api_key(self, api_key: str):
            openai.api_key = api_key
            self.openai_api_key_is_set = True
            self.api_key_dialog.close()

src/manim_studio/ai_widget.py
MathItYT/manim-studio
from .int_value_tracker import IntValueTracker


class BooleanValueTracker(IntValueTracker):
    def __init__(self, value=False, **kwargs):
        super().__init__(value=bool(value), **kwargs)

    def get_value(self) -> bool:
        return bool(super().get_value())

    def set_value(self, value: bool):
        return super().set_value(value)

src/manim_studio/boolean_value_tracker.py
MathItYT/manim-studio
from pathlib import Path
from manim import logger
import subprocess


BUILTINS_REPO = "https://github.com/MathItYT/manim-studio-snippets.git"


def main():
    if not Path("builtin_snippets").exists():
        subprocess.run(
            [
                "git",
                "clone",
                BUILTINS_REPO,
                "builtin_snippets",
            ],
            check=True,
        )
        (Path("builtin_snippets") / ".git").unlink()
    else:
        logger.error(
            "If you want to initialize built-in snippets, please delete the builtins_snippets folder and try again!"
        )
        exit(1)

src/manim_studio/builtins.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QCheckBox

from .boolean_value_tracker import BooleanValueTracker


class CheckboxWidget(QCheckBox):
    def __init__(self, name: str, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.value_tracker = BooleanValueTracker(False)
        self.stateChanged.connect(
            lambda: self.value_tracker.set_value(self.isChecked()))
        self.setText(name)

src/manim_studio/checkbox_widget.py
MathItYT/manim-studio
from manim_studio.client.client import ManimStudioClient
from manim_studio.client.dialog import ClientDialog

src/manim_studio/client/__init__.py
MathItYT/manim-studio
import socket
from PyQt6.QtWidgets import QWidget, QLabel, QVBoxLayout, QPushButton, QTextEdit, QMessageBox


class ManimStudioClient(QWidget):
    def __init__(self, host: str, port: int, password: str):
        super().__init__()
        self.setWindowTitle("Manim Studio Client")
        self.host = host
        self.port = port
        self.password = password
        self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.s.connect((self.host, self.port))
        self.s.send(self.password.encode("utf-8"))
        msg = self.s.recv(1024)
        if msg.decode("utf-8") == "Correct password":
            self.label = QLabel()
            self.label.setText(
                f"Connected to {self.host}:{self.port}")
            self.layout = QVBoxLayout()
            self.layout.addWidget(self.label)
            self.code_edit = QTextEdit()
            self.code_edit.setPlaceholderText("Enter the code")
            self.layout.addWidget(self.code_edit)
            self.send_button = QPushButton("Send")
            self.send_button.clicked.connect(self.send_code)
            self.layout.addWidget(self.send_button)
            self.setLayout(self.layout)
        else:
            QMessageBox.information(
                self, "Debug", msg.decode("utf-8"))
            self.close()

    def closeEvent(self, event):
        self.s.send(b"exit")
        self.s.close()
        super().closeEvent(event)

    def send_code(self):
        code = self.code_edit.toPlainText()
        self.s.send(code.encode("utf-8"))
        msg = self.s.recv(1024)
        if msg.decode("utf-8") == "Code executed":
            QMessageBox.information(
                self, "Code executed", "The code has been executed.")
        else:
            QMessageBox.critical(
                self, "Error", "The code could not be executed. Please try again.")

src/manim_studio/client/client.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLineEdit, QPushButton


class ClientDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Manim Studio Client Dialog")
        self.host_edit = QLineEdit()
        self.host_edit.setPlaceholderText("Enter the host")
        self.port_edit = QLineEdit()
        self.port_edit.setPlaceholderText("Enter the port")
        self.password_edit = QLineEdit()
        self.password_edit.setPlaceholderText("Enter the password")
        self.layout = QVBoxLayout()
        self.layout.addWidget(self.host_edit)
        self.layout.addWidget(self.port_edit)
        self.layout.addWidget(self.password_edit)
        self.ok_button = QPushButton("OK")
        self.ok_button.clicked.connect(self.accept)
        self.layout.addWidget(self.ok_button)
        self.setLayout(self.layout)

    def get_server_info(self):
        host = self.host_edit.text()
        port = int(self.port_edit.text())
        password = self.password_edit.text()
        return host, port, password

src/manim_studio/client/dialog.py
MathItYT/manim-studio
from manim import ValueTracker, Mobject
from colour import Color
import numpy as np


class ColorValueTracker(ValueTracker):
    def __init__(self, value=np.array([0, 0, 0, 1]), **kwargs):
        Mobject.__init__(self, **kwargs)
        self.set_points(np.zeros((1, 4)))
        self.set_value(value)

    def get_value(self) -> Color:
        return Color(rgb=np.array(self.points[0, :3])).hex, self.points[0, 3]

    def set_value(self, value: np.ndarray):
        self.points[0, :4] = np.array(value)
        return self

src/manim_studio/color_value_tracker.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QColorDialog
import numpy as np

from .color_value_tracker import ColorValueTracker


class ColorWidget(QColorDialog):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setOption(QColorDialog.ColorDialogOption.ShowAlphaChannel, True)
        self.color_tracker = ColorValueTracker(np.array([0, 0, 0, 255]))
        self.currentColorChanged.connect(lambda qcolor: self.color_tracker.set_value(
            np.array([qcolor.red() / 255, qcolor.green() / 255, qcolor.blue() / 255, qcolor.alpha() / 255])))
        self.setOption(QColorDialog.ColorDialogOption.NoButtons, True)

src/manim_studio/color_widget.py
MathItYT/manim-studio
from PyQt6.QtCore import pyqtSignal, QObject
import numpy as np


class Communicate(QObject):
    update_scene = pyqtSignal(str)
    update_image = pyqtSignal(np.ndarray)
    end_scene = pyqtSignal()
    alert = pyqtSignal(Exception)
    save_snippet = pyqtSignal(str)
    next_slide = pyqtSignal()
    add_slider_to_editor = pyqtSignal(str, str, str, str, str)
    add_color_widget_to_editor = pyqtSignal(str, np.ndarray)
    add_dropdown_to_editor = pyqtSignal(str, list, str)
    add_line_edit_to_editor = pyqtSignal(str, str)
    add_text_editor_to_editor = pyqtSignal(str, str)
    add_checkbox_to_editor = pyqtSignal(str, bool)
    save_mobject = pyqtSignal()
    load_mobject = pyqtSignal()
    set_control_value = pyqtSignal(str, str)

src/manim_studio/communicate.py
MathItYT/manim-studio

from PyQt6.QtWidgets import QDialog, QVBoxLayout, QCheckBox, QPushButton, QWidget


class ControlDialog(QDialog):
    def __init__(self, controls: dict, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setWindowTitle("Add controls to snippet")
        self.layout_ = QVBoxLayout()
        self.addControlsCheckbox = QCheckBox("Add controls")
        self.addControlsCheckbox.setChecked(False)
        self.add_controls = False
        self.controls_toggle = {k: False for k in controls.keys()}
        self.addControlsCheckbox.stateChanged.connect(self.toggle_add_controls)
        self.controls_toggle_widget = QWidget()
        self.controls_toggle_layout = QVBoxLayout()
        self.controls_toggle_widget.setLayout(self.controls_toggle_layout)
        for name in controls.keys():
            checkbox = QCheckBox(name)
            checkbox.setChecked(False)
            checkbox.stateChanged.connect(
                lambda state, name=name: self.toggle_control(name, state))
            self.controls_toggle_layout.addWidget(checkbox)
        self.controls_toggle_widget.hide()
        self.okButton = QPushButton("OK")
        self.okButton.clicked.connect(self.accept)
        self.layout_.addWidget(self.addControlsCheckbox)
        self.layout_.addWidget(self.okButton)
        self.layout_.addWidget(self.controls_toggle_widget)
        self.setLayout(self.layout_)

    def toggle_add_controls(self, state: bool):
        self.add_controls = bool(state)
        if self.add_controls:
            self.controls_toggle_widget.show()
        else:
            self.controls_toggle_widget.hide()

    def exec_and_return(self):
        self.exec()
        return self.add_controls, self.controls_toggle

    def toggle_control(self, name: str, state: bool):
        self.controls_toggle[name] = bool(state)

src/manim_studio/control_dialog.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QComboBox

from .string_value_tracker import StringValueTracker


class DropdownWidget(QComboBox):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.value_tracker = StringValueTracker("")
        self.currentTextChanged.connect(self.value_tracker.set_value)

src/manim_studio/dropdown_widget.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QWidget, QTextEdit, QPushButton, QVBoxLayout, QFileDialog, \
    QMenuBar, QMessageBox, QDialog, QLineEdit, QLabel, QCheckBox, QGridLayout, QScrollArea
from PyQt6.QtGui import QAction, QIntValidator, QColor
from PyQt6.QtCore import pyqtSlot, Qt
import numpy as np
from pathlib import Path
import pickle

from .communicate import Communicate
from .live_scene import LiveScene
from .slider import Slider
from .color_widget import ColorWidget
from .dropdown_widget import DropdownWidget
from .line_editor_widget import LineEditorWidget
from .text_editor_widget import TextEditorWidget
from .checkbox_widget import CheckboxWidget
from .control_dialog import ControlDialog


class EditorWidget(QWidget):
    def __init__(self, communicate: Communicate, scene: LiveScene, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.communicate = communicate
        self.setWindowTitle("Manim Studio - Editor")
        self.scene = scene
        self.controls = dict()
        self.ready_to_save = True

        self.code_cell_edit = QTextEdit()
        self.code_cell_edit.setPlaceholderText("Enter your code here")
        self.code_cell_edit.setGeometry(0, 0, 1920, 250)

        self.send_button = QPushButton("Send code")
        self.send_button.setGeometry(0, 0, 100, 50)
        self.send_button.clicked.connect(self.send_code)
        self.end_button = QPushButton("End scene without saving")
        self.end_button.setGeometry(0, 0, 100, 50)
        self.end_button.clicked.connect(self.end_scene)
        self.end_and_save_button = QPushButton("End scene and save")
        self.end_and_save_button.setGeometry(0, 0, 100, 50)
        self.end_and_save_button.clicked.connect(self.end_scene_saving)
        self.save_snip_button = QPushButton("Save snippet")
        self.save_snip_button.setGeometry(0, 0, 100, 50)
        self.save_snip_button.clicked.connect(self.save_snippet)
        self.save_snip_and_run_button = QPushButton("Save snippet and run")
        self.save_snip_and_run_button.setGeometry(0, 0, 100, 50)
        self.save_snip_and_run_button.clicked.connect(
            self.save_snippet_and_run)
        self.communicate.save_snippet.connect(self.save_snippet_command)
        self.next_slide_button = QPushButton("Next slide")
        self.next_slide_button.setGeometry(0, 0, 100, 50)
        self.communicate.next_slide.connect(self.next_slide)
        self.next_slide_button.clicked.connect(
            self.communicate.next_slide.emit)

        self.menu_bar = QMenuBar()
        self.file_menu = self.menu_bar.addMenu("File")
        self.open_snip_action = QAction("Open snippet", self)
        self.open_snip_action.triggered.connect(self.open_snippet)
        self.file_menu.addAction(self.open_snip_action)
        self.open_snip_and_run_action = QAction(
            "Open snippet and run", self)
        self.open_snip_and_run_action.triggered.connect(
            self.open_snippet_and_run)
        self.file_menu.addAction(self.open_snip_and_run_action)
        self.edit_menu = self.menu_bar.addMenu("Edit")
        self.add_slider_action = QAction("Add slider", self)
        self.add_slider_action.triggered.connect(
            self.add_slider)
        self.edit_menu.addAction(self.add_slider_action)
        self.add_color_widget_action = QAction("Add color widget", self)
        self.add_color_widget_action.triggered.connect(
            self.add_color_widget)
        self.edit_menu.addAction(self.add_color_widget_action)
        self.add_dropdown_action = QAction("Add dropdown", self)
        self.add_dropdown_action.triggered.connect(
            self.add_dropdown)
        self.edit_menu.addAction(self.add_dropdown_action)
        self.add_line_editor_widget_action = QAction(
            "Add line editor widget", self)
        self.add_line_editor_widget_action.triggered.connect(
            self.add_line_editor_widget)
        self.edit_menu.addAction(self.add_line_editor_widget_action)
        self.add_text_editor_widget_action = QAction(
            "Add text editor widget", self)
        self.add_text_editor_widget_action.triggered.connect(
            self.add_text_editor_widget)
        self.edit_menu.addAction(self.add_text_editor_widget_action)
        self.add_checkbox_widget_action = QAction(
            "Add checkbox widget", self)
        self.add_checkbox_widget_action.triggered.connect(
            self.add_checkbox_widget)
        self.edit_menu.addAction(self.add_checkbox_widget_action)
        self.save_mobject_action = QAction("Save mobject", self)
        self.save_mobject_action.triggered.connect(self.save_mobject)
        self.edit_menu.addAction(self.save_mobject_action)
        self.load_mobject_action = QAction("Load mobject", self)
        self.load_mobject_action.triggered.connect(self.load_mobject)
        self.edit_menu.addAction(self.load_mobject_action)

        self.layout_ = QVBoxLayout()
        self.layout_.addWidget(self.menu_bar)
        self.layout_.addWidget(self.code_cell_edit)
        self.layout_.addWidget(self.send_button)
        self.layout_.addWidget(self.end_button)
        self.layout_.addWidget(self.end_and_save_button)
        self.layout_.addWidget(self.save_snip_button)
        self.layout_.addWidget(self.save_snip_and_run_button)
        self.layout_.addWidget(self.next_slide_button)
        self.controls_widget = QWidget()
        self.controls_scroll = QScrollArea()
        self.controls_scroll.setWidget(self.controls_widget)
        self.controls_scroll.setWidgetResizable(True)
        self.no_controls = QLabel(text="No controls defined")
        self.controls_scroll.setWindowTitle("Manim Studio - Controls")
        self.controls_layout = QGridLayout()
        self.controls_layout.addWidget(self.no_controls)
        self.controls_widget.setLayout(self.controls_layout)
        self.communicate.add_slider_to_editor.connect(
            self.add_slider_to_editor)
        self.communicate.add_color_widget_to_editor.connect(
            self.add_color_widget_to_editor)
        self.communicate.add_dropdown_to_editor.connect(
            self.add_dropdown_to_editor)
        self.communicate.add_line_edit_to_editor.connect(
            self.add_line_editor_widget_to_editor)
        self.communicate.add_text_editor_to_editor.connect(
            self.add_text_editor_to_editor)
        self.communicate.add_checkbox_to_editor.connect(
            self.add_checkbox_to_editor)
        self.communicate.set_control_value.connect(self.set_control_value)
        self.setLayout(self.layout_)

    def show(self):
        super().show()
        self.controls_scroll.show()

    @pyqtSlot(str, str)
    def set_control_value(self, name: str, value: str):
        control = self.controls.get(name)
        if control is None:
            alert = QMessageBox()
            alert.setText(f"Control {name} not found")
            alert.exec()
            return
        if isinstance(control, Slider):
            control.setValue(int(value))
        elif isinstance(control, ColorWidget):
            value = value.split(",")
            control.setCurrentColor(QColor(int(value[0]), int(
                value[1]), int(value[2]), int(value[3])))
        elif isinstance(control, DropdownWidget):
            control.setCurrentText(value)
        elif isinstance(control, LineEditorWidget):
            control.setText(value)
        elif isinstance(control, TextEditorWidget):
            control.setText(value)
        elif isinstance(control, CheckboxWidget):
            control.setChecked(value == "True")

    def save_mobject(self):
        self.communicate.save_mobject.emit()

    def load_mobject(self):
        self.communicate.load_mobject.emit()

    def add_text_editor_widget(self):
        dialog = QDialog(self)
        dialog.setWindowTitle("Add text editor widget")
        text_edit = QLineEdit(dialog)
        text_edit.setPlaceholderText("Text editor widget name")
        default_value_edit = QCheckBox(dialog)
        default_value_edit.setText("Is default value True?")
        ok_button = QPushButton("OK", dialog)
        ok_button.clicked.connect(dialog.close)
        ok_button.clicked.connect(lambda: self.scene.add_text_editor_command(
            text_edit.text(), default_value_edit.text()))
        layout = QVBoxLayout()
        layout.addWidget(text_edit)
        layout.addWidget(default_value_edit)
        layout.addWidget(ok_button)
        dialog.setLayout(layout)
        dialog.exec()

    @pyqtSlot(str, bool)
    def add_checkbox_to_editor(self, name: str, default_value: bool):
        label = QLabel(text=name)
        checkbox = CheckboxWidget(name)
        checkbox.setChecked(default_value)
        self.controls[name] = checkbox
        setattr(self.scene, name, checkbox.value_tracker)
        self.controls_layout.addWidget(label)
        self.controls_layout.addWidget(checkbox)
        if self.no_controls.isVisible():
            self.no_controls.hide()

    @pyqtSlot(str, str)
    def add_text_editor_to_editor(self, name: str, default_value: str):
        label = QLabel(text=name)
        text_editor_widget = TextEditorWidget(name)
        text_editor_widget.setText(default_value)
        self.controls[name] = text_editor_widget
        setattr(self.scene, name, text_editor_widget.value_tracker)
        self.controls_layout.addWidget(label)
        self.controls_layout.addWidget(text_editor_widget)
        if self.no_controls.isVisible():
            self.no_controls.hide()

    def add_checkbox_widget(self):
        dialog = QDialog(self)
        dialog.setWindowTitle("Add checkbox widget")
        text_edit = QLineEdit(dialog)
        text_edit.setPlaceholderText("Checkbox widget name")
        default_value_edit = QCheckBox(dialog)
        default_value_edit.setText("Is default value True?")
        ok_button = QPushButton("OK", dialog)
        ok_button.clicked.connect(dialog.close)
        ok_button.clicked.connect(lambda: self.scene.add_checkbox_command(
            text_edit.text(), default_value_edit.isChecked()))
        layout = QVBoxLayout()
        layout.addWidget(text_edit)
        layout.addWidget(default_value_edit)
        layout.addWidget(ok_button)
        dialog.setLayout(layout)
        dialog.exec()

    def add_dropdown(self):
        dialog = QDialog(self)
        dialog.setWindowTitle("Add dropdown")
        text_edit = QLineEdit(dialog)
        text_edit.setPlaceholderText("Dropdown name")
        options_edit = QLineEdit(dialog)
        options_edit.setPlaceholderText("Dropdown options")
        ok_button = QPushButton("OK", dialog)
        ok_button.clicked.connect(dialog.close)
        default_value_edit = QLineEdit(dialog)
        default_value_edit.setPlaceholderText("Default value")
        ok_button.clicked.connect(lambda: self.scene.add_dropdown_command(
            text_edit.text(), options_edit.text().split(","), default_value_edit.text()))
        layout = QVBoxLayout()
        layout.addWidget(text_edit)
        layout.addWidget(options_edit)
        layout.addWidget(default_value_edit)
        layout.addWidget(ok_button)
        dialog.setLayout(layout)
        dialog.exec()

    def add_line_editor_widget(self):
        dialog = QDialog(self)
        dialog.setWindowTitle("Add line editor widget")
        text_edit = QLineEdit(dialog)
        text_edit.setPlaceholderText("Line editor widget name")
        default_value_edit = QLineEdit(dialog)
        default_value_edit.setPlaceholderText("Default value")
        ok_button = QPushButton("OK", dialog)
        ok_button.clicked.connect(dialog.close)
        ok_button.clicked.connect(lambda: self.scene.add_line_edit_command(
            text_edit.text(), default_value_edit.text()))
        layout = QVBoxLayout()
        layout.addWidget(text_edit)
        layout.addWidget(default_value_edit)
        layout.addWidget(ok_button)
        dialog.setLayout(layout)
        dialog.exec()

    @pyqtSlot(str, str)
    def add_line_editor_widget_to_editor(self, name: str, default_value: str):
        label = QLabel(text=name)
        line_editor_widget = LineEditorWidget(name)
        line_editor_widget.setText(default_value)
        self.controls[name] = line_editor_widget
        setattr(self.scene, name, line_editor_widget.value_tracker)
        self.controls_layout.addWidget(label)
        self.controls_layout.addWidget(line_editor_widget)
        if self.no_controls.isVisible():
            self.no_controls.hide()

    def send_code(self):
        self.communicate.update_scene.emit(self.code_cell_edit.toPlainText())
        self.code_cell_edit.clear()

    def add_slider(self):
        dialog = QDialog(self)
        dialog.setWindowTitle("Add slider")
        text_edit = QLineEdit(dialog)
        text_edit.setPlaceholderText("Slider name")
        default_value_edit = QLineEdit(dialog)
        default_value_edit.setValidator(QIntValidator(
            -2147483648, 2147483647))
        default_value_edit.setPlaceholderText("Default value")
        min_value_edit = QLineEdit(dialog)
        min_value_edit.setValidator(QIntValidator(
            -2147483648, 2147483647))
        min_value_edit.setPlaceholderText("Minimum value")
        max_value_edit = QLineEdit(dialog)
        max_value_edit.setValidator(QIntValidator(
            -2147483648, 2147483647))
        max_value_edit.setPlaceholderText("Maximum value")
        step_value_edit = QLineEdit(dialog)
        step_value_edit.setValidator(QIntValidator(
            -2147483648, 2147483647))
        step_value_edit.setPlaceholderText("Step value")
        ok_button = QPushButton("OK", dialog)
        ok_button.clicked.connect(dialog.close)
        ok_button.clicked.connect(lambda: self.scene.add_slider_command(
            text_edit.text(), default_value_edit.text(), min_value_edit.text(), max_value_edit.text(), step_value_edit.text()))
        layout = QVBoxLayout()
        layout.addWidget(text_edit)
        layout.addWidget(default_value_edit)
        layout.addWidget(min_value_edit)
        layout.addWidget(max_value_edit)
        layout.addWidget(step_value_edit)
        layout.addWidget(ok_button)
        dialog.setLayout(layout)
        dialog.exec()

    def add_color_widget(self):
        dialog = QDialog(self)
        dialog.setWindowTitle("Add color widget")
        text_edit = QLineEdit(dialog)
        text_edit.setPlaceholderText("Color widget name")
        default_r_edit = QLineEdit(dialog)
        default_r_edit.setValidator(QIntValidator(
            0, 255))
        default_r_edit.setPlaceholderText("Default red value")
        default_g_edit = QLineEdit(dialog)
        default_g_edit.setValidator(QIntValidator(
            0, 255))
        default_g_edit.setPlaceholderText("Default green value")
        default_b_edit = QLineEdit(dialog)
        default_b_edit.setValidator(QIntValidator(
            0, 255))
        default_b_edit.setPlaceholderText("Default blue value")
        default_a_edit = QLineEdit(dialog)
        default_a_edit.setValidator(QIntValidator(
            0, 255))
        default_a_edit.setPlaceholderText("Default alpha value")
        ok_button = QPushButton("OK", dialog)
        ok_button.clicked.connect(dialog.close)
        ok_button.clicked.connect(lambda: self.scene.add_color_widget_command(
            text_edit.text(), np.array([int(default_r_edit.text()), int(default_g_edit.text()), int(default_b_edit.text()), int(default_a_edit.text())])))
        layout = QVBoxLayout()
        layout.addWidget(text_edit)
        layout.addWidget(default_r_edit)
        layout.addWidget(default_g_edit)
        layout.addWidget(default_b_edit)
        layout.addWidget(default_a_edit)
        layout.addWidget(ok_button)
        dialog.setLayout(layout)
        dialog.exec()

    @pyqtSlot(str, np.ndarray)
    def add_color_widget_to_editor(self, name: str, default_value: np.ndarray):
        default_value = np.array(default_value)
        label = QLabel(text=name)
        color_widget = ColorWidget()
        color_widget.setCurrentColor(QColor(
            default_value[0], default_value[1], default_value[2], default_value[3]))
        self.controls[name] = color_widget
        setattr(self.scene, name, color_widget.color_tracker)
        self.controls_layout.addWidget(label)
        self.controls_layout.addWidget(color_widget)
        if self.no_controls.isVisible():
            self.no_controls.hide()

    @pyqtSlot(str, str, str, str, str)
    def add_slider_to_editor(self, name: str, default_value: str, min_value: str, max_value: str, step_value: str):
        label = QLabel(text=name)
        slider = Slider()
        slider.setOrientation(Qt.Orientation.Horizontal)
        slider.setMinimum(int(min_value))
        slider.setMaximum(int(max_value))
        slider.setValue(int(default_value))
        slider.setSingleStep(int(step_value))
        self.controls[name] = slider
        setattr(self.scene, name, slider.value_tracker)
        self.controls_layout.addWidget(label)
        self.controls_layout.addWidget(slider)
        if self.no_controls.isVisible():
            self.no_controls.hide()

    @pyqtSlot(str, list, str)
    def add_dropdown_to_editor(self, name: str, options: list[str], default_value: str):
        label = QLabel(text=name)
        dropdown = DropdownWidget()
        dropdown.addItems(options)
        dropdown.setCurrentText(default_value)
        self.controls[name] = dropdown
        setattr(self.scene, name, dropdown.value_tracker)
        self.controls_layout.addWidget(label)
        self.controls_layout.addWidget(dropdown)
        if self.no_controls.isVisible():
            self.no_controls.hide()

    def save_snippet(self):
        self.communicate.save_snippet.emit(self.code_cell_edit.toPlainText())

    def save_snippet_command(self, code: str):
        controls_dialog = ControlDialog(self.controls)
        add_controls, controls_toggle = controls_dialog.exec_and_return()
        file_ = QFileDialog.getSaveFileName(
            self, "Save snippet", ".", "Manim Studio Snippet (*.mss)")
        if file_[0]:
            self.ready_to_save = True
            with open(file_[0], "w", encoding="utf-8") as f:
                f.write(code)
            if add_controls is True:
                controls = {k: v for k, v in self.controls.items()
                            if k in controls_toggle.keys() and controls_toggle[k] is True}

                def get_tup(v):
                    if isinstance(v, Slider):
                        return ("Slider", v.value(), v.minimum(), v.maximum(), v.singleStep())
                    elif isinstance(v, ColorWidget):
                        return ("ColorWidget", list(v.currentColor().getRgb()))
                    elif isinstance(v, DropdownWidget):
                        return ("DropdownWidget", [v.itemText(i) for i in range(v.count())], v.currentText())
                    elif isinstance(v, LineEditorWidget):
                        return ("LineEditorWidget", v.text())
                    elif isinstance(v, TextEditorWidget):
                        return ("TextEditorWidget", v.toPlainText())
                    elif isinstance(v, CheckboxWidget):
                        return ("CheckboxWidget", v.isChecked())

                controls = {k: get_tup(v) for k, v in controls.items()}
                with open(f"{file_[0]}.controls", "wb") as f:
                    pickle.dump(controls, f)
            else:
                if Path(f"{file_[0]}.controls").exists():
                    Path(f"{file_[0]}.controls").unlink()

    def end_scene_saving(self):
        codes = "\n".join(self.scene.codes)
        self.ready_to_save = False
        self.save_snippet_command(codes)
        if self.ready_to_save is True:
            self.end_scene()
        else:
            self.ready_to_save = True
            alert = QMessageBox(
                text="You have not saved the snippet yet.")
            alert.setWindowTitle("Snippet not saved")
            alert.setIcon(QMessageBox.Icon.Information)
            alert.setStandardButtons(QMessageBox.StandardButton.Ok)
            alert.exec()

    def save_snippet_and_run(self):
        self.save_snippet()
        self.send_code()

    def open_snippet(self):
        file_ = QFileDialog.getOpenFileName(
            self, "Open snippet", ".", "Manim Studio Snippet (*.mss)")
        if file_[0]:
            with open(file_[0], "r", encoding="utf-8") as f:
                self.code_cell_edit.setText(
                    f"{self.code_cell_edit.toPlainText()}\n{f.read()}")
            if Path(f"{file_[0]}.controls").exists():
                with open(f"{file_[0]}.controls", "rb") as f:
                    controls = pickle.load(f)
                for name, control in controls.items():
                    if name in self.controls.keys():
                        continue
                    if control[0] == "Slider":
                        self.add_slider_to_editor(
                            name, control[1], control[2], control[3], control[4])
                    elif control[0] == "ColorWidget":
                        self.add_color_widget_to_editor(
                            name, np.array(control[1]))
                    elif control[0] == "DropdownWidget":
                        self.add_dropdown_to_editor(
                            name, control[1], control[2])
                    elif control[0] == "LineEditorWidget":
                        self.add_line_editor_widget_to_editor(
                            name, control[1])
                    elif control[0] == "TextEditorWidget":
                        self.add_text_editor_to_editor(
                            name, control[1])
                    elif control[0] == "CheckboxWidget":
                        self.add_checkbox_to_editor(
                            name, control[1])
        else:
            alert = QMessageBox(
                text="No file selected.")
            alert.setWindowTitle("No file selected")
            alert.setIcon(QMessageBox.Icon.Information)
            alert.setStandardButtons(QMessageBox.StandardButton.Ok)
            alert.exec()

    def open_snippet_and_run(self):
        self.open_snippet()
        self.send_code()

    def next_slide(self):
        if self.scene.freeze is False:
            alert = QMessageBox(
                text="The scene is not paused.")
            alert.setWindowTitle("Scene not paused")
            alert.setIcon(QMessageBox.Icon.Information)
            alert.setStandardButtons(QMessageBox.StandardButton.Ok)
            alert.exec()
            return
        self.scene.freeze = False

    def end_scene(self):
        self.communicate.end_scene.emit()

src/manim_studio/editor_widget.py
MathItYT/manim-studio
def get_icon_file():
    import importlib
    from pathlib import Path

    module = importlib.import_module("manim_studio")
    module_path = Path(module.__file__).parent
    icon_folder = module_path / "assets"
    icon_file = icon_folder / "favicon.ico"
    return str(icon_file.absolute())

src/manim_studio/get_icon_file.py
MathItYT/manim-studio
from manim import ValueTracker


class IntValueTracker(ValueTracker):
    def get_value(self) -> int:
        return int(self.points[0, 0])

    def set_value(self, value: int):
        self.points[0, 0] = int(value)
        return self

src/manim_studio/int_value_tracker.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QLineEdit

from .string_value_tracker import StringValueTracker


class LineEditorWidget(QLineEdit):
    def __init__(self, name: str, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.value_tracker = StringValueTracker("")
        self.textChanged.connect(self.value_tracker.set_value)
        self.setPlaceholderText(name)

src/manim_studio/line_editor_widget.py
MathItYT/manim-studio
from manim import *
from PyQt6.QtCore import QObject, pyqtSlot
from PyQt6.QtWidgets import QMessageBox, QDialog, QVBoxLayout, QLineEdit, QLabel, QPushButton, QFileDialog
from .communicate import Communicate
import dill as pickle

from .load_mobject import load_mobject


class CairoLiveRenderer(QObject, CairoRenderer):
    def __init__(self, communicate: Communicate, *args, **kwargs):
        QObject.__init__(self)
        CairoRenderer.__init__(self, *args, **kwargs)
        self.communicate = communicate

    def render(self, scene, time, moving_mobjects):
        super().render(scene, time, moving_mobjects)
        self.communicate.update_image.emit(self.get_frame())


class LiveScene(QObject, Scene):
    def __init__(self, communicate: Communicate, renderer=None, *args, **kwargs):
        config.disable_caching = True
        QObject.__init__(self)
        Scene.__init__(self, *args, **kwargs)
        self.communicate = communicate
        renderer = CairoLiveRenderer(communicate)
        QObject.__init__(self)
        Scene.__init__(self, renderer=renderer, *args, **kwargs)
        self.communicate.update_scene.connect(self.update_scene)
        self.communicate.end_scene.connect(self.end_scene)
        self.communicate.alert.connect(self.alert)
        self.communicate.save_mobject.connect(self.save_mobject)
        self.communicate.load_mobject.connect(self.load_mobject)
        self.current_code = None
        self.codes = []
        self.freeze = False

    def set_control_value(self, name: str, value: str):
        self.communicate.set_control_value.emit(name, value)

    def construct(self):
        while True:
            while self.no_instruction():
                self.wait_until(lambda: not self.no_instruction(), 1)
            self.run_instruction()

    def add_checkbox_command(self, name: str, default_value: bool):
        self.communicate.add_checkbox_to_editor.emit(
            name, default_value)

    def add_text_editor_command(self, name: str, default_value: str):
        self.communicate.add_text_editor_to_editor.emit(
            name, default_value)

    def add_line_edit_command(self, name: str, default_value: str):
        self.communicate.add_line_edit_to_editor.emit(
            name, default_value)

    def add_dropdown_command(self, name: str, options: list[str], default_value: str):
        self.communicate.add_dropdown_to_editor.emit(
            name, options, default_value)

    def add_color_widget_command(self, name: str, default_value: np.ndarray):
        self.communicate.add_color_widget_to_editor.emit(
            name, default_value)

    def add_slider_command(self, name: str, default_value: str, min_value: str, max_value: str, step_value: str):
        self.communicate.add_slider_to_editor.emit(
            name, default_value, min_value, max_value, step_value)

    def wait(self, *args, frozen_frame=False, **kwargs):
        super().wait(*args, frozen_frame=frozen_frame, **kwargs)

    def run_instruction(self):
        try:
            scope = globals()
            scope["self"] = self
            exec(self.current_code, scope)
        except EndSceneEarlyException:
            raise EndSceneEarlyException()
        except Exception as e:
            logger.info(
                f"Exception occured in live scene ({e.__class__.__name__}: {e})")
            self.communicate.alert.emit(e)
        else:
            self.codes.append(self.current_code)
        self.current_code = None

    @pyqtSlot(str)
    def update_scene(self, code: str):
        if self.freeze and code != "raise EndSceneEarlyException()":
            alert = QMessageBox(
                text="You cannot update the scene while it is paused.")
            alert.setWindowTitle("Scene paused")
            alert.setIcon(QMessageBox.Icon.Information)
            alert.setStandardButtons(QMessageBox.StandardButton.Ok)
            alert.exec()
            return
        self.current_code = code

    @pyqtSlot()
    def end_scene(self):
        self.current_code = "raise EndSceneEarlyException()"
        alert = QMessageBox(
            text="Your scene has ended. See the terminal for more information.")
        alert.setWindowTitle("Scene ended")
        alert.setIcon(QMessageBox.Icon.Information)
        alert.setStandardButtons(QMessageBox.StandardButton.Ok)
        alert.exec()

    @pyqtSlot(Exception)
    def alert(self, e: Exception):
        alert = QMessageBox(
            text="An error occured while processing your code.")
        alert.setWindowTitle("Error")
        alert.setIcon(QMessageBox.Icon.Warning)
        alert.setStandardButtons(QMessageBox.StandardButton.Ok)
        alert.setInformativeText(f"{e.__class__.__name__}: {e}")
        alert.exec()

    def pause_slide(self):
        self.freeze = True
        while self.freeze and self.no_instruction():
            self.wait_until(
                lambda: not self.freeze or not self.no_instruction(), 1)
        if not self.no_instruction():
            self.run_instruction()

    def no_instruction(self):
        return self.current_code is None

    def save_mobject(self):
        dialog = QDialog()
        dialog.setWindowTitle("Save mobject")
        dialog.layout_ = QVBoxLayout()
        dialog.name_edit = QLineEdit()
        dialog.name_edit.setPlaceholderText("Enter the name of the mobject")
        dialog.info_label = QLabel(
            text="The name of the mobject must be a valid attribute of the scene.\n"
            "For example, if you want to save the mobject 'self.circle',\n"
            "you must enter 'circle' in the text box below.")
        dialog.layout_.addWidget(dialog.name_edit)
        dialog.layout_.addWidget(dialog.info_label)
        dialog.ok_button = QPushButton("OK")
        dialog.ok_button.clicked.connect(dialog.accept)
        dialog.layout_.addWidget(dialog.ok_button)
        dialog.setLayout(dialog.layout_)
        dialog.exec()

        name = dialog.name_edit.text()
        if name == "":
            alert = QMessageBox(
                text="You must enter a name for the mobject.")
            alert.setWindowTitle("No name entered")
            alert.setIcon(QMessageBox.Icon.Information)
            alert.setStandardButtons(QMessageBox.StandardButton.Ok)
            alert.exec()
            return
        mobject_to_save = getattr(self, name, None)
        if mobject_to_save is None:
            alert = QMessageBox(
                text="The name you entered is not a valid attribute of the scene.")
            alert.setWindowTitle("Invalid name")
            alert.setIcon(QMessageBox.Icon.Information)
            alert.setStandardButtons(QMessageBox.StandardButton.Ok)
            alert.exec()
            return
        file_name = QFileDialog.getSaveFileName(
            caption="Save mobject",
            filter="Pickle file (*.pkl)"
        )
        if file_name[0]:
            with open(file_name[0], "wb") as f:
                pickle.dump(mobject_to_save, f)
        else:
            alert = QMessageBox(
                text="You must enter a file name.")
            alert.setWindowTitle("No file name entered")
            alert.setIcon(QMessageBox.Icon.Information)
            alert.setStandardButtons(QMessageBox.StandardButton.Ok)
            alert.exec()
            return
        alert = QMessageBox(
            text="The mobject has been saved.")
        alert.setWindowTitle("Mobject saved")
        alert.setIcon(QMessageBox.Icon.Information)
        alert.setStandardButtons(QMessageBox.StandardButton.Ok)
        alert.exec()

    def load_mobject(self):
        file_name = QFileDialog.getOpenFileName(
            caption="Load mobject",
            filter="Pickle file (*.pkl)"
        )
        if not file_name[0]:
            alert = QMessageBox(
                text="You must enter a file name.")
            alert.setWindowTitle("No file name entered")
            alert.setIcon(QMessageBox.Icon.Information)
            alert.setStandardButtons(QMessageBox.StandardButton.Ok)
            alert.exec()
            return
        dialog = QDialog()
        dialog.setWindowTitle("Load mobject")
        dialog.layout_ = QVBoxLayout()
        dialog.name_edit = QLineEdit()
        dialog.name_edit.setPlaceholderText("Enter the name of the mobject")
        dialog.info_label = QLabel(
            text="The name of the mobject will be an attribute of the scene.\n"
            "For example, if you want to load the mobject as 'self.circle',\n"
            "you must enter 'circle' in the text box below.")
        dialog.layout_.addWidget(dialog.name_edit)
        dialog.layout_.addWidget(dialog.info_label)
        dialog.ok_button = QPushButton("OK")
        dialog.ok_button.clicked.connect(dialog.accept)
        dialog.layout_.addWidget(dialog.ok_button)
        dialog.setLayout(dialog.layout_)
        dialog.exec()
        if dialog.name_edit.text() == "":
            alert = QMessageBox(
                text="You must enter a name for the mobject.")
            alert.setWindowTitle("No name entered")
            alert.setIcon(QMessageBox.Icon.Information)
            alert.setStandardButtons(QMessageBox.StandardButton.Ok)
            alert.exec()
            return
        code = f"self.{dialog.name_edit.text()} = load_mobject('{file_name[0]}')"
        self.communicate.update_scene.emit(code)
        alert = QMessageBox(
            text="The mobject has been loaded.")
        alert.setWindowTitle("Mobject loaded")
        alert.setIcon(QMessageBox.Icon.Information)
        alert.setStandardButtons(QMessageBox.StandardButton.Ok)
        alert.exec()

src/manim_studio/live_scene.py
MathItYT/manim-studio
import dill as pickle


def load_mobject(file_name):
    with open(file_name, "rb") as f:
        return pickle.load(f)

src/manim_studio/load_mobject.py
MathItYT/manim-studio
def load_snippet(scene, file_name):
    with open(file_name, "r") as f:
        code = f.read()
    scope = globals()
    scope["self"] = scene
    exec(code, scope)

src/manim_studio/load_snippet.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QApplication
from PyQt6.QtGui import QIcon
import sys

from .editor_widget import EditorWidget
from .preview_widget import PreviewWidget
from .live_scene import LiveScene
from .communicate import Communicate
from .ai_widget import AIWidget
from .get_icon_file import get_icon_file


def main(scene_type=LiveScene, server=False) -> None:
    app = QApplication(sys.argv)
    icon = QIcon(get_icon_file())
    app.setWindowIcon(icon)
    communicate = Communicate()
    scene = scene_type(communicate)
    editor_window = EditorWidget(communicate, scene)
    preview_window = PreviewWidget(communicate, scene)
    preview_window.showFullScreen()
    editor_window.show()
    if AIWidget is not None:
        ai_window = AIWidget(communicate)
        ai_window.show()
    if server:
        from .server import ManimStudioServer
        server = ManimStudioServer(communicate)
        server.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main(LiveScene)

src/manim_studio/main.py
MathItYT/manim-studio
from .mathjax_mobject import MathJax

src/manim_studio/mathjax_mobject/__init__.py
MathItYT/manim-studio
from manim import logger, config, SVGMobject, VMobject, color_to_rgb
from colour import Color
from manim.utils.tex_file_writing import tex_hash
import inspect
import importlib
from pathlib import Path
import subprocess
import re


def create_dir_if_not_exists(dir_path: Path):
    if not dir_path.exists():
        dir_path.mkdir(parents=True)


def check_nodejs_installation():
    try:
        subprocess.run(["node", "--version"], check=True)
    except FileNotFoundError:
        raise FileNotFoundError(
            "Node.js is not installed. Please install node and try again.")
    except subprocess.CalledProcessError:
        raise RuntimeError(
            "Node.js is not installed correctly. Please install node and try again.")


def get_cli_path():
    module = importlib.import_module(
        "manim_studio.mathjax_mobject.mathjax_mobject")
    folder = Path(inspect.getfile(module)).parent
    return (folder / "mathjax-cli.js").absolute()


def install_dependencies_if_not_installed():
    logger.info("Checking if manim-mathjax is installed...")
    logger.info("Installing manim-mathjax...")
    try:
        subprocess.run(
            ["npm", "install", get_cli_path().parent], check=True, shell=True)
    except FileNotFoundError:
        raise FileNotFoundError(
            "NPM is not installed. Please install npm and try again.")
    except subprocess.CalledProcessError:
        raise RuntimeError(
            "An error occurred while installing manim-mathjax. Please try again.")
    logger.info("manim-mathjax is installed.")


def render_mathjax(mathjax_str: str, check_installation=True):
    """Render a MathJax string as a mobject.

    Solution from https://github.com/manim-kindergarten/ManimGL-MathJax/blob/main/manimgl_mathjax/mathjax.py

    Parameters
    ----------
    mathjax_str : str
        A MathJax string.
    **kwargs
        Keyword arguments to pass to the MathTex mobject.

    Returns
    -------
    MathTex
        A MathTex mobject.
    """
    if check_installation:
        check_nodejs_installation()
        install_dependencies_if_not_installed()
    cli_path = get_cli_path()
    p = subprocess.Popen(["node", str(cli_path)],
                         stdout=subprocess.PIPE, stdin=subprocess.PIPE)
    out, err = p.communicate(input=mathjax_str.encode("utf-8"))
    svg_str = out.decode("utf-8")

    error_match_obj = re.search(
        r"(?<=data\-mjx\-error\=\")(.*?)(?=\")", svg_str)
    if error_match_obj is not None:
        logger.error(
            "LaTeX Error!  Not a worry, it happens to the best of us.")
        logger.info(f"The error could be: `{error_match_obj.group()}`")
        raise ValueError("LaTeX Error")
    tex_dir = config.get_dir("tex_dir")
    tex_file = tex_dir / f"{tex_hash(mathjax_str)}.svg"
    create_dir_if_not_exists(config.get_dir("media_dir"))
    create_dir_if_not_exists(tex_dir)
    with open(tex_file, "wb") as f:
        f.write(out)
    return tex_file


class MathJax(SVGMobject):
    first_time = True

    def __init__(self, expr: str, font_size: int = 48, color=None, opacity=None, stroke_width=None, **kwargs):
        self.expr = expr
        self.font_size = font_size
        if color is None:
            color = VMobject().color
        if opacity is None:
            opacity = 1.0
        if stroke_width is None:
            stroke_width = 0.0
        self.colored_expr = self.get_colored_expr(self.expr, color)
        super().__init__(render_mathjax(self.colored_expr, self.__class__.first_time), **kwargs)
        self.__class__.first_time = False
        self.set_color(color, family=False)
        self.set_opacity(opacity)
        self.set_stroke(width=stroke_width)
        self.scale(0.2 * self.font_size / 48)

    def get_colored_expr(self, expr: str, color: Color):
        return f"\\textcolor[rgb]{{{','.join(map(str, color_to_rgb(color)))}}}{{{expr}}}"

src/manim_studio/mathjax_mobject/mathjax_mobject.py
MathItYT/manim-studio
from manim.utils.tex_file_writing import tex_to_svg_file as prerender_latex

src/manim_studio/prerender_latex.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QWidget, QLabel, QVBoxLayout
from PyQt6.QtCore import pyqtSlot
from PyQt6.QtGui import QPixmap, QImage
import numpy as np

from .communicate import Communicate
from .live_scene import LiveScene
from .render_thread import RenderThread


class PreviewWidget(QWidget):
    def __init__(self, communicate: Communicate, scene: LiveScene, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.communicate = communicate
        self.scene = scene
        self.setWindowTitle("Manim Studio - Preview")
        self.setGeometry(0, 0, 1920, 1080)
        self.label = QLabel()
        self.layout_ = QVBoxLayout()
        self.layout_.setContentsMargins(0, 0, 0, 0)
        self.label.setGeometry(0, 0, 1920, 1080)
        self.label.setScaledContents(True)
        self.layout_.addWidget(self.label)
        self.setLayout(self.layout_)
        self.render_thread = RenderThread(self.scene)
        self.render_thread.start()
        self.communicate.update_image.connect(self.update_image)
        self.communicate.update_image.emit(self.scene.renderer.get_frame())

    @pyqtSlot(np.ndarray)
    def update_image(self, image):
        self.label.setPixmap(QPixmap.fromImage(
            QImage(image, image.shape[1], image.shape[0], QImage.Format.Format_RGBA8888)))

src/manim_studio/preview_widget.py
MathItYT/manim-studio
from PyQt6.QtCore import QThread
from .live_scene import LiveScene


class RenderThread(QThread):
    def __init__(self, scene: LiveScene, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.scene = scene

    def run(self):
        self.scene.render()

src/manim_studio/render_thread.py
MathItYT/manim-studio
from manim_studio.server.main import ManimStudioServer
from manim_studio.server.server import Server

src/manim_studio/server/__init__.py
MathItYT/manim-studio
from manim_studio.server.server import Server
from PyQt6.QtWidgets import QWidget, QInputDialog, QLabel, QVBoxLayout, QPushButton, QLineEdit
from pyngrok import ngrok


class ManimStudioServer(QWidget):
    def __init__(self, communicate, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Manim Studio Server")
        self.communicate = communicate
        password_dialog = QInputDialog()
        password_dialog.setLabelText("Create a password for the server: ")
        password_dialog.setWindowTitle("Manim Studio Server")
        password_dialog.setModal(True)
        password_dialog.exec()
        self.password = password_dialog.textValue()
        self.server = Server("", 5555, self.password, self.communicate)
        self.server.start()
        self.label = QLabel()
        self.label.setText(
            f"Server started at {self.server.host}:{self.server.port}")
        self.ask_label = QLabel()
        self.ask_label.setText(
            "If you want to work collaboratively, you need to have installed ngrok.\nIf you click the button below, ngrok will be started and you will be able to share the link with your mates.")
        self.ask_label.setWordWrap(True)
        self.ask_label.setMargin(10)
        self.ask_label.setIndent(10)
        self.ask_label.setStyleSheet("background-color: #f0f0f0;")
        self.button = QPushButton("Start ngrok")
        self.button.clicked.connect(self.start_ngrok)
        self.layout = QVBoxLayout()
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.ask_label)
        self.layout.addWidget(self.button)
        self.setLayout(self.layout)

    def closeEvent(self, event):
        self.server.close()
        super().closeEvent(event)

    def start_ngrok(self):
        tunnel = ngrok.connect(5555, "tcp")
        self.button.setText("ngrok started")
        self.button.setEnabled(False)
        self.link_label = QLabel()
        self.link_label.setText(
            "ngrok started. You can share the host and port below with your mates.")
        self.link_label.setWordWrap(True)
        self.link_label.setMargin(10)
        self.link_label.setIndent(10)
        self.link_label.setStyleSheet("background-color: #f0f0f0;")
        self.layout.addWidget(self.link_label)
        self.link = QLineEdit()
        host, port = tunnel.public_url[6:].split(":")
        self.link.setText(f"HOST={host}; PORT={port}")
        self.link.setReadOnly(True)
        self.layout.addWidget(self.link)

src/manim_studio/server/main.py
MathItYT/manim-studio
import socket
from PyQt6.QtCore import QThread

from manim_studio.communicate import Communicate


class Server(QThread):
    def __init__(self, host: str, port: int, password: str, communicate: Communicate):
        super().__init__()
        self.host = host
        self.port = port
        self.password = password
        self.communicate = communicate
        self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.s.bind((self.host, self.port))
        self.s.listen()
        self.clients: list[socket.socket] = []
        self.threads: list[QThread] = []
        self.running = True

    def run(self):
        while self.running:
            client, address = self.s.accept()
            self.clients.append(client)
            self.threads.append(ClientThread(
                client, address, self.password, self.communicate))
            self.threads[-1].start()

    def close(self):
        self.running = False
        for client in self.clients:
            client.send(b"exit")
            client.close()
        for thread in self.threads:
            thread.quit()
            thread.wait()
        self.s.close()


class ClientThread(QThread):
    def __init__(self, client: socket.socket, address: tuple[str, int], password: str, communicate: Communicate):
        super().__init__()
        self.client = client
        self.address = address
        self.password = password
        self.communicate = communicate

    def run(self):
        msg = self.client.recv(1024)
        try:
            msg.decode("utf-8")
        except UnicodeDecodeError:
            self.client.send(b"Wrong password (UnicodeDecodeError)")
            self.client.close()
            return
        if msg.decode("utf-8") == self.password:
            self.client.send(b"Correct password")
            while True:
                msg = self.client.recv(1024)
                if msg.decode("utf-8") == "exit":
                    break
                self.communicate.update_scene.emit(msg.decode("utf-8"))
                self.client.send(b"Code executed")
            self.client.send(b"exit")
            self.client.close()
        else:
            self.client.send(b"Wrong password")
            self.client.close()

src/manim_studio/server/server.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QSlider

from .int_value_tracker import IntValueTracker


class Slider(QSlider):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.value_tracker = IntValueTracker(0)
        self.valueChanged.connect(self.value_tracker.set_value)

src/manim_studio/slider.py
MathItYT/manim-studio
from manim import ValueTracker, Mobject


class StringValueTracker(ValueTracker):
    def __init__(self, value="", **kwargs):
        Mobject.__init__(self, **kwargs)
        self.set_value(value)

    def get_value(self) -> str:
        return str(self.value)

    def set_value(self, value: str):
        self.value = str(value)

src/manim_studio/string_value_tracker.py
MathItYT/manim-studio
from PyQt6.QtWidgets import QTextEdit

from .string_value_tracker import StringValueTracker


class TextEditorWidget(QTextEdit):
    def __init__(self, name: str, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.value_tracker = StringValueTracker("")
        self.textChanged.connect(
            lambda: self.value_tracker.set_value(self.toPlainText()))
        self.setPlaceholderText(name)

src/manim_studio/text_editor_widget.py
MathItYT/manim-studio