diff --git a/manim/__main__.py b/manim/__main__.py index 8e8c9fba15..bac5a5ecec 100644 --- a/manim/__main__.py +++ b/manim/__main__.py @@ -4,7 +4,9 @@ from click_default_group import DefaultGroup from . import __version__, console -from .cli.cfg.commands import cfg +from .cli.cfg.group import cfg +from .cli.init.commands import init +from .cli.new.group import new from .cli.plugins.commands import plugins from .cli.render.commands import render from .constants import EPILOG @@ -41,6 +43,8 @@ def main(ctx): main.add_command(cfg) main.add_command(plugins) +main.add_command(init) +main.add_command(new) main.add_command(render) if __name__ == "__main__": diff --git a/manim/cli/cfg/commands.py b/manim/cli/cfg/group.py similarity index 100% rename from manim/cli/cfg/commands.py rename to manim/cli/cfg/group.py diff --git a/manim/cli/init/__init__.py b/manim/cli/init/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manim/cli/init/commands.py b/manim/cli/init/commands.py new file mode 100644 index 0000000000..6b0a4f5611 --- /dev/null +++ b/manim/cli/init/commands.py @@ -0,0 +1,31 @@ +"""Manim's init subcommand. + +Manim's init subcommand is accessed in the command-line interface via ``manim +init``. Here you can specify options, subcommands, and subgroups for the init +group. + +""" +from pathlib import Path + +import click + +from ...constants import CONTEXT_SETTINGS, EPILOG +from ...utils.file_ops import copy_template_files + + +@click.command( + context_settings=CONTEXT_SETTINGS, + epilog=EPILOG, +) +def init(): + """Sets up a project in current working directory with default settings. + + It copies files from templates directory and pastes them in the current working dir. + + The new project is set up with default settings. + """ + cfg = Path("manim.cfg") + if cfg.exists(): + raise FileExistsError(f"\t{cfg} exists\n") + else: + copy_template_files() diff --git a/manim/cli/new/__init__.py b/manim/cli/new/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manim/cli/new/group.py b/manim/cli/new/group.py new file mode 100644 index 0000000000..a09d369fe7 --- /dev/null +++ b/manim/cli/new/group.py @@ -0,0 +1,189 @@ +import configparser +from pathlib import Path + +import click + +from ... import console +from ...constants import CONTEXT_SETTINGS, EPILOG, QUALITIES +from ...utils.file_ops import ( + add_import_statement, + copy_template_files, + get_template_names, + get_template_path, +) + +CFG_DEFAULTS = { + "frame_rate": 30, + "background_color": "BLACK", + "background_opacity": 1, + "scene_names": "Default", + "resolution": (854, 480), +} + + +def select_resolution(): + """Prompts input of type click.Choice from user. Presents options from QUALITIES constant. + + Returns + ------- + :class:`tuple` + Tuple containing height and width. + """ + resolution_options = [] + for quality in QUALITIES.items(): + resolution_options.append( + (quality[1]["pixel_height"], quality[1]["pixel_width"]) + ) + resolution_options.pop() + choice = click.prompt( + "\nSelect resolution:\n", + type=click.Choice([f"{i[0]}p" for i in resolution_options]), + show_default=False, + default="480p", + ) + return [res for res in resolution_options if f"{res[0]}p" == choice][0] + + +def update_cfg(cfg_dict, project_cfg_path): + """Updates the manim.cfg file after reading it from the project_cfg_path. + + Parameters + ---------- + cfg : :class:`dict` + values used to update manim.cfg found project_cfg_path. + project_cfg_path : :class:`Path` + Path of manim.cfg file. + """ + config = configparser.ConfigParser() + config.read(project_cfg_path) + cli_config = config["CLI"] + for key, value in cfg_dict.items(): + if key == "resolution": + cli_config["pixel_height"] = str(value[0]) + cli_config["pixel_width"] = str(value[1]) + else: + cli_config[key] = str(value) + + with open(project_cfg_path, "w") as conf: + config.write(conf) + + +@click.command( + context_settings=CONTEXT_SETTINGS, + epilog=EPILOG, +) +@click.argument("project_name", type=Path, required=False) +@click.option( + "-d", + "--default", + "default_settings", + is_flag=True, + help="Default settings for project creation.", + nargs=1, +) +def project(default_settings, **args): + """Creates a new project. + + PROJECT_NAME is the name of the folder in which the new project will be initialized. + """ + if args["project_name"]: + project_name = args["project_name"] + else: + project_name = click.prompt("Project Name", type=Path) + + # in the future when implementing a full template system. Choices are going to be saved in some sort of config file for templates + template_name = click.prompt( + "Template", + type=click.Choice(get_template_names(), False), + default="Default", + ) + + if project_name.is_dir(): + console.print( + f"\nFolder [red]{project_name}[/red] exists. Please type another name\n" + ) + else: + project_name.mkdir() + new_cfg = dict() + new_cfg_path = Path.resolve(project_name / "manim.cfg") + + if not default_settings: + for key, value in CFG_DEFAULTS.items(): + if key == "scene_names": + new_cfg[key] = template_name + "Template" + elif key == "resolution": + new_cfg[key] = select_resolution() + else: + new_cfg[key] = click.prompt(f"\n{key}", default=value) + + console.print("\n", new_cfg) + if click.confirm("Do you want to continue?", default=True, abort=True): + copy_template_files(project_name, template_name) + update_cfg(new_cfg, new_cfg_path) + else: + copy_template_files(project_name, template_name) + update_cfg(CFG_DEFAULTS, new_cfg_path) + + +@click.command( + context_settings=CONTEXT_SETTINGS, + no_args_is_help=True, + epilog=EPILOG, +) +@click.argument("scene_name", type=str, required=True) +@click.argument("file_name", type=str, required=False) +def scene(**args): + """Inserts a SCENE to an existing FILE or creates a new FILE. + + SCENE is the name of the scene that will be inserted. + + FILE is the name of file in which the SCENE will be inserted. + """ + if not Path("main.py").exists(): + raise FileNotFoundError(f"{Path('main.py')} : Not a valid project direcotory.") + + template_name = click.prompt( + "template", + type=click.Choice(get_template_names(), False), + default="Default", + ) + scene = "" + with open(Path.resolve(get_template_path() / f"{template_name}.mtp")) as f: + scene = f.read() + scene = scene.replace(template_name + "Template", args["scene_name"], 1) + + if args["file_name"]: + file_name = Path(args["file_name"] + ".py") + + if file_name.is_file(): + # file exists so we are going to append new scene to that file + with open(file_name, "a") as f: + f.write("\n\n\n" + scene) + pass + else: + # file does not exist so we create a new file, append the scene and prepend the import statement + with open(file_name, "w") as f: + f.write("\n\n\n" + scene) + + add_import_statement(file_name) + else: + # file name is not provided so we assume it is main.py + # if main.py does not exist we do not continue + with open(Path("main.py"), "a") as f: + f.write("\n\n\n" + scene) + + +@click.group( + context_settings=CONTEXT_SETTINGS, + invoke_without_command=True, + no_args_is_help=True, + epilog=EPILOG, + help="Create a new project or insert a new scene.", +) +@click.pass_context +def new(ctx): + pass + + +new.add_command(project) +new.add_command(scene) diff --git a/manim/templates/Axes.mtp b/manim/templates/Axes.mtp new file mode 100644 index 0000000000..4cc4447076 --- /dev/null +++ b/manim/templates/Axes.mtp @@ -0,0 +1,11 @@ +class AxesTemplate(Scene): + def construct(self): + graph = Axes( + x_range=[-1,10,1], + y_range=[-1,10,1], + x_length=9, + y_length=6, + axis_config={"include_tip":False} + ) + labels = graph.get_axis_labels() + self.add(graph, labels) \ No newline at end of file diff --git a/manim/templates/Default.mtp b/manim/templates/Default.mtp new file mode 100644 index 0000000000..317ca7ba10 --- /dev/null +++ b/manim/templates/Default.mtp @@ -0,0 +1,12 @@ +class DefaultTemplate(Scene): + def construct(self): + circle = Circle() # create a circle + circle.set_fill(PINK, opacity=0.5) # set color and transparency + + square = Square() # create a square + square.flip(RIGHT) # flip horizontally + square.rotate(-3 * TAU / 8) # rotate a certain amount + + self.play(Create(square)) # animate the creation of the square + self.play(Transform(square, circle)) # interpolate the square into the circle + self.play(FadeOut(square)) # fade out animation diff --git a/manim/templates/MovingCamera.mtp b/manim/templates/MovingCamera.mtp new file mode 100644 index 0000000000..02ea5dc4e6 --- /dev/null +++ b/manim/templates/MovingCamera.mtp @@ -0,0 +1,8 @@ +class MovingCameraTemplate(MovingCameraScene): + def construct(self): + text = Text("Hello World").set_color(BLUE) + self.add(text) + self.camera.frame.save_state() + self.play(self.camera.frame.animate.set(width=text.width * 1.2)) + self.wait(0.3) + self.play(Restore(self.camera.frame)) diff --git a/manim/templates/template.cfg b/manim/templates/template.cfg new file mode 100644 index 0000000000..7be41783b6 --- /dev/null +++ b/manim/templates/template.cfg @@ -0,0 +1,7 @@ +[CLI] +frame_rate = 30 +pixel_height = 480 +pixel_width = 854 +background_color = BLACK +background_opacity = 1 +scene_names = DefaultScene diff --git a/manim/utils/file_ops.py b/manim/utils/file_ops.py index d69e1293bb..30b8898643 100644 --- a/manim/utils/file_ops.py +++ b/manim/utils/file_ops.py @@ -14,9 +14,12 @@ import subprocess as sp import time from pathlib import Path +from shutil import copyfile from manim import __version__, config, logger +from .. import console + def add_extension_if_not_present(file_name, extension): if file_name.suffix != extension: @@ -96,3 +99,67 @@ def open_media_file(file_writer): open_file(file_path, False) logger.info(f"Previewed File at: {file_path}") + + +def get_template_names(): + """Returns template names from the templates directory. + + Returns + ------- + :class:`list` + """ + template_path = Path.resolve(Path(__file__).parent.parent / "templates") + return [template_name.stem for template_name in template_path.glob("*.mtp")] + + +def get_template_path(): + """Returns the Path of templates directory. + + Returns + ------- + :class:`Path` + """ + return Path.resolve(Path(__file__).parent.parent / "templates") + + +def add_import_statement(file): + """Prepends an import statment in a file + + Parameters + ---------- + file : :class:`Path` + """ + with open(file, "r+") as f: + import_line = "from manim import *" + content = f.read() + f.seek(0, 0) + f.write(import_line.rstrip("\r\n") + "\n" + content) + + +def copy_template_files(project_dir=Path("."), template_name="Default"): + """Copies template files from templates dir to project_dir. + + Parameters + ---------- + project_dir : :class:`Path` + Path to project directory. + template_name : :class:`str` + Name of template. + """ + template_cfg_path = Path.resolve( + Path(__file__).parent.parent / "templates/template.cfg" + ) + template_scene_path = Path.resolve( + Path(__file__).parent.parent / f"templates/{template_name}.mtp" + ) + + if not template_cfg_path.exists(): + raise FileNotFoundError(f"{template_cfg_path} : file does not exist") + if not template_scene_path.exists(): + raise FileNotFoundError(f"{template_scene_path} : file does not exist") + + copyfile(template_cfg_path, Path.resolve(project_dir / "manim.cfg")) + console.print("\n\t[green]copied[/green] [blue]manim.cfg[/blue]\n") + copyfile(template_scene_path, Path.resolve(project_dir / "main.py")) + console.print("\n\t[green]copied[/green] [blue]main.py[/blue]\n") + add_import_statement(Path.resolve(project_dir / "main.py")) diff --git a/tests/test_commands.py b/tests/test_commands.py index 7095c6a59f..47408ed3a6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,4 +1,5 @@ import sys +from pathlib import Path from textwrap import dedent from click.testing import CliRunner @@ -61,3 +62,61 @@ def test_manim_plugins_subcommand(): Made with <3 by Manim Community developers. """ assert dedent(expected_output) == result.output + + +def test_manim_init_subcommand(): + command = ["init"] + runner = CliRunner() + runner.invoke(main, command, prog_name="manim") + + expected_manim_cfg = "" + expected_main_py = "" + + with open( + Path.resolve(Path(__file__).parent.parent / "manim/templates/template.cfg") + ) as f: + expected_manim_cfg = f.read() + + with open( + Path.resolve(Path(__file__).parent.parent / "manim/templates/Default.mtp") + ) as f: + expected_main_py = f.read() + + manim_cfg_path = Path("manim.cfg") + manim_cfg_content = "" + main_py_path = Path("main.py") + main_py_content = "" + with open(manim_cfg_path) as f: + manim_cfg_content = f.read() + + with open(main_py_path) as f: + main_py_content = f.read() + + manim_cfg_path.unlink() + main_py_path.unlink() + + assert ( + dedent(expected_manim_cfg + "from manim import *\n" + expected_main_py) + == manim_cfg_content + main_py_content + ) + + +def test_manim_new_command(): + command = ["new"] + runner = CliRunner() + result = runner.invoke(main, command, prog_name="manim") + expected_output = """\ +Usage: manim new [OPTIONS] COMMAND [ARGS]... + + Create a new project or insert a new scene. + +Options: + -h, --help Show this message and exit. + +Commands: + project Creates a new project. + scene Inserts a SCENE to an existing FILE or creates a new FILE. + + Made with <3 by Manim Community developers. +""" + assert dedent(expected_output) == result.output