├── .editorconfig ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── src ├── assets │ └── easymanim_ss.png └── easymanim │ ├── __init__.py │ ├── gui │ ├── __init__.py │ ├── preview_panel.py │ ├── properties_panel.py │ ├── statusbar_panel.py │ ├── timeline_panel.py │ └── toolbar_panel.py │ ├── interface │ ├── __init__.py │ └── manim_interface.py │ ├── logic │ ├── __init__.py │ └── scene_builder.py │ ├── main.py │ ├── main_app.py │ └── ui │ ├── __init__.py │ └── ui_manager.py └── tests ├── __init__.py ├── gui ├── __init__.py ├── test_preview_panel.py ├── test_properties_panel.py ├── test_statusbar_panel.py ├── test_timeline_panel.py └── test_toolbar_panel.py ├── interface ├── __init__.py └── test_manim_interface.py ├── logic ├── __init__.py └── test_scene_builder.py └── ui ├── __init__.py └── test_ui_manager.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] # Applies to all files 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | 13 | [*.py] # Python files 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [*.md] # Markdown files 18 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a CI, 31 | # but can also be written by the developer directly. 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | *.py,cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | cover/ 52 | 53 | # Environments 54 | .env 55 | .venv 56 | venv/ 57 | env/ 58 | ENV/ 59 | env.bak/ 60 | venv.bak/ 61 | 62 | # Spyder project settings 63 | .spyderproject 64 | .spyproject 65 | 66 | # Rope project settings 67 | .ropeproject 68 | 69 | # mkdocs documentation 70 | /site 71 | 72 | # mypy 73 | .mypy_cache/ 74 | .dmypy.json 75 | dmypy.json 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # VS Code settings 81 | .vscode/ 82 | 83 | # Manim output 84 | media/ 85 | files/ 86 | output/ 87 | *.log 88 | texput.log 89 | *_assets/ 90 | 91 | # Misc 92 | aa_ref_01/ 93 | tasks.md -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to EasyManim 2 | 3 | First off, thank you for considering contributing to EasyManim! We welcome any help to make this tool better and more accessible for everyone interested in Manim. 4 | 5 | ## How Can I Contribute? 6 | 7 | There are many ways to contribute, from reporting bugs to suggesting features, writing documentation, or contributing code. 8 | 9 | ### Reporting Bugs 10 | 11 | * **Check if the bug already exists:** Please search the existing issues on GitHub to see if someone else has already reported it. 12 | * **Be detailed:** If you're reporting a new bug, please include as much detail as possible: 13 | * Your operating system and version. 14 | * Python version and Manim version. 15 | * EasyManim version or commit hash if you're running from source. 16 | * Clear steps to reproduce the bug. 17 | * What you expected to happen and what actually happened. 18 | * Any error messages or stack traces from the console. 19 | 20 | ### Suggesting Enhancements or New Features 21 | 22 | * We'd love to hear your ideas for making EasyManim better! 23 | * Please open an issue on GitHub, clearly describe the feature you'd like to see, why it would be useful, and if possible, provide some examples or mockups of how it might work. 24 | 25 | ### Code Contributions 26 | 27 | If you'd like to contribute code, that's fantastic! Here's how to get started: 28 | 29 | 1. **Set up your Development Environment:** 30 | * Fork the repository on GitHub. 31 | * Clone your forked repository: `git clone https://github.com/tailwiinder/easymanim.git` 32 | * `cd easymanim` 33 | * Create and activate a Python virtual environment (see `README.md` for detailed steps). 34 | * Install dependencies: `pip install -r requirements.txt` 35 | * Install development dependencies: `pip install -r requirements-dev.txt` 36 | * Install the project in editable mode: `pip install -e .` 37 | 38 | 2. **Coding Style:** 39 | * Please try to follow the existing coding style in the project. 40 | * We aim for PEP 8 compliance, but clarity and consistency are key. 41 | * Add comments to your code where it clarifies complex logic. 42 | 43 | 3. **Make Your Changes:** 44 | * Create a new branch for your feature or bugfix: `git checkout -b feature/your-feature-name` or `git checkout -b fix/your-bugfix-name`. 45 | * Write your code and, if applicable, add or update tests. 46 | 47 | 4. **Testing:** 48 | * Run existing tests (if any are set up with pytest) to ensure your changes haven't broken anything. 49 | * Manually test your changes thoroughly to ensure they work as expected. 50 | 51 | 5. **Commit Your Changes:** 52 | * Make clear, concise commit messages. 53 | 54 | 6. **Submit a Pull Request (PR):** 55 | * Push your branch to your fork on GitHub: `git push origin feature/your-feature-name`. 56 | * Open a Pull Request from your forked repository to the main EasyManim repository. 57 | * Provide a clear title and description for your PR, explaining the changes you've made and why. 58 | * Link to any relevant issues if your PR addresses one. 59 | 60 | We'll review your PR as soon as possible. Thank you for your contribution! 61 | 62 | ## Questions? 63 | 64 | Feel free to open an issue if you have any questions about contributing. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 @tailwiinder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EasyManim: Visual Manim Scene Creator 2 | 3 | EasyManim is a user-friendly desktop application designed to simplify the creation of mathematical animations with the powerful [Manim Community](https://www.manim.community/) engine. It provides a graphical user interface (GUI) allowing users to visually construct scenes, add objects, edit properties, and render videos without directly writing Manim Python scripts for every detail. 4 | 5 | Inspired by the usability of modern visual editors, EasyManim aims to make Manim's capabilities more accessible to educators, content creators, and anyone interested in producing high-quality mathematical visualizations. 6 | 7 | ![EasyManim UI](./src/assets/easymanim_ss.png) 8 | 9 | ## Key Features (V0.1) 10 | 11 | * **Visual Scene Construction:** Add and manage Manim objects in a graphical environment. 12 | * **Supported Objects:** Create Circles, Squares, and Text objects. 13 | * **Property Editing:** Visually edit object properties such as: 14 | * Position (X, Y, Z coordinates) 15 | * Size (radius for circles, side length for squares) 16 | * Text Content & Font Size 17 | * Fill Color & Fill Opacity 18 | * Stroke (Border) Color, Stroke Width (for shapes), & Stroke Opacity 19 | * **Animation Assignment:** Assign basic introductory animations (e.g., FadeIn, Write, GrowFromCenter) to objects. 20 | * **Static Preview:** Quickly render a static preview image of the current scene. 21 | * **Video Rendering:** Render the full animation to an MP4 video file. 22 | * **User-Friendly Interface:** Built with Python and `ttkbootstrap` for a clean, modern look and feel. 23 | 24 | ## Tech Stack 25 | 26 | * **Python 3.12.3** 27 | * **Manim Community Edition** (tested with v0.18.0 and later) 28 | * **Tkinter** (via `ttkbootstrap` for modern styling) 29 | * **Pillow** (for image handling in previews) 30 | 31 | ## Prerequisites 32 | 33 | * Python 3.12.3 installed and added to your PATH. 34 | * A working Manim Community Edition installation. Please follow the [official Manim installation guide](https://docs.manim.community/en/stable/installation/index.html). This includes dependencies like FFmpeg, LaTeX, etc. 35 | 36 | ## Installation 37 | 38 | 1. **Clone the repository:** 39 | ```bash 40 | git clone https://github.com/tailwiinder/easymanim.git # Replace with your repo URL 41 | cd easymanim 42 | ``` 43 | 44 | 2. **Create and activate a Python virtual environment:** 45 | ```bash 46 | python -m venv venv 47 | # On Windows 48 | venv\Scripts\activate 49 | # On macOS/Linux 50 | source venv/bin/activate 51 | ``` 52 | 53 | 3. **Install dependencies:** 54 | ```bash 55 | pip install -r requirements.txt 56 | ``` 57 | If you plan to contribute or run tests, also install development dependencies: 58 | ```bash 59 | pip install -r requirements-dev.txt 60 | ``` 61 | 4. **Install the project in editable mode:** 62 | This step is important for resolving imports correctly, especially if you have a `src` layout. 63 | ```bash 64 | pip install -e . 65 | ``` 66 | 67 | ## How to Run 68 | 69 | After completing the installation steps, run the application using: 70 | 71 | ```bash 72 | python -m easymanim.main 73 | ``` 74 | (Adjust the path if your entry point `main.py` is located differently, e.g., `python src/easymanim/main.py` or directly `python main.py` if it's in the root and configured in `pyproject.toml` as a script). 75 | 76 | ## How to Use (Basic Workflow) 77 | 78 | 1. **Launch EasyManim.** 79 | 2. **Add Objects:** Use the "Add Circle", "Add Square", or "Add Text" buttons in the toolbar. Objects will appear on the timeline. 80 | 3. **Select an Object:** Click on an object's block in the timeline. Its properties will appear in the Properties Panel. 81 | 4. **Edit Properties:** Modify position, size, color (fill/stroke), text content, opacity, etc., in the Properties Panel. Select an animation from the dropdown. 82 | 5. **Preview Changes:** Click the "Refresh Preview" button to see a static image of your scene. 83 | 6. **Render Video:** When ready, click the "Render Video" button to generate an MP4 animation. 84 | 85 | ## Current Status 86 | 87 | EasyManim is currently at **V0.1**, a functional prototype demonstrating core viability. It supports basic object creation, property editing, a simple animation system, and video rendering. 88 | 89 | ## Future Direction (V2 and Beyond) 90 | 91 | The next major version (V2) aims to introduce advanced animation and timeline controls, such as: 92 | 93 | * Custom animation durations (`run_time`). 94 | * Staggered object introduction and animation start times. 95 | * Defined object visibility durations on the timeline. 96 | * (Potentially) A more interactive timeline for visual adjustment of these timings. 97 | 98 | We welcome contributions and suggestions to help EasyManim grow! 99 | 100 | ## Contributing 101 | 102 | We encourage contributions to EasyManim! Please see the `CONTRIBUTING.md` file for guidelines on how to report bugs, suggest features, and submit pull requests. 103 | 104 | ## License 105 | 106 | EasyManim is released under the **MIT License**. See the `LICENSE` file for more details. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "easymanim" 7 | version = "0.1.0" 8 | # Add other metadata later if needed (authors, description, etc.) 9 | 10 | [tool.setuptools.packages.find] 11 | where = ["src"] # Look for packages in the src directory -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-mock -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | manim>=0.18.0 2 | ttkbootstrap 3 | Pillow -------------------------------------------------------------------------------- /src/assets/easymanim_ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailwiinder/easymanim/7144e46c7b2a0064c9f21fb8d8a3d7ea4b2fb22f/src/assets/easymanim_ss.png -------------------------------------------------------------------------------- /src/easymanim/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/easymanim/gui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/easymanim/gui/preview_panel.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import ttkbootstrap as ttk 3 | from PIL import Image, ImageTk 4 | import io 5 | from typing import Optional, TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | # Avoid circular import at runtime 9 | from ..ui.ui_manager import UIManager 10 | 11 | class PreviewPanel(ttk.Frame): 12 | """Panel to display the Manim preview image and refresh button.""" 13 | 14 | def __init__(self, parent, ui_manager: 'UIManager'): 15 | """Initialize the PreviewPanel. 16 | 17 | Args: 18 | parent: The parent widget. 19 | ui_manager: The UIManager instance. 20 | """ 21 | super().__init__(parent, padding=5) 22 | self.ui_manager = ui_manager 23 | self.canvas: Optional[tk.Canvas] = None 24 | self.refresh_button: Optional[ttk.Button] = None 25 | self._photo_image = None # Keep reference to avoid GC 26 | self._image_on_canvas = None 27 | self._placeholder_id = None 28 | 29 | self._create_widgets() 30 | self._bind_events() 31 | self.show_idle_state() # Start in idle state 32 | 33 | def _create_widgets(self): 34 | """Create canvas and button widgets.""" 35 | # Configure grid 36 | self.rowconfigure(0, weight=1) # Canvas row expands 37 | self.columnconfigure(0, weight=1) # Canvas col expands 38 | 39 | self.canvas = tk.Canvas(self, bg="gray85", bd=1, relief="sunken") 40 | self.canvas.grid(row=0, column=0, sticky="nsew") 41 | 42 | self.refresh_button = ttk.Button( 43 | self, 44 | text="Refresh Preview", 45 | # command=self.ui_manager.handle_refresh_preview_request # Bind in _bind_events 46 | bootstyle="success" 47 | ) 48 | self.refresh_button.grid(row=1, column=0, pady=(5, 0), sticky="ew") 49 | 50 | def _bind_events(self): 51 | """Bind events.""" 52 | if self.refresh_button: 53 | self.refresh_button.config(command=self.ui_manager.handle_refresh_preview_request) 54 | # Bind configure to redraw placeholder like in Timeline 55 | if self.canvas: 56 | self.canvas.bind("", self._draw_placeholder) 57 | 58 | def _draw_placeholder(self, event=None): 59 | """Draw placeholder text if no image is present.""" 60 | if not self.canvas: 61 | return 62 | 63 | # If an image exists, don't draw placeholder 64 | if self._image_on_canvas: 65 | if self._placeholder_id: 66 | self.canvas.delete(self._placeholder_id) 67 | self._placeholder_id = None 68 | return 69 | 70 | # Delete previous placeholder if switching back 71 | if self._placeholder_id: 72 | self.canvas.delete(self._placeholder_id) 73 | self._placeholder_id = None 74 | 75 | canvas_width = self.canvas.winfo_width() 76 | canvas_height = self.canvas.winfo_height() 77 | 78 | # Fallback to configured size if needed 79 | if canvas_width <= 1: canvas_width = self.canvas.cget("width") 80 | if canvas_height <= 1: canvas_height = self.canvas.cget("height") 81 | try: 82 | canvas_width = int(canvas_width) 83 | canvas_height = int(canvas_height) 84 | except ValueError: 85 | return # Cannot draw 86 | 87 | if canvas_width > 1 and canvas_height > 1: 88 | placeholder_text = "Click 'Refresh Preview' to see output" 89 | self._placeholder_id = self.canvas.create_text( 90 | canvas_width / 2, canvas_height / 2, 91 | text=placeholder_text, 92 | fill="grey50", 93 | tags=("placeholder",) # Tag for finding in tests 94 | ) 95 | 96 | # --- Public Methods (Called by UIManager) --- 97 | 98 | def display_image(self, image_bytes: bytes): 99 | """Display the rendered preview image on the canvas.""" 100 | if not self.canvas: 101 | return 102 | 103 | # Clear previous items (placeholder, rendering text, or old image) 104 | self.canvas.delete("placeholder") 105 | self.canvas.delete("rendering_text") 106 | if self._image_on_canvas: 107 | self.canvas.delete(self._image_on_canvas) 108 | self._image_on_canvas = None 109 | if self._placeholder_id: 110 | self._placeholder_id = None # Placeholder already deleted by tag 111 | 112 | try: 113 | # Load image data 114 | image = Image.open(io.BytesIO(image_bytes)) 115 | self._photo_image = ImageTk.PhotoImage(image) # Keep reference! 116 | 117 | # Get canvas center coordinates 118 | canvas_width = self.canvas.winfo_width() 119 | canvas_height = self.canvas.winfo_height() 120 | # Fallback to configured size if needed 121 | if canvas_width <= 1: canvas_width = self.canvas.cget("width") 122 | if canvas_height <= 1: canvas_height = self.canvas.cget("height") 123 | try: 124 | canvas_width = int(canvas_width) 125 | canvas_height = int(canvas_height) 126 | except ValueError: 127 | canvas_width, canvas_height = 300, 200 # Default if conversion fails 128 | 129 | center_x = canvas_width / 2 130 | center_y = canvas_height / 2 131 | 132 | # Create image on canvas 133 | self._image_on_canvas = self.canvas.create_image( 134 | center_x, 135 | center_y, 136 | image=self._photo_image, 137 | tags=("preview_image",) 138 | ) 139 | print(f"display_image: Displayed image with ID {self._image_on_canvas}") # Debug 140 | 141 | except Exception as e: 142 | print(f"[PreviewPanel Error] Failed to display image: {e}") 143 | # Optionally show an error message on the canvas 144 | self._draw_placeholder() # Revert to placeholder on error 145 | 146 | def show_rendering_state(self): 147 | """Update UI to show preview is rendering (disable button, update text).""" 148 | if self.refresh_button: 149 | self.refresh_button.config(state=tk.DISABLED) 150 | if self.canvas: 151 | # Clear previous image/placeholder 152 | if self._image_on_canvas: 153 | self.canvas.delete(self._image_on_canvas) 154 | self._image_on_canvas = None 155 | if self._placeholder_id: 156 | self.canvas.delete(self._placeholder_id) 157 | self._placeholder_id = None 158 | 159 | # Draw "Rendering..." text 160 | # TODO: Add a dedicated ID for rendering text if needed 161 | canvas_width = self.canvas.winfo_width() 162 | canvas_height = self.canvas.winfo_height() 163 | # Use fallback size similar to _draw_placeholder 164 | if canvas_width <= 1: canvas_width = self.canvas.cget("width") 165 | if canvas_height <= 1: canvas_height = self.canvas.cget("height") 166 | try: 167 | canvas_width = int(canvas_width) 168 | canvas_height = int(canvas_height) 169 | if canvas_width > 1 and canvas_height > 1: 170 | # Ensure no duplicate rendering text exists 171 | self.canvas.delete("rendering_text") 172 | self.canvas.create_text( 173 | canvas_width / 2, canvas_height / 2, 174 | text="Rendering Preview...", 175 | fill="black", 176 | tags=("rendering_text",) # Tag for finding/deleting 177 | ) 178 | except ValueError: 179 | pass # Cannot draw 180 | 181 | def show_idle_state(self): 182 | """Update UI to show idle state (enable button, show placeholder/image).""" 183 | if self.refresh_button: 184 | self.refresh_button.config(state=tk.NORMAL) 185 | 186 | # Remove rendering text if it exists 187 | if self.canvas: 188 | self.canvas.delete("rendering_text") 189 | 190 | # Decide whether to draw placeholder or keep existing image 191 | if self._image_on_canvas: 192 | pass # Keep image 193 | else: 194 | self._draw_placeholder() -------------------------------------------------------------------------------- /src/easymanim/gui/properties_panel.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import ttkbootstrap as ttk 3 | from tkinter.colorchooser import askcolor 4 | from typing import Optional, Any, TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | # Avoid circular import at runtime, only needed for type hinting 8 | from ..ui.ui_manager import UIManager 9 | 10 | class PropertiesPanel(ttk.Frame): 11 | """A panel to display and edit properties of the selected object.""" 12 | 13 | # Store default properties centrally if they become complex 14 | DEFAULT_PROPS = { 15 | 'position': (0.0, 0.0, 0.0), 16 | 'color': '#FFFFFF', 17 | 'opacity': 1.0, 18 | # Object-specific defaults will be merged 19 | } 20 | OBJECT_DEFAULTS = { 21 | 'Circle': {'radius': 0.5}, 22 | 'Square': {'side_length': 1.0}, 23 | 'Text': {'text': 'Hello', 'font_size': 48} 24 | } 25 | # Available animations (could be dynamically fetched later) 26 | ANIMATIONS = ["None", "FadeIn", "GrowFromCenter", "Write"] # Example 27 | 28 | def __init__(self, parent, ui_manager: 'UIManager'): 29 | """Initialize the PropertiesPanel. 30 | 31 | Args: 32 | parent: The parent widget. 33 | ui_manager: The UIManager instance. 34 | """ 35 | super().__init__(parent, padding=10) 36 | self.ui_manager = ui_manager 37 | self.current_object_id: Optional[str] = None 38 | self.widgets = {} # Store property widgets for easy access/clearing 39 | self._placeholder_label = None 40 | 41 | # Create initial placeholder 42 | self.show_placeholder() 43 | 44 | def show_placeholder(self): 45 | """Clear existing widgets and display the placeholder message.""" 46 | self._clear_widgets() 47 | self._placeholder_label = ttk.Label( 48 | self, 49 | text="Select an object to edit properties.", 50 | anchor=tk.CENTER 51 | ) 52 | self._placeholder_label.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) 53 | self.rowconfigure(0, weight=1) 54 | self.columnconfigure(0, weight=1) 55 | 56 | def _clear_widgets(self): 57 | """Destroy all current property widgets and the placeholder.""" 58 | self.rowconfigure(0, weight=0) 59 | self.columnconfigure(0, weight=0) 60 | self.columnconfigure(1, weight=0) # Reset column 1 used by display_properties 61 | 62 | # Destroy ALL direct children widgets 63 | for child in self.winfo_children(): 64 | child.destroy() 65 | 66 | # Reset internal references 67 | self.widgets = {} 68 | # if self._placeholder_label: 69 | # self._placeholder_label.destroy() # Already destroyed by loop above 70 | self._placeholder_label = None 71 | 72 | # --- Public Methods (Called by UIManager) --- 73 | 74 | def display_properties(self, obj_id: str, props: dict): 75 | """Display the properties for the given object.""" 76 | self.current_object_id = obj_id 77 | self._clear_widgets() # Remove placeholder or previous properties 78 | 79 | # Define the order, ensuring keys match SceneBuilder's 'properties' dict 80 | # 'type' is usually not edited here. 'pos_x', 'pos_y', 'pos_z' are individual. 81 | prop_order = [ 82 | 'text_content', 'font_size', 'radius', 'side_length', 83 | 'pos_x', 'pos_y', 'pos_z', 84 | 'fill_color', 'opacity', # opacity is fill_opacity 85 | 'stroke_color', 'stroke_width', 'stroke_opacity', 86 | 'animation' 87 | ] 88 | 89 | row = 0 90 | # Create Label and Widget for each property 91 | # First, iterate defined order 92 | for key in prop_order: 93 | if key in props: 94 | value = props[key] 95 | self._create_property_widget(key, value, row) 96 | row += 1 97 | 98 | # Second, iterate any remaining props not in prop_order (optional, for robustness) 99 | # for key, value in props.items(): 100 | # if key not in prop_order and key != 'type': # Avoid re-creating or showing 'type' 101 | # self._create_property_widget(key, value, row) 102 | # row += 1 103 | 104 | # Configure column weights if using grid 105 | self.columnconfigure(1, weight=1) 106 | 107 | def _create_property_widget(self, key: str, value: Any, row: int): 108 | """Helper to create and grid a label and appropriate widget for a property.""" 109 | if key == 'type': 110 | return 111 | 112 | display_key_name = key.replace('_', ' ').capitalize() 113 | if key == 'fill_color': 114 | display_key_name = 'Fill Color' 115 | elif key == 'opacity': 116 | display_key_name = 'Fill Opacity' 117 | elif key == 'stroke_color': 118 | display_key_name = 'Stroke Color' 119 | elif key == 'stroke_width': 120 | display_key_name = 'Stroke Width' 121 | elif key == 'stroke_opacity': 122 | display_key_name = 'Stroke Opacity' 123 | 124 | label = ttk.Label(self, text=f"{display_key_name}:") 125 | label.grid(row=row, column=0, sticky=tk.W, padx=5, pady=2) 126 | 127 | widget = None 128 | if key == 'fill_color' or key == 'stroke_color': 129 | widget = self._create_color_picker(key, str(value)) 130 | widget.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=2) 131 | elif key == 'animation': 132 | widget = self._create_animation_dropdown(key, str(value)) 133 | widget.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=2) 134 | elif key == 'text_content': 135 | widget = self._create_entry(key, value, is_text=True) 136 | widget.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=2) 137 | # Default for numerical entries 138 | elif key in ['pos_x', 'pos_y', 'pos_z', 'radius', 'side_length', 'opacity', 'font_size', 'stroke_width', 'stroke_opacity']: 139 | # For shapes, stroke_width should be editable. For Text, it might not be present in props from SceneBuilder. 140 | # We should only create entry if key is actually in props (checked by caller display_properties) 141 | widget = self._create_entry(key, value) 142 | widget.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=2) 143 | else: 144 | # Fallback for any other properties, display as non-editable text for now 145 | widget = ttk.Label(self, text=str(value)) 146 | widget.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=2) 147 | self.widgets[key] = widget # Store even if non-editable 148 | 149 | # --- Widget Creation Helpers (Called by display_properties) --- 150 | 151 | def _create_entry(self, key: str, value: Any, is_text: bool = False): 152 | """Helper to create a validated ttk.Entry widget.""" 153 | entry = ttk.Entry(self) 154 | entry.insert(0, str(value)) 155 | entry._property_key = key # Store property name for callbacks 156 | self.widgets[key] = entry 157 | 158 | if not is_text: 159 | # Register the validation command 160 | vcmd = (self.register(self._validate_float), '%P') # %P is value_if_allowed 161 | entry.config(validate='key', validatecommand=vcmd) # Validate on each key press 162 | # Bind FocusOut and Return to the general property change handler 163 | entry.bind("", lambda e, k=key: self._on_property_changed(e, k)) 164 | entry.bind("", lambda e, k=key: self._on_property_changed(e, k)) 165 | else: 166 | # Bind text entry changes too 167 | entry.bind("", lambda e, k=key: self._on_property_changed(e, k)) 168 | entry.bind("", lambda e, k=key: self._on_property_changed(e, k)) 169 | 170 | return entry 171 | 172 | def _create_color_picker(self, key: str, value: str): 173 | """Helper to create a color picker button and swatch.""" 174 | color_frame = ttk.Frame(self) 175 | 176 | # Color Swatch (Label with background color) 177 | swatch = ttk.Label(color_frame, text=" ", background=value, relief="sunken", borderwidth=1) 178 | swatch.pack(side=tk.LEFT, padx=5) 179 | 180 | # Button to open color picker 181 | button = ttk.Button(color_frame, text="Choose Color...", 182 | command=lambda k=key, s=swatch: self._on_pick_color(k, s)) 183 | button.pack(side=tk.LEFT) 184 | 185 | button._property_key = key # Store property name 186 | self.widgets[key] = button # Or maybe store the frame? 187 | # Store swatch separately if needed for update 188 | self.widgets[f"{key}_swatch"] = swatch 189 | 190 | return color_frame 191 | 192 | def _create_animation_dropdown(self, key: str, value: str): 193 | """Helper to create an animation selection Combobox.""" 194 | combo = ttk.Combobox(self, values=self.ANIMATIONS, state="readonly") 195 | combo.set(value if value in self.ANIMATIONS else self.ANIMATIONS[0]) # Set current or default 196 | combo._property_key = key 197 | self.widgets[key] = combo 198 | 199 | # Binding for when a selection is made (for Phase 2: Editing) 200 | combo.bind("<>", lambda e, k=key: self._on_animation_selected(e, k)) 201 | return combo 202 | 203 | # --- Validation and Event Handlers --- 204 | 205 | def _validate_float(self, value_if_allowed: str) -> bool: 206 | """Validation command for float entries.""" 207 | if not value_if_allowed: 208 | return True # Allow empty entry 209 | try: 210 | float(value_if_allowed) 211 | return True 212 | except ValueError: 213 | return False 214 | 215 | def _on_property_changed(self, event, key: str, is_pos: bool = False, axis: Optional[int] = None): 216 | """Handle Entry widget update (FocusOut, Return key).""" 217 | widget = event.widget 218 | # Check if widget still exists (it might have been destroyed by a quick subsequent selection) 219 | if not widget.winfo_exists(): 220 | return 221 | 222 | new_value_str = widget.get() 223 | print(f"_on_property_changed: key={key}, axis={axis}, value='{new_value_str}'") # Debug 224 | 225 | if self.current_object_id is None: 226 | print("_on_property_changed: No object selected.") # Debug 227 | return 228 | 229 | try: 230 | if is_pos: 231 | # Position needs special handling for tuple update 232 | new_value = float(new_value_str) 233 | # Get current position tuple from UI manager or SceneBuilder? 234 | # For now, assume we pass axis and value separately 235 | self.ui_manager.handle_property_change(self.current_object_id, key, new_value, axis_index=axis) 236 | elif key in ['radius', 'side_length', 'opacity', 'font_size', 'pos_x', 'pos_y', 'pos_z', 'stroke_width', 'stroke_opacity']: 237 | new_value = float(new_value_str) # Convert to float 238 | self.ui_manager.handle_property_change(self.current_object_id, key, new_value) 239 | elif key == 'text_content' or key == 'animation': # Handle animation as string 240 | new_value = new_value_str # Text/Animation is already a string 241 | # For animation, _on_animation_selected handles UIManager call for Combobox 242 | # This branch for _on_property_changed would only be relevant if animation was an Entry 243 | if key == 'text_content': 244 | self.ui_manager.handle_property_change(self.current_object_id, key, new_value) 245 | elif key == 'fill_color' or key == 'stroke_color': # For color picker, _on_pick_color handles the UIManager call 246 | pass # Handled by _on_pick_color 247 | else: 248 | # Fallback? Or should only known keys trigger this? 249 | print(f"_on_property_changed: Unknown key type '{key}'") 250 | return # Don't call UIManager for unknown keys 251 | 252 | except ValueError: 253 | print(f"_on_property_changed: Invalid value '{new_value_str}' for key '{key}'. Update aborted.") 254 | # Optionally revert the widget to the last known good value 255 | pass 256 | 257 | def _on_pick_color(self, key: str, swatch_widget: ttk.Label): 258 | """Handle the color picker button click.""" 259 | # key argument here should consistently be what SceneBuilder expects, e.g., 'fill_color' 260 | if self.current_object_id is None: 261 | print("_on_pick_color: No object selected.") # Debug 262 | return 263 | 264 | # Get current color to set as initialcolor in askcolor dialog 265 | current_color = "#FFFFFF" # Default 266 | # Assuming color is stored in props; SceneBuilder would be the source of truth. 267 | # For now, try to get it from the swatch if available 268 | try: 269 | current_color = swatch_widget.cget("background") 270 | except tk.TclError: # Widget might not exist or prop not set 271 | pass 272 | 273 | new_color_tuple = askcolor(initialcolor=current_color, title="Choose Color") 274 | if new_color_tuple and new_color_tuple[1]: # Check if a color was chosen (result is tuple (rgb, hex)) 275 | new_color_hex = new_color_tuple[1] 276 | print(f"_on_pick_color: New color selected: {new_color_hex} for key {key}") # Debug 277 | swatch_widget.config(background=new_color_hex) 278 | # Notify UIManager (for Phase 2: Editing) 279 | self.ui_manager.handle_property_change(self.current_object_id, key, new_color_hex) 280 | 281 | def _on_animation_selected(self, event, key: str): 282 | """Handle Combobox selection change for animation.""" 283 | # key argument here should be 'animation' 284 | widget = event.widget 285 | new_value = widget.get() 286 | print(f"_on_animation_selected: key={key}, value='{new_value}'") # Debug 287 | 288 | if self.current_object_id is None: 289 | print("_on_animation_selected: No object selected.") # Debug 290 | return 291 | 292 | # Notify UIManager (for Phase 2: Editing) 293 | self.ui_manager.handle_animation_change(self.current_object_id, new_value) -------------------------------------------------------------------------------- /src/easymanim/gui/statusbar_panel.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import ttkbootstrap as ttk 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | # Avoid circular import at runtime 7 | from ..ui.ui_manager import UIManager 8 | 9 | class StatusBarPanel(ttk.Frame): 10 | """A simple status bar panel to display messages.""" 11 | 12 | def __init__(self, parent, ui_manager: 'UIManager'): 13 | """Initialize the StatusBarPanel. 14 | 15 | Args: 16 | parent: The parent widget. 17 | ui_manager: The UIManager instance (passed for consistency). 18 | """ 19 | # Use relief and padding common for status bars 20 | super().__init__(parent, padding=(5, 2), relief="groove", borderwidth=1) 21 | self.ui_manager = ui_manager # Store even if not used directly 22 | 23 | self.status_var = tk.StringVar(value="Ready") # Default text 24 | 25 | status_label = ttk.Label( 26 | self, 27 | textvariable=self.status_var, 28 | anchor=tk.W # Anchor text to the left 29 | ) 30 | status_label.pack(fill=tk.X, expand=True) 31 | 32 | def set_status(self, text: str): 33 | """Update the text displayed in the status bar.""" 34 | self.status_var.set(text) -------------------------------------------------------------------------------- /src/easymanim/gui/timeline_panel.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import ttkbootstrap as ttk 3 | from typing import Optional 4 | 5 | class TimelinePanel(ttk.Frame): 6 | """A panel that displays object blocks on a timeline canvas.""" 7 | 8 | def __init__(self, parent, ui_manager): 9 | """Initialize the TimelinePanel. 10 | 11 | Args: 12 | parent: The parent widget. 13 | ui_manager: The UIManager instance to handle selections. 14 | """ 15 | super().__init__(parent, padding=5) 16 | self.ui_manager = ui_manager 17 | self.canvas = None 18 | self.object_canvas_items = {} # Map canvas item ID -> obj_id 19 | self._placeholder_id = None 20 | 21 | self._create_widgets() 22 | self._bind_events() 23 | 24 | def _create_widgets(self): 25 | """Create the canvas and initial placeholder text.""" 26 | # Use a standard tk.Canvas for drawing 27 | self.canvas = tk.Canvas(self, bg="white", bd=1, relief="sunken") 28 | self.canvas.pack(fill=tk.BOTH, expand=True) 29 | 30 | # Draw placeholder text initially - centered 31 | # We need to wait until the canvas has a size to center properly, 32 | # so we bind to the event. 33 | self._draw_placeholder_text() 34 | 35 | def _bind_events(self): 36 | """Bind events for the canvas.""" 37 | # Redraw placeholder when canvas size changes 38 | self.canvas.bind("", self._on_canvas_configure) 39 | # Handle clicks on the canvas 40 | self.canvas.bind("", self._on_canvas_click) 41 | 42 | def _draw_placeholder_text(self, event=None): 43 | """Draw or redraw the placeholder text centered on the canvas.""" 44 | if self.canvas is None: 45 | return 46 | 47 | # Delete previous placeholder if it exists 48 | if self._placeholder_id: 49 | self.canvas.delete(self._placeholder_id) 50 | self._placeholder_id = None 51 | 52 | # Only draw if no actual objects are present (check mapping) 53 | print(f"_draw_placeholder_text: Checking condition: not self.object_canvas_items = {not self.object_canvas_items}") # Debug 54 | if not self.object_canvas_items: 55 | canvas_width = self.canvas.winfo_width() 56 | canvas_height = self.canvas.winfo_height() 57 | print(f"_draw_placeholder_text: Canvas size reported = ({canvas_width}, {canvas_height})") # Debug 58 | 59 | # Fallback: Use configured size if reported size is invalid (e.g., 1x1 during init) 60 | if canvas_width <= 1: 61 | canvas_width = self.canvas.cget("width") 62 | print(f"_draw_placeholder_text: Using configured width: {canvas_width}") # Debug 63 | if canvas_height <= 1: 64 | canvas_height = self.canvas.cget("height") 65 | print(f"_draw_placeholder_text: Using configured height: {canvas_height}") # Debug 66 | 67 | # Ensure canvas has a valid size before drawing (convert cget result to int) 68 | try: 69 | canvas_width = int(canvas_width) 70 | canvas_height = int(canvas_height) 71 | except ValueError: 72 | print("_draw_placeholder_text: Error converting configured size to int") # Debug 73 | return # Cannot draw if size is not numerical 74 | 75 | if canvas_width > 1 and canvas_height > 1: 76 | placeholder_text = "Timeline - Add objects using the toolbar" 77 | # Tag the item so the test can find it 78 | self._placeholder_id = self.canvas.create_text( 79 | canvas_width / 2, 80 | canvas_height / 2, 81 | text=placeholder_text, 82 | fill="grey", 83 | tags=("placeholder_text",) # Assign tag 84 | ) 85 | print(f"_draw_placeholder_text: Created placeholder with ID {self._placeholder_id}") # Debug 86 | else: 87 | print("_draw_placeholder_text: Condition (width > 1 and height > 1) failed.") # Debug 88 | else: 89 | print("_draw_placeholder_text: Condition (not self.object_canvas_items) failed.") # Debug 90 | 91 | def _on_canvas_configure(self, event): 92 | """Handle canvas resize events, redraw placeholder if needed.""" 93 | # Redraw placeholder AND existing blocks if needed (e.g., width changes) 94 | self._draw_placeholder_text() 95 | # TODO: Consider redrawing/adjusting existing blocks if canvas width changes significantly 96 | 97 | # --- Public Methods (to be called by UIManager) --- 98 | 99 | def add_block(self, obj_id: str, obj_type: str): 100 | """Add a visual block representing an object to the timeline.""" 101 | if self.canvas is None: 102 | return # Should not happen if initialized correctly 103 | 104 | # 1. Remove placeholder if it exists 105 | if self._placeholder_id: 106 | self.canvas.delete(self._placeholder_id) 107 | self._placeholder_id = None 108 | 109 | # 2. Calculate position for the new block 110 | num_blocks = len(self.object_canvas_items) 111 | block_height = 30 # Configurable height for each block 112 | padding = 5 113 | y_start = padding + num_blocks * (block_height + padding) 114 | x_start = padding 115 | # Use current canvas width for the block width 116 | # Note: might need adjustments if canvas resizes later 117 | canvas_width = self.canvas.winfo_width() 118 | if canvas_width <= 1: canvas_width = 200 # Default width if not rendered yet 119 | x_end = canvas_width - padding 120 | y_end = y_start + block_height 121 | 122 | # Define tags for easy finding/grouping 123 | obj_tag = f"obj_{obj_id}" 124 | block_tag = "timeline_block" # General tag for all blocks 125 | 126 | # 3. Draw Rectangle 127 | rect_id = self.canvas.create_rectangle( 128 | x_start, y_start, x_end, y_end, 129 | fill="lightblue", # Default fill 130 | activefill="skyblue", # Fill when mouse is over 131 | outline="black", 132 | tags=(obj_tag, block_tag) # Apply both tags 133 | ) 134 | 135 | # 4. Draw Text inside the rectangle 136 | text_content = f"{obj_type}: {obj_id}" 137 | text_id = self.canvas.create_text( 138 | x_start + padding, y_start + block_height / 2, 139 | anchor=tk.W, # Anchor text to the West (left) 140 | text=text_content, 141 | tags=(obj_tag, block_tag) # Apply both tags 142 | ) 143 | 144 | # 5. Store mapping (using rectangle ID as the primary reference) 145 | self.object_canvas_items[rect_id] = obj_id 146 | # Optionally store text_id too if needed later, maybe map obj_id -> (rect_id, text_id) 147 | 148 | # 6. Update scroll region (important for potential future scrolling) 149 | self.canvas.config(scrollregion=self.canvas.bbox(tk.ALL)) 150 | 151 | def highlight_block(self, selected_obj_id: Optional[str]): 152 | """Highlight the block corresponding to selected_obj_id, deselect others.""" 153 | if not self.canvas: 154 | return 155 | 156 | default_outline = "black" 157 | default_width = 1 158 | highlight_outline = "red" 159 | highlight_width = 2 160 | 161 | # Iterate through all managed object blocks 162 | for rect_id, obj_id in self.object_canvas_items.items(): 163 | try: 164 | # Check if item still exists and is a rectangle 165 | if self.canvas.type(rect_id) == "rectangle": 166 | if obj_id == selected_obj_id: 167 | # Apply highlight style 168 | self.canvas.itemconfig(rect_id, outline=highlight_outline, width=highlight_width) 169 | # Optional: Raise the selected item to the top 170 | # self.canvas.tag_raise(rect_id) 171 | # if text_id: self.canvas.tag_raise(text_id) # Need text_id mapping too 172 | else: 173 | # Apply default style 174 | self.canvas.itemconfig(rect_id, outline=default_outline, width=default_width) 175 | except tk.TclError: 176 | # Item might have been deleted externally? Log or ignore. 177 | print(f"[TimelinePanel Warning] Could not configure item {rect_id} during highlight.") 178 | continue 179 | 180 | # --- Event Handlers --- 181 | 182 | def _on_canvas_click(self, event): 183 | """Handle clicks on the timeline canvas to select objects.""" 184 | if not self.canvas: 185 | return 186 | 187 | # Find items directly overlapping the click coordinates 188 | overlapping_items = self.canvas.find_overlapping(event.x, event.y, event.x, event.y) 189 | 190 | selected_obj_id = None 191 | if overlapping_items: 192 | # Iterate overlapping items (usually just one, but check just in case) 193 | for item_id in overlapping_items: 194 | tags = self.canvas.gettags(item_id) 195 | print(f"_on_canvas_click: Click near ({event.x}, {event.y}), found overlapping item {item_id} with tags: {tags}") # Debug 196 | # Find the object ID from the specific object tag 197 | for tag in tags: 198 | print(f"_on_canvas_click: Checking tag: {tag}") # Debug 199 | if tag.startswith("obj_"): 200 | selected_obj_id = tag[4:] # Extract the ID after "obj_" 201 | print(f"_on_canvas_click: Found obj_id: {selected_obj_id} from item {item_id}") # Debug 202 | break # Found the relevant tag for this item 203 | if selected_obj_id: 204 | break # Found the object ID from one of the overlapping items 205 | 206 | if selected_obj_id: 207 | # Found an object block (rect or text) under the cursor 208 | print(f"Timeline item {item_id} clicked, selecting obj_id={selected_obj_id}") # Debug 209 | self.ui_manager.handle_timeline_selection(selected_obj_id) 210 | else: 211 | # No item with an "obj_" tag was directly under the cursor 212 | print(f"Timeline background clicked at ({event.x}, {event.y})") # Debug 213 | self.ui_manager.handle_timeline_selection(None) -------------------------------------------------------------------------------- /src/easymanim/gui/toolbar_panel.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import ttkbootstrap as ttk 3 | 4 | # Assuming UIManager is in src/easymanim/ui/ui_manager.py 5 | # We need it for type hinting potentially, but not runtime logic here 6 | # from ..ui.ui_manager import UIManager # Use relative import 7 | 8 | class ToolbarPanel(ttk.Frame): 9 | """A frame containing buttons to add new objects to the scene.""" 10 | 11 | def __init__(self, parent, ui_manager): 12 | """Initialize the ToolbarPanel. 13 | 14 | Args: 15 | parent: The parent widget. 16 | ui_manager: The UIManager instance to handle button actions. 17 | """ 18 | super().__init__(parent) 19 | self.ui_manager = ui_manager 20 | 21 | self._create_widgets() 22 | 23 | def _create_widgets(self): 24 | """Create and layout the widgets for the toolbar.""" 25 | 26 | # Configure style for buttons if needed (e.g., padding) 27 | # style = ttk.Style() 28 | # style.configure('Toolbar.TButton', padding=5) 29 | 30 | # --- Add Object Buttons --- 31 | add_circle_btn = ttk.Button( 32 | self, 33 | text="Add Circle", 34 | command=lambda: self.ui_manager.handle_add_object_request('Circle'), 35 | # style='Toolbar.TButton' 36 | bootstyle="info" 37 | ) 38 | add_circle_btn.pack(side=tk.LEFT, padx=5, pady=5) 39 | 40 | add_square_btn = ttk.Button( 41 | self, 42 | text="Add Square", 43 | command=lambda: self.ui_manager.handle_add_object_request('Square'), 44 | # style='Toolbar.TButton' 45 | bootstyle="info" 46 | ) 47 | add_square_btn.pack(side=tk.LEFT, padx=5, pady=5) 48 | 49 | add_text_btn = ttk.Button( 50 | self, 51 | text="Add Text", 52 | command=lambda: self.ui_manager.handle_add_object_request('Text'), 53 | # style='Toolbar.TButton' 54 | bootstyle="info" 55 | ) 56 | add_text_btn.pack(side=tk.LEFT, padx=5, pady=5) 57 | 58 | # Add more buttons as needed (e.g., shapes, controls) 59 | 60 | # --- Separator --- (Optional, for visual distinction) 61 | separator = ttk.Separator(self, orient=tk.VERTICAL) 62 | separator.pack(side=tk.LEFT, fill='y', padx=10, pady=5) 63 | 64 | # --- Render Video Button --- 65 | render_video_btn = ttk.Button( 66 | self, 67 | text="Render Video", 68 | command=self.ui_manager.handle_render_video_request, # Direct call 69 | bootstyle="success" # 'success' (green) for a positive final action 70 | ) 71 | render_video_btn.pack(side=tk.LEFT, padx=5, pady=5) -------------------------------------------------------------------------------- /src/easymanim/interface/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/easymanim/interface/manim_interface.py: -------------------------------------------------------------------------------- 1 | # src/easymanim/interface/manim_interface.py 2 | """Handles executing Manim rendering commands asynchronously.""" 3 | 4 | import threading 5 | import subprocess 6 | import os 7 | import tempfile 8 | import pathlib 9 | from typing import TYPE_CHECKING, Callable, Union, List, Literal 10 | 11 | # Avoid circular import for type hinting 12 | if TYPE_CHECKING: 13 | from easymanim.main_app import MainApplication # Assuming MainApplication is defined here 14 | 15 | class ManimInterface: 16 | """Manages the execution of Manim CLI commands in background threads.""" 17 | 18 | def __init__(self, root_app: 'MainApplication'): 19 | """Initializes the ManimInterface. 20 | 21 | Args: 22 | root_app: Reference to the main application instance, needed for scheduling 23 | UI updates from background threads. 24 | """ 25 | self.root_app: 'MainApplication' = root_app 26 | 27 | # Create a dedicated temporary directory for scripts 28 | # This directory will be managed by the OS's temp system 29 | temp_dir_path_str = tempfile.mkdtemp(prefix="easymanim_scripts_") 30 | self.temp_script_dir: pathlib.Path = pathlib.Path(temp_dir_path_str) 31 | 32 | # Optional: Add cleanup for this dir? Maybe later if needed. 33 | # For now, rely on OS temp cleanup or manual deletion if issues arise. 34 | 35 | def render_async(self, 36 | script_content: str, 37 | scene_name: str, 38 | quality_flags: List[str], 39 | output_format: Literal['png', 'mp4'], 40 | callback: Callable[[bool, Union[bytes, str]], None]): 41 | """Renders a Manim script asynchronously in a background thread. 42 | 43 | Creates a temporary script file, starts a thread to run the Manim 44 | command, and schedules a callback on the main thread upon completion. 45 | 46 | Args: 47 | script_content: The Manim script content as a string. 48 | scene_name: The name of the Scene class to render. 49 | quality_flags: List of Manim CLI flags for quality/preview 50 | (e.g., ['-', '-ql'], ['ql']). 51 | output_format: The desired output ('png' for preview, 'mp4' for video). 52 | callback: A function to call upon completion. Takes two arguments: 53 | - success (bool): True if rendering succeeded, False otherwise. 54 | - result (Union[bytes, str]): PNG image bytes if success and 55 | output_format='png', output video *path* (str) if success 56 | and output_format='mp4', or an error message (str) if failure. 57 | """ 58 | temp_script_path_str = None # Define variable outside try block 59 | try: 60 | # Create a temporary file to store the script 61 | # delete=False is important because we need the file path to exist 62 | # after the 'with' block to pass to the subprocess. 63 | # The thread running the subprocess will be responsible for deleting it. 64 | with tempfile.NamedTemporaryFile(mode='w', 65 | suffix='.py', 66 | delete=False, 67 | dir=str(self.temp_script_dir), 68 | encoding='utf-8') as temp_script: 69 | temp_script.write(script_content) 70 | temp_script_path_str = temp_script.name 71 | # temp_script is automatically closed upon exiting the 'with' block 72 | 73 | # --- Start the background thread --- 74 | thread = threading.Thread( 75 | target=self._run_manim_thread, 76 | args=( 77 | temp_script_path_str, 78 | scene_name, 79 | quality_flags, 80 | output_format, 81 | callback 82 | ) 83 | ) 84 | thread.start() 85 | 86 | # Note: Cleanup of temp_script_path_str is now the responsibility 87 | # of _run_manim_thread after the subprocess finishes. 88 | 89 | except Exception as e: 90 | # Handle exceptions during file writing or thread setup 91 | # Ensure callback is still called with failure 92 | error_message = f"Error setting up render: {e}" 93 | print(f"[ManimInterface Error] {error_message}") # Log error 94 | # Ensure cleanup happens even if thread start fails 95 | if temp_script_path_str and os.path.exists(temp_script_path_str): 96 | try: 97 | os.remove(temp_script_path_str) 98 | print(f"Cleaned up failed script: {temp_script_path_str}") 99 | except OSError as remove_error: 100 | print(f"Error cleaning up failed script {temp_script_path_str}: {remove_error}") 101 | # Schedule the callback in the main thread 102 | self.root_app.schedule_task(callback, False, error_message) 103 | 104 | def _get_quality_directory(self, flags: List[str]) -> str: 105 | """Determines the Manim media quality directory based on flags.""" 106 | # Manim maps flags to directory names, e.g. -ql -> 480p, -qm -> 720p, -qh -> 1080p 107 | # For V1, we primarily care about -ql 108 | if "-ql" in flags: 109 | return "480p" 110 | # Add other mappings if needed for future quality options 111 | # elif "-qm" in flags: 112 | # return "720p" 113 | # elif "-qh" in flags: 114 | # return "1080p" 115 | else: 116 | # Default or fallback if flags don't specify quality explicitly 117 | # Manim's default might vary, but 480p is a safe guess for -ql absence? 118 | # Or perhaps raise an error or use a default like "default_quality" 119 | print("[Manim Thread Warning] Could not determine quality directory from flags. Defaulting to 480p.") 120 | return "480p" 121 | 122 | def _run_manim_thread(self, 123 | script_path: str, 124 | scene_name: str, 125 | flags: List[str], 126 | output_format: Literal['png', 'mp4'], 127 | callback: Callable[[bool, Union[bytes, str]], None]): 128 | """The function executed in the background thread to run Manim. 129 | 130 | Handles subprocess execution, result checking, and cleanup. 131 | """ 132 | command = [ 133 | 'python', '-m', 'manim', 134 | script_path, 135 | scene_name 136 | ] + flags 137 | 138 | print(f"Running Manim command: {' '.join(command)}") 139 | success = False 140 | result_data: Union[bytes, str] = "Unknown error during execution." 141 | script_path_obj = pathlib.Path(script_path) 142 | 143 | try: 144 | result = subprocess.run( 145 | command, 146 | capture_output=True, 147 | text=True, 148 | check=False # We check returncode manually below 149 | ) 150 | 151 | if result.returncode == 0: 152 | print(f"Manim execution successful (code 0) for {script_path}") 153 | # --- Handle successful PNG output --- 154 | if output_format == 'png': 155 | script_stem = script_path_obj.stem 156 | # Manim convention: media/images//*.png 157 | # Glob for the potentially timestamped file name 158 | # Assuming execution from project root where 'media' would be 159 | glob_pattern = f"media/images/{script_stem}/{scene_name}*.png" 160 | try: 161 | output_files = list(pathlib.Path('.').glob(glob_pattern)) 162 | if not output_files: 163 | result_data = f"Render success (code 0), but output PNG not found using glob: {glob_pattern}" 164 | print(f"[Manim Thread Warning] {result_data}") 165 | elif len(output_files) > 1: 166 | # Warn but proceed with the first file found 167 | result_data = f"Render success (code 0), but multiple PNGs found ({len(output_files)}) using glob: {glob_pattern}. Using first one." 168 | print(f"[Manim Thread Warning] {result_data}") 169 | output_file = output_files[0] 170 | result_data = output_file.read_bytes() 171 | success = True 172 | else: # Exactly one file found 173 | output_file = output_files[0] 174 | print(f"Found output PNG: {output_file}") 175 | result_data = output_file.read_bytes() 176 | success = True 177 | except Exception as e: 178 | result_data = f"Render success (code 0), but failed to find/read output PNG: {e}" 179 | print(f"[Manim Thread Error] {result_data}") 180 | 181 | # --- Handle successful MP4 output --- 182 | elif output_format == 'mp4': 183 | script_stem = script_path_obj.stem 184 | scene_name_no_ext = scene_name # Scene name usually doesn't have extension 185 | # quality_dir = self._get_quality_directory(flags) # Old way 186 | 187 | # Manim convention: media/videos///.mp4 188 | # Instead of predicting exact quality_dir, glob for the MP4 file. 189 | media_videos_script_dir = pathlib.Path('.') / "media" / "videos" / script_stem 190 | 191 | print(f"Searching for MP4 output for scene '{scene_name_no_ext}' in subdirectories of: {media_videos_script_dir}") 192 | 193 | # Glob pattern: */SceneName.mp4 (any subdir under media_videos_script_dir) 194 | found_files = list(media_videos_script_dir.glob(f"*/{scene_name_no_ext}.mp4")) 195 | 196 | if found_files: 197 | if len(found_files) > 1: 198 | # This case should be rare if Manim is run for a single quality setting at a time. 199 | print(f"[Manim Thread Warning] Multiple MP4s found for '{scene_name_no_ext}' in {media_videos_script_dir}. Using first: {found_files[0]}") 200 | 201 | output_file_path = str(found_files[0].resolve()) # Use absolute path 202 | print(f"Found output MP4: {output_file_path}") 203 | success = True 204 | result_data = output_file_path 205 | else: 206 | # File not found using glob 207 | result_data = f"Render success (code 0), but output MP4 for '{scene_name_no_ext}' not found in any subdirectory of {media_videos_script_dir}" 208 | print(f"[Manim Thread Warning] {result_data}") 209 | # success remains False 210 | 211 | else: # Manim returned non-zero exit code 212 | error_intro = f"Manim failed (code {result.returncode}) for {script_path}:" 213 | # Combine stdout/stderr for more context, prioritizing stderr 214 | error_details = f"\n--- STDERR ---\n{result.stderr or '[No Stderr]'}\n--- STDOUT ---\n{result.stdout or '[No Stdout]'}" 215 | result_data = f"{error_intro}{error_details}" 216 | print(f"[Manim Thread Error] {error_intro}") 217 | # success remains False (set initially) 218 | 219 | except Exception as e: 220 | # --- Exception handling during subprocess run --- 221 | result_data = f"Exception during Manim subprocess run for {script_path}: {e}" 222 | print(f"[Manim Thread Error] {result_data}") 223 | success = False # Ensure success is false if exception occurs 224 | 225 | finally: 226 | # --- Schedule Callback --- 227 | try: 228 | self.root_app.schedule_task(callback, success, result_data) 229 | print(f"Callback scheduled for {script_path_obj.name} (Success: {success})") 230 | except Exception as cb_e: 231 | print(f"[Manim Thread Error] Failed to schedule callback for {script_path_obj.name}: {cb_e}") 232 | 233 | # --- Cleanup --- 234 | print(f"Cleaning up script: {script_path}") 235 | try: 236 | # Use os.path.exists with the string path 237 | if os.path.exists(script_path): 238 | os.remove(script_path) 239 | print(f"Successfully removed script: {script_path_obj.name}") 240 | else: 241 | print(f"Script file not found for cleanup: {script_path}") 242 | except OSError as e: 243 | print(f"[Manim Thread Error] Failed to remove script {script_path}: {e}") 244 | 245 | # Note: Removed the extra pass from the end of the class definition -------------------------------------------------------------------------------- /src/easymanim/logic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/easymanim/logic/scene_builder.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import textwrap 3 | from typing import Any, Dict, List, Optional, Literal 4 | 5 | # Default properties based on PRD and Manim standards 6 | DEFAULT_CIRCLE_PROPS = { 7 | 'pos_x': 0.0, 8 | 'pos_y': 0.0, 9 | 'pos_z': 0.0, # Add pos_z for consistency 10 | 'radius': 1.0, 11 | 'fill_color': '#58C4DD', # Manim default blue 12 | 'opacity': 1.0, # Add opacity 13 | 'stroke_color': '#FFFFFF', # Default white stroke 14 | 'stroke_width': 2.0, # Default stroke width 15 | 'stroke_opacity': 1.0, 16 | 'animation': 'None' # Add animation 17 | } 18 | 19 | DEFAULT_SQUARE_PROPS = { 20 | 'pos_x': 0.0, 21 | 'pos_y': 0.0, 22 | 'pos_z': 0.0, # Add pos_z 23 | 'side_length': 2.0, # Manim default 24 | 'fill_color': '#58C4DD', # Manim default blue (can change later) 25 | 'opacity': 1.0, # Add opacity 26 | 'stroke_color': '#FFFFFF', # Default white stroke 27 | 'stroke_width': 2.0, # Default stroke width 28 | 'stroke_opacity': 1.0, 29 | 'animation': 'None' # Add animation 30 | } 31 | 32 | DEFAULT_TEXT_PROPS = { 33 | 'pos_x': 0.0, 34 | 'pos_y': 0.0, 35 | 'pos_z': 0.0, # Add pos_z 36 | 'text_content': 'Text', 37 | 'fill_color': '#FFFFFF', # Manim default white 38 | 'font_size': 48, # Add font_size 39 | 'opacity': 1.0, # Add opacity 40 | 'stroke_color': '#000000', # Default black stroke for text (often not visible without width) 41 | 'stroke_opacity': 1.0, # Manim's Text might not use stroke_width directly for font outline 42 | # but stroke_color and stroke_opacity are valid params. 43 | 'animation': 'None' # Add animation 44 | } 45 | 46 | DEFAULT_PROPS_MAP = { 47 | 'Circle': DEFAULT_CIRCLE_PROPS, 48 | 'Square': DEFAULT_SQUARE_PROPS, 49 | 'Text': DEFAULT_TEXT_PROPS, # PRD uses TextMobject, just use Text for type key 50 | } 51 | 52 | class SceneBuilder: 53 | """Manages the state of the Manim scene being constructed. 54 | 55 | Handles adding objects, updating their properties, managing animations, 56 | and generating Manim scripts. 57 | """ 58 | def __init__(self): 59 | """Initializes the SceneBuilder with an empty list of scene objects.""" 60 | self.objects: List[Dict[str, Any]] = [] 61 | 62 | def _generate_unique_id(self, obj_type: str) -> str: 63 | """Generates a unique ID for a scene object.""" 64 | # Format: objecttype_first6charsofUUIDhex 65 | return f"{obj_type.lower()}_{uuid.uuid4().hex[:6]}" 66 | 67 | def add_object(self, obj_type: str) -> str: 68 | """Adds a new object of the specified type to the scene. 69 | 70 | Creates an object dictionary with default properties based on the type, 71 | assigns a unique ID, and appends it to the internal list. 72 | 73 | Args: 74 | obj_type: The type of Manim object to add (e.g., 'Circle', 'Square', 'Text'). 75 | 76 | Returns: 77 | A unique ID assigned to the new object. 78 | 79 | Raises: 80 | ValueError: If obj_type is not recognized. 81 | """ 82 | if obj_type not in DEFAULT_PROPS_MAP: 83 | # Consider more robust error handling or logging later 84 | raise ValueError(f"Unsupported object type: {obj_type}") 85 | 86 | new_id = self._generate_unique_id(obj_type) 87 | default_props = DEFAULT_PROPS_MAP[obj_type] 88 | 89 | new_object_data = { 90 | 'id': new_id, 91 | 'type': obj_type, 92 | 'properties': default_props.copy(), # animation is now part of default_props 93 | # 'animation': 'None' # No longer a top-level item here 94 | } 95 | 96 | self.objects.append(new_object_data) 97 | 98 | return new_id 99 | 100 | def get_object_properties(self, obj_id: str) -> Optional[Dict[str, Any]]: 101 | """Retrieves the 'properties' sub-dictionary for a given object ID. 102 | This dictionary contains all displayable and editable attributes. 103 | """ 104 | for obj in self.objects: 105 | if obj.get('id') == obj_id: 106 | # Return the 'properties' sub-dictionary directly 107 | return obj.get('properties') 108 | return None 109 | 110 | def update_object_property(self, obj_id: str, prop_key: str, value: Any, axis_index: Optional[int] = None): 111 | """Updates a specific property of a specific object within its 'properties' dict.""" 112 | obj_to_update = None 113 | for obj_data in self.objects: # Renamed obj to obj_data to avoid conflict 114 | if obj_data['id'] == obj_id: 115 | obj_to_update = obj_data 116 | break 117 | 118 | if obj_to_update: 119 | properties = obj_to_update['properties'] # Get the sub-dictionary 120 | 121 | if prop_key == 'position' and axis_index is not None: 122 | # Determine the actual key for pos_x, pos_y, pos_z based on axis_index 123 | pos_keys = ['pos_x', 'pos_y', 'pos_z'] 124 | if 0 <= axis_index < len(pos_keys): 125 | actual_prop_key = pos_keys[axis_index] 126 | try: 127 | properties[actual_prop_key] = float(value) 128 | print(f"Updated {actual_prop_key} for {obj_id} to {value}. Properties: {properties}") 129 | except ValueError: 130 | print(f"Error: Invalid value '{value}' for {actual_prop_key}.") 131 | else: 132 | print(f"Error: Invalid axis_index {axis_index} for position component update.") 133 | elif prop_key == 'animation': # Handle animation directly now 134 | properties[prop_key] = str(value) # Ensure it's a string 135 | print(f"Updated property for {obj_id}: {prop_key} to {value}. Properties: {properties}") 136 | else: 137 | # Default behavior for other properties 138 | # Type conversion might be needed here based on prop_key 139 | if prop_key in ['radius', 'side_length', 'font_size', 'opacity', 'stroke_width', 'stroke_opacity']: 140 | try: 141 | properties[prop_key] = float(value) 142 | except ValueError: 143 | print(f"Error: Invalid float value '{value}' for {prop_key}.") 144 | return # Or handle error appropriately 145 | elif prop_key == 'text_content': 146 | properties[prop_key] = str(value) 147 | elif prop_key == 'fill_color': # Assuming color is a hex string 148 | properties[prop_key] = str(value) 149 | else: 150 | properties[prop_key] = value # Fallback for other types or new properties 151 | print(f"Updated property for {obj_id}: {prop_key} to {value}. Properties: {properties}") 152 | else: 153 | print(f"Error: Object with ID {obj_id} not found for update.") 154 | 155 | def set_object_animation(self, obj_id: str, anim_name: str): 156 | """Sets the animation type for a specific object within its 'properties' dict.""" 157 | target_object_data = None 158 | for obj_data in self.objects: 159 | if obj_data.get('id') == obj_id: 160 | target_object_data = obj_data 161 | break 162 | 163 | if target_object_data is None: 164 | print(f"Error: Object with ID {obj_id} not found for setting animation.") 165 | return 166 | 167 | if 'properties' in target_object_data: 168 | target_object_data['properties']['animation'] = anim_name 169 | print(f"Set animation for {obj_id} to {anim_name}. Properties: {target_object_data['properties']}") 170 | else: 171 | print(f"Error: 'properties' dictionary not found for object {obj_id}.") 172 | 173 | def get_all_objects(self) -> List[Dict[str, Any]]: 174 | """Returns a shallow copy of the list of all object dictionaries. 175 | 176 | Returns: 177 | A list containing dictionaries, where each dictionary represents 178 | an object in the scene. 179 | """ 180 | # Return a shallow copy to prevent external modification of internal list 181 | return self.objects.copy() 182 | 183 | def _format_manim_prop(self, value: Any) -> str: 184 | """Formats a Python value into a Manim-compatible string representation.""" 185 | if isinstance(value, str): 186 | # Need quotes for strings 187 | # Escape potential quotes within the string itself 188 | escaped_value = value.replace("\'", "\\\'").replace('"', '\\"') 189 | return f"'{escaped_value}'" 190 | # Add more formatting rules if needed (e.g., for lists, specific objects) 191 | return str(value) # Default to standard string conversion 192 | 193 | def generate_script(self, script_type: Literal['preview', 'render']) -> tuple[str, str]: 194 | """Generates a Manim Python script based on the current scene state. 195 | 196 | Args: 197 | script_type: Determines the type of script ('preview' or 'render'). 198 | 'preview' generates a static scene. 199 | 'render' includes animations. 200 | 201 | Returns: 202 | A tuple containing: 203 | - The generated Python script content as a string. 204 | - The name of the Manim Scene class defined in the script. 205 | """ 206 | object_creation_lines = [] 207 | add_lines = [] 208 | play_lines = [] # For render script later 209 | 210 | # Common imports 211 | imports = [ 212 | "# Generated by EasyManim", 213 | "from manim import *", 214 | "import numpy as np # Often needed for positioning" 215 | ] 216 | 217 | # Generate code for each object 218 | for obj in self.objects: 219 | obj_id = obj['id'] 220 | obj_type = obj['type'] 221 | properties = obj['properties'] 222 | animation = properties.get('animation', 'None') 223 | 224 | var_name = f"{obj_type.lower()}_{obj_id[-6:]}" 225 | 226 | # --- Object Instantiation (common for all) --- 227 | prop_args_for_script = [] # Renamed to avoid conflict if you had prop_args locally 228 | pos_x = properties.get('pos_x', 0.0) 229 | pos_y = properties.get('pos_y', 0.0) 230 | pos_z = properties.get('pos_z', 0.0) 231 | move_to_str = f".move_to(np.array([{pos_x}, {pos_y}, {pos_z}]))" 232 | 233 | instantiation_base = f"{var_name} = {obj_type}(" 234 | if obj_type == 'Text': 235 | text_content_formatted = self._format_manim_prop(properties.get('text_content', '')) 236 | instantiation_base += text_content_formatted 237 | font_size_val = properties.get('font_size') 238 | if font_size_val is not None: 239 | prop_args_for_script.append(f"font_size={self._format_manim_prop(font_size_val)}") 240 | # Manim's Text uses 'color' for fill, but also accepts 'fill_color'. Using 'color' for primary text color. 241 | prop_args_for_script.append(f"color={self._format_manim_prop(properties.get('fill_color', '#FFFFFF'))}") 242 | prop_args_for_script.append(f"fill_opacity={self._format_manim_prop(properties.get('opacity', 1.0))}") 243 | prop_args_for_script.append(f"stroke_color={self._format_manim_prop(properties.get('stroke_color', '#000000'))}") 244 | prop_args_for_script.append(f"stroke_opacity={self._format_manim_prop(properties.get('stroke_opacity', 1.0))}") 245 | # stroke_width is deliberately omitted for Text for now as it behaves differently than shapes. 246 | elif obj_type == 'Circle': 247 | prop_args_for_script.append(f"radius={self._format_manim_prop(properties.get('radius', 1.0))}") 248 | prop_args_for_script.append(f"fill_color={self._format_manim_prop(properties.get('fill_color', '#FFFFFF'))}") 249 | prop_args_for_script.append(f"fill_opacity={self._format_manim_prop(properties.get('opacity', 1.0))}") 250 | prop_args_for_script.append(f"stroke_color={self._format_manim_prop(properties.get('stroke_color', '#FFFFFF'))}") 251 | prop_args_for_script.append(f"stroke_width={self._format_manim_prop(properties.get('stroke_width', 2.0))}") 252 | prop_args_for_script.append(f"stroke_opacity={self._format_manim_prop(properties.get('stroke_opacity', 1.0))}") 253 | elif obj_type == 'Square': 254 | prop_args_for_script.append(f"side_length={self._format_manim_prop(properties.get('side_length', 2.0))}") 255 | prop_args_for_script.append(f"fill_color={self._format_manim_prop(properties.get('fill_color', '#FFFFFF'))}") 256 | prop_args_for_script.append(f"fill_opacity={self._format_manim_prop(properties.get('opacity', 1.0))}") 257 | prop_args_for_script.append(f"stroke_color={self._format_manim_prop(properties.get('stroke_color', '#FFFFFF'))}") 258 | prop_args_for_script.append(f"stroke_width={self._format_manim_prop(properties.get('stroke_width', 2.0))}") 259 | prop_args_for_script.append(f"stroke_opacity={self._format_manim_prop(properties.get('stroke_opacity', 1.0))}") 260 | 261 | if prop_args_for_script: 262 | if obj_type == 'Text' and instantiation_base.endswith(text_content_formatted): 263 | instantiation_base += ", " 264 | instantiation_base += ", ".join(prop_args_for_script) 265 | final_instantiation = f"{instantiation_base}){move_to_str}" 266 | object_creation_lines.append(final_instantiation) 267 | 268 | # --- Animation and Scene Addition Logic --- 269 | added_by_intro_animation = False 270 | if script_type == 'render' and animation != 'None': 271 | animation_command_str = None 272 | is_intro_anim = False 273 | 274 | if animation == 'FadeIn': 275 | animation_command_str = f"FadeIn({var_name})" 276 | is_intro_anim = True 277 | elif animation == 'GrowFromCenter': 278 | animation_command_str = f"GrowFromCenter({var_name})" 279 | is_intro_anim = True 280 | elif animation == 'Write' and obj_type == 'Text': 281 | animation_command_str = f"Write({var_name})" 282 | is_intro_anim = True 283 | # Add other known intro animations here and set is_intro_anim = True 284 | 285 | if animation_command_str: 286 | play_lines.append(f"self.play({animation_command_str})") 287 | if is_intro_anim: 288 | added_by_intro_animation = True 289 | elif animation == 'Write' and obj_type != 'Text': # Non-applicable animation 290 | add_lines.append(f"self.add({var_name}) # 'Write' animation not applicable to {obj_type}") 291 | else: # Unknown animation type, or known non-intro animation 292 | add_lines.append(f"self.add({var_name}) # Default add for animation: {animation}") 293 | 294 | # Add object if it wasn't added by an intro animation, or if it's a preview 295 | if not added_by_intro_animation: 296 | add_lines.append(f"self.add({var_name})") 297 | 298 | # Determine Scene Name and Class Definition 299 | scene_name = "PreviewScene" if script_type == 'preview' else "EasyManimScene" 300 | class_def = f"class {scene_name}(Scene):" 301 | construct_def = " def construct(self):" 302 | 303 | # Assemble the final script 304 | construct_body_lines = [] 305 | construct_body_lines.extend(object_creation_lines) 306 | 307 | # For render scripts, play lines (animations) come first, then residual add lines. 308 | # For preview scripts, only add lines are used after object creation. 309 | if script_type == 'render': 310 | construct_body_lines.extend(play_lines) 311 | construct_body_lines.extend(add_lines) # These are objects not introduced by an animation 312 | else: # preview 313 | construct_body_lines.extend(add_lines) 314 | 315 | if not (object_creation_lines or add_lines or play_lines): # Check if any content for body 316 | indented_body = " pass # No objects or animations" 317 | else: 318 | indented_body = textwrap.indent("\n".join(construct_body_lines), " ") 319 | 320 | script_lines = imports + ["", class_def, construct_def, indented_body] 321 | script_content = "\n".join(script_lines) 322 | 323 | return script_content, scene_name -------------------------------------------------------------------------------- /src/easymanim/main.py: -------------------------------------------------------------------------------- 1 | """Main entry point for the EasyManim application.""" 2 | 3 | # Ensure Pillow knows where Tcl/Tk is if not automatically found 4 | # import os 5 | # if os.environ.get('TK_LIBRARY') is None or os.environ.get('TCL_LIBRARY') is None: 6 | # # Add paths specific to your Python/Tk installation if needed 7 | # # Example for some Homebrew installations on macOS: 8 | # # tk_lib_path = "/opt/homebrew/opt/tcl-tk/lib" 9 | # # if os.path.exists(tk_lib_path): 10 | # # os.environ['TK_LIBRARY'] = os.path.join(tk_lib_path, 'tk8.6') 11 | # # os.environ['TCL_LIBRARY'] = os.path.join(tk_lib_path, 'tcl8.6') 12 | # print(f"TK_LIBRARY: {os.environ.get('TK_LIBRARY')}") 13 | # print(f"TCL_LIBRARY: {os.environ.get('TCL_LIBRARY')}") 14 | 15 | # Import the main application class using relative import 16 | from .main_app import MainApplication 17 | 18 | def main(): 19 | """Creates and runs the main application instance.""" 20 | print("EasyManim starting...") 21 | app = MainApplication() 22 | app.run() 23 | print("EasyManim finished.") 24 | 25 | if __name__ == "__main__": 26 | main() -------------------------------------------------------------------------------- /src/easymanim/main_app.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import ttkbootstrap as ttk 3 | from typing import Callable, Any 4 | 5 | # Core Logic 6 | from .logic.scene_builder import SceneBuilder 7 | 8 | # Interface 9 | from .interface.manim_interface import ManimInterface 10 | 11 | # UI Manager 12 | from .ui.ui_manager import UIManager 13 | 14 | # GUI Panels 15 | from .gui.toolbar_panel import ToolbarPanel 16 | from .gui.timeline_panel import TimelinePanel 17 | from .gui.properties_panel import PropertiesPanel 18 | from .gui.preview_panel import PreviewPanel 19 | from .gui.statusbar_panel import StatusBarPanel 20 | 21 | class MainApplication(ttk.Window): 22 | """The main application window and orchestrator.""" 23 | 24 | def __init__(self): 25 | """Initialize the application.""" 26 | # Use a ttkbootstrap theme 27 | super().__init__(themename="darkly") # Or another theme like "litera", "cosmo" 28 | 29 | self.title("EasyManim Editor V0.1") 30 | self.geometry("1200x800") # Initial size 31 | 32 | print("Initializing core components...") 33 | # --- Initialize Core Components --- 34 | self.scene_builder = SceneBuilder() 35 | self.manim_interface = ManimInterface(self) # Pass app for scheduling 36 | self.ui_manager = UIManager(self, self.scene_builder, self.manim_interface) 37 | print("Core components initialized.") 38 | 39 | print("Creating widgets...") 40 | # --- Create GUI Widgets --- 41 | self._create_widgets() 42 | print("Widgets created.") 43 | 44 | def _create_widgets(self): 45 | """Create and layout all the GUI panels.""" 46 | 47 | # --- Configure Main Window Grid --- 48 | self.columnconfigure(0, weight=1) # Left column group (Timeline/Props) 49 | self.columnconfigure(1, weight=3) # Right column group (Preview) - 3x wider 50 | self.rowconfigure(0, weight=0) # Toolbar row (fixed height) 51 | self.rowconfigure(1, weight=1) # Main content row (expands) 52 | self.rowconfigure(2, weight=0) # Status bar row (fixed height) 53 | 54 | # --- Create Panel Instances --- 55 | print(" Instantiating panels...") 56 | # Toolbar 57 | toolbar = ToolbarPanel(self, self.ui_manager) 58 | toolbar.grid(row=0, column=0, columnspan=2, sticky="ew", padx=5, pady=5) 59 | self.ui_manager.register_panel("toolbar", toolbar) 60 | 61 | # Left Pane Frame (for Timeline and Properties) 62 | left_pane = ttk.Frame(self) 63 | left_pane.grid(row=1, column=0, sticky="nsew", padx=(5, 2), pady=(0, 5)) 64 | left_pane.rowconfigure(0, weight=1) # Timeline expands 65 | left_pane.rowconfigure(1, weight=1) # Properties expands 66 | left_pane.columnconfigure(0, weight=1) 67 | 68 | # Timeline 69 | timeline = TimelinePanel(left_pane, self.ui_manager) 70 | timeline.grid(row=0, column=0, sticky="nsew", pady=(0, 5)) 71 | self.ui_manager.register_panel("timeline", timeline) 72 | 73 | # Properties 74 | properties = PropertiesPanel(left_pane, self.ui_manager) 75 | properties.grid(row=1, column=0, sticky="nsew") 76 | self.ui_manager.register_panel("properties", properties) 77 | 78 | # Preview (Right Pane) 79 | preview = PreviewPanel(self, self.ui_manager) 80 | preview.grid(row=1, column=1, sticky="nsew", padx=(2, 5), pady=(0, 5)) 81 | self.ui_manager.register_panel("preview", preview) 82 | 83 | # Status Bar 84 | statusbar = StatusBarPanel(self, self.ui_manager) 85 | statusbar.grid(row=2, column=0, columnspan=2, sticky="ew", padx=5, pady=(2, 5)) 86 | self.ui_manager.register_panel("statusbar", statusbar) 87 | print(" Panels instantiated and gridded.") 88 | 89 | def schedule_task(self, callback: Callable[..., Any], *args: Any): 90 | """Schedule a task (callback) to be run in the main Tkinter thread. 91 | 92 | This is crucial for updating the GUI from background threads (like ManimInterface). 93 | Args: 94 | callback: The function to call. 95 | *args: Arguments to pass to the callback. 96 | """ 97 | # print(f"Scheduling task: {callback.__name__} with args: {args}") # Debug 98 | self.after(0, lambda: callback(*args)) 99 | 100 | def run(self): 101 | """Start the Tkinter main event loop.""" 102 | print("Starting main application loop...") 103 | self.mainloop() 104 | 105 | # Entry point will be in a separate main.py or handled by packaging 106 | # if __name__ == "__main__": 107 | # app = MainApplication() 108 | # app.run() -------------------------------------------------------------------------------- /src/easymanim/ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/easymanim/ui/ui_manager.py: -------------------------------------------------------------------------------- 1 | # src/easymanim/ui/ui_manager.py 2 | """Coordinates interactions between UI panels and backend logic.""" 3 | 4 | from typing import TYPE_CHECKING, Optional, Any, Dict, Union 5 | import tkinter as tk # Tkinter types might be needed if panels are typed 6 | import tkinter.messagebox # Import messagebox 7 | 8 | # Avoid circular imports 9 | if TYPE_CHECKING: 10 | from easymanim.logic.scene_builder import SceneBuilder 11 | from easymanim.interface.manim_interface import ManimInterface 12 | from easymanim.main_app import MainApplication 13 | # Add imports for Panel types if needed later, e.g.: 14 | # from easymanim.gui.preview_panel import PreviewPanel 15 | # from easymanim.gui.timeline_panel import TimelinePanel 16 | # from easymanim.gui.properties_panel import PropertiesPanel 17 | 18 | class UIManager: 19 | """Mediates communication between UI panels, SceneBuilder, and ManimInterface.""" 20 | 21 | def __init__(self, 22 | root_app: 'MainApplication', 23 | scene_builder: 'SceneBuilder', 24 | manim_interface: 'ManimInterface'): 25 | """Initializes the UIManager. 26 | 27 | Args: 28 | root_app: The main application instance (for scheduling tasks). 29 | scene_builder: The instance managing scene state. 30 | manim_interface: The instance handling Manim execution. 31 | """ 32 | self.root_app = root_app 33 | self.scene_builder = scene_builder 34 | self.manim_interface = manim_interface 35 | self.panels: Dict[str, Any] = {} # Store references to UI panels (mocked in tests) 36 | self.selected_object_id: Optional[str] = None 37 | 38 | def register_panel(self, name: str, panel_instance: Any): 39 | """Allows the MainApplication to register UI panel instances.""" 40 | print(f"Registering panel: {name}") # Debug print 41 | self.panels[name] = panel_instance 42 | 43 | # --- Event Handling Methods (to be implemented via TDD) --- 44 | 45 | def handle_add_object_request(self, obj_type: str): 46 | """Handles request to add a new object (e.g., from Toolbar button).""" 47 | print(f"Handling add object request: {obj_type}") 48 | try: 49 | new_id = self.scene_builder.add_object(obj_type) 50 | print(f" SceneBuilder returned new ID: {new_id}") 51 | 52 | # Update Timeline Panel 53 | timeline_panel = self.panels.get("timeline") 54 | if timeline_panel: 55 | # Generate a label (can be refined later) 56 | # For now, use type and last 6 chars of ID for uniqueness 57 | print(f" Updating timeline panel with: obj_id={new_id}, obj_type={obj_type}") 58 | # Assume add_block exists on the panel instance 59 | timeline_panel.add_block(new_id, obj_type) # Pass obj_type directly 60 | else: 61 | print(" Timeline panel not found!") 62 | 63 | # Update Status Bar 64 | statusbar_panel = self.panels.get("statusbar") 65 | if statusbar_panel: 66 | status_message = f"{obj_type} added (ID: {new_id})" 67 | print(f" Updating status bar: {status_message}") 68 | # Assume set_status exists 69 | statusbar_panel.set_status(status_message) 70 | else: 71 | print(" Statusbar panel not found!") 72 | 73 | # TODO: Enable Render button if needed? 74 | 75 | except ValueError as e: 76 | # Handle case where SceneBuilder rejects object type 77 | print(f"[UIManager Error] Failed to add object: {e}") 78 | statusbar_panel = self.panels.get("statusbar") 79 | if statusbar_panel: 80 | statusbar_panel.set_status(f"Error: {e}") 81 | # Optionally show a messagebox error too 82 | # messagebox.showerror("Error", f"Failed to add object: {e}") 83 | except Exception as e: 84 | # Catch unexpected errors during the process 85 | print(f"[UIManager Error] Unexpected error adding object: {e}") 86 | statusbar_panel = self.panels.get("statusbar") 87 | if statusbar_panel: 88 | statusbar_panel.set_status(f"Error adding {obj_type}") 89 | # Consider logging traceback here 90 | 91 | def handle_timeline_selection(self, obj_id: Optional[str]): 92 | """Handles an object being selected or deselected in the timeline. 93 | 94 | Updates the properties panel accordingly. 95 | """ 96 | print(f"Handling timeline selection: {obj_id}") 97 | self.selected_object_id = obj_id 98 | 99 | properties_panel = self.panels.get("properties") 100 | statusbar_panel = self.panels.get("statusbar") 101 | 102 | if properties_panel is None: 103 | print(" Properties panel not found!") 104 | # Optionally update status bar about missing panel 105 | return 106 | 107 | if obj_id is not None: 108 | # Object selected 109 | props = self.scene_builder.get_object_properties(obj_id) 110 | if props is not None: 111 | print(f" Displaying properties for {obj_id}") 112 | properties_panel.display_properties(obj_id, props) 113 | if statusbar_panel: 114 | statusbar_panel.set_status(f"Selected: {obj_id}") 115 | else: 116 | # ID was provided but not found in SceneBuilder (e.g., stale ID) 117 | print(f" Selected object ID {obj_id} not found in SceneBuilder!") 118 | properties_panel.show_placeholder() 119 | if statusbar_panel: 120 | statusbar_panel.set_status(f"Error: Object {obj_id} not found") 121 | else: 122 | # Deselection 123 | print(" Deselecting object, showing placeholder.") 124 | properties_panel.show_placeholder() 125 | if statusbar_panel: 126 | # Using "Ready" is common for deselection/idle state 127 | statusbar_panel.set_status("Ready") 128 | 129 | def handle_property_change(self, obj_id: str, prop_key: str, value: Any, axis_index: Optional[int] = None): 130 | """Handles a property value being changed in the Properties Panel.""" 131 | print(f"Handling property change for {obj_id}: {prop_key} = {value}, axis_index = {axis_index}") 132 | try: 133 | self.scene_builder.update_object_property(obj_id, prop_key, value, axis_index=axis_index) 134 | # Update status bar 135 | statusbar_panel = self.panels.get("statusbar") 136 | if statusbar_panel: 137 | # Capitalize prop_key for display 138 | display_prop = prop_key.replace('_', ' ').capitalize() 139 | status_message = f"{display_prop} updated for {obj_id}" 140 | statusbar_panel.set_status(status_message) 141 | except Exception as e: 142 | print(f"[UIManager Error] Failed to update property {prop_key} for {obj_id}: {e}") 143 | statusbar_panel = self.panels.get("statusbar") 144 | if statusbar_panel: 145 | statusbar_panel.set_status(f"Error updating {prop_key}") 146 | 147 | def handle_animation_change(self, obj_id: str, anim_name: str): 148 | """Handles the animation type being changed in the Properties Panel.""" 149 | print(f"Handling animation change for {obj_id}: {anim_name}") 150 | try: 151 | self.scene_builder.set_object_animation(obj_id, anim_name) 152 | # Update status bar 153 | statusbar_panel = self.panels.get("statusbar") 154 | if statusbar_panel: 155 | status_message = f"Animation set to '{anim_name}' for {obj_id}" 156 | statusbar_panel.set_status(status_message) 157 | except Exception as e: 158 | print(f"[UIManager Error] Failed to set animation for {obj_id}: {e}") 159 | statusbar_panel = self.panels.get("statusbar") 160 | if statusbar_panel: 161 | statusbar_panel.set_status(f"Error setting animation") 162 | 163 | def handle_refresh_preview_request(self): 164 | """Handles request to refresh the static preview image.""" 165 | print("Handling refresh preview request") 166 | try: 167 | script_content, scene_name = self.scene_builder.generate_script('preview') 168 | print(f" Generated preview script for scene: {scene_name}") 169 | 170 | preview_panel = self.panels.get("preview") 171 | statusbar_panel = self.panels.get("statusbar") 172 | 173 | # Update UI to show rendering state 174 | if preview_panel: 175 | preview_panel.show_rendering_state() 176 | if statusbar_panel: 177 | statusbar_panel.set_status("Rendering preview...") 178 | 179 | # Define preview quality flags 180 | quality_flags = ['-s', '-ql'] # Static image, low quality 181 | 182 | # Call ManimInterface to render asynchronously 183 | self.manim_interface.render_async( 184 | script_content=script_content, 185 | scene_name=scene_name, 186 | quality_flags=quality_flags, 187 | output_format='png', 188 | callback=self._preview_callback # Pass internal method as callback 189 | ) 190 | print(" render_async called on ManimInterface") 191 | 192 | except Exception as e: 193 | print(f"[UIManager Error] Failed during refresh preview setup: {e}") 194 | statusbar_panel = self.panels.get("statusbar") 195 | if statusbar_panel: 196 | statusbar_panel.set_status("Error generating preview script") 197 | # Potentially show error to user or update preview panel state 198 | preview_panel = self.panels.get("preview") 199 | if preview_panel: 200 | preview_panel.show_idle_state() # Re-enable button etc. 201 | 202 | def handle_render_video_request(self): 203 | """Handles request to render the final video. 204 | 205 | Similar to preview request, but uses 'render' script type and 'mp4' output. 206 | """ 207 | print("Handling render video request") 208 | try: 209 | script_content, scene_name = self.scene_builder.generate_script('render') 210 | print(f" Generated render script for scene: {scene_name}") 211 | 212 | # Get panels needed for state updates 213 | preview_panel = self.panels.get("preview") # May need to disable btn 214 | statusbar_panel = self.panels.get("statusbar") 215 | 216 | # Update UI to show rendering state (disable relevant buttons) 217 | # TODO: Determine which buttons *exactly* need disabling 218 | if preview_panel: 219 | # Potentially reuse show_rendering_state or have a specific one 220 | preview_panel.show_rendering_state() 221 | if statusbar_panel: 222 | statusbar_panel.set_status("Rendering video...") 223 | 224 | # Define render quality flags (low quality for V1) 225 | quality_flags = ['-ql'] 226 | 227 | # Call ManimInterface to render asynchronously 228 | self.manim_interface.render_async( 229 | script_content=script_content, 230 | scene_name=scene_name, 231 | quality_flags=quality_flags, 232 | output_format='mp4', 233 | callback=self._render_callback # Use the render callback 234 | ) 235 | print(" render_async called on ManimInterface for MP4") 236 | 237 | except Exception as e: 238 | print(f"[UIManager Error] Failed during render video setup: {e}") 239 | statusbar_panel = self.panels.get("statusbar") 240 | if statusbar_panel: 241 | statusbar_panel.set_status("Error generating render script") 242 | # Reset UI state if setup fails 243 | preview_panel = self.panels.get("preview") 244 | if preview_panel: 245 | preview_panel.show_idle_state() 246 | 247 | # --- Callback Methods (to be implemented via TDD) --- 248 | 249 | def _preview_callback(self, success: bool, result: Union[bytes, str]): 250 | """Callback function for ManimInterface after preview render attempt.""" 251 | print(f"_preview_callback received: success={success}, result type={type(result)}") 252 | 253 | preview_panel = self.panels.get("preview") 254 | statusbar_panel = self.panels.get("statusbar") 255 | 256 | if success: 257 | if isinstance(result, bytes): 258 | # Success path: Display the image 259 | if preview_panel: 260 | preview_panel.display_image(result) 261 | preview_panel.show_idle_state() 262 | if statusbar_panel: 263 | statusbar_panel.set_status("Preview updated.") 264 | else: 265 | # Should not happen for successful PNG, but handle defensively 266 | print(f"[UIManager Error] Preview success=True, but result was not bytes: {type(result)}") 267 | if preview_panel: 268 | preview_panel.show_idle_state() # Still reset state 269 | if statusbar_panel: 270 | statusbar_panel.set_status("Preview error: Invalid result type.") 271 | else: 272 | # Failure path 273 | error_message = str(result) # Ensure result is a string 274 | print(f"Preview failed: {error_message}") 275 | 276 | # Show error dialog to the user 277 | tkinter.messagebox.showerror("Preview Failed", error_message) 278 | 279 | # Reset UI state 280 | if preview_panel: 281 | preview_panel.show_idle_state() 282 | if statusbar_panel: 283 | statusbar_panel.set_status("Preview failed.") 284 | 285 | def _render_callback(self, success: bool, result: Union[str, str]): 286 | """Callback function for ManimInterface after video render attempt.""" 287 | print(f"_render_callback received: success={success}, result type={type(result)}") 288 | 289 | preview_panel = self.panels.get("preview") # Needed to reset state 290 | statusbar_panel = self.panels.get("statusbar") 291 | 292 | if success: 293 | if isinstance(result, str): 294 | # Success path: Show confirmation message with path 295 | video_path = result 296 | print(f"Render successful: {video_path}") 297 | tkinter.messagebox.showinfo("Render Complete", f"Video saved as:\n{video_path}") 298 | if statusbar_panel: 299 | statusbar_panel.set_status(f"Video render complete: {video_path}") 300 | else: 301 | # Should not happen for successful MP4, but handle defensively 302 | print(f"[UIManager Error] Render success=True, but result was not str: {type(result)}") 303 | if statusbar_panel: 304 | statusbar_panel.set_status("Render error: Invalid result type.") 305 | else: 306 | # Failure path 307 | error_message = str(result) 308 | print(f"Render failed: {error_message}") 309 | tkinter.messagebox.showerror("Render Failed", error_message) 310 | if statusbar_panel: 311 | statusbar_panel.set_status("Video render failed.") 312 | 313 | # Reset UI state (e.g., re-enable buttons) regardless of success/failure 314 | if preview_panel: 315 | preview_panel.show_idle_state() 316 | 317 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gui/test_preview_panel.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tkinter as tk 3 | import ttkbootstrap as ttk 4 | from unittest.mock import Mock 5 | from PIL import Image 6 | import io 7 | 8 | # Assuming PreviewPanel will be in src/easymanim/gui/preview_panel.py 9 | from easymanim.gui.preview_panel import PreviewPanel 10 | 11 | # Fixture to create a root window for tests needing GUI elements 12 | @pytest.fixture(scope="function") 13 | def root(): 14 | root = tk.Tk() 15 | root.withdraw() # Hide the main window during tests 16 | yield root 17 | root.destroy() 18 | 19 | def test_preview_panel_init_state(root, mocker): 20 | """Test the initial state of the PreviewPanel.""" 21 | mock_ui_manager = mocker.Mock() 22 | 23 | # Create the panel instance 24 | preview_panel = PreviewPanel(root, mock_ui_manager) 25 | preview_panel.pack(fill=tk.BOTH, expand=True) 26 | root.update_idletasks() 27 | 28 | # Find Canvas and Button 29 | canvas = None 30 | refresh_button = None 31 | for widget in preview_panel.winfo_children(): 32 | if isinstance(widget, tk.Canvas): 33 | canvas = widget 34 | elif isinstance(widget, ttk.Button) and "Refresh" in widget.cget("text"): 35 | refresh_button = widget 36 | 37 | assert canvas is not None, "Canvas widget not found" 38 | assert refresh_button is not None, "Refresh Preview button not found" 39 | 40 | # Check button state (should be enabled initially) 41 | assert str(refresh_button.cget("state")) == "normal" 42 | 43 | # Check for initial placeholder on canvas (assuming tagged 'placeholder') 44 | # Explicitly set size and update/draw like in TimelinePanel tests 45 | canvas.config(width=300, height=200) 46 | root.update_idletasks() 47 | root.update() 48 | # Assume a method like _draw_placeholder exists or is called in init 49 | if hasattr(preview_panel, "_draw_placeholder"): 50 | preview_panel._draw_placeholder() 51 | root.update() 52 | 53 | placeholder_items = canvas.find_withtag("placeholder") 54 | assert placeholder_items, "Placeholder item not found on canvas" 55 | # Optional: Check placeholder text content if needed 56 | # placeholder_text = canvas.itemcget(placeholder_items[0], "text") 57 | # assert "refresh" in placeholder_text.lower() 58 | 59 | def test_preview_panel_refresh_button_calls_handler(root, mocker): 60 | """Test that clicking the refresh button calls the UIManager handler.""" 61 | mock_ui_manager = mocker.Mock() 62 | 63 | # Create the panel instance 64 | preview_panel = PreviewPanel(root, mock_ui_manager) 65 | preview_panel.pack(fill=tk.BOTH, expand=True) 66 | root.update_idletasks() 67 | 68 | # Find the refresh button 69 | refresh_button = preview_panel.refresh_button 70 | assert refresh_button is not None, "Refresh button reference not found on panel instance" 71 | assert isinstance(refresh_button, ttk.Button), "Refresh widget is not a Button" 72 | 73 | # Simulate button click 74 | refresh_button.invoke() 75 | root.update() 76 | 77 | # Assert handler was called 78 | mock_ui_manager.handle_refresh_preview_request.assert_called_once() 79 | 80 | def test_preview_panel_show_rendering_state(root, mocker): 81 | """Test that show_rendering_state disables button and updates canvas.""" 82 | mock_ui_manager = mocker.Mock() 83 | preview_panel = PreviewPanel(root, mock_ui_manager) 84 | preview_panel.pack(fill=tk.BOTH, expand=True) 85 | canvas = preview_panel.canvas 86 | refresh_button = preview_panel.refresh_button 87 | canvas.config(width=300, height=200) 88 | root.update_idletasks() 89 | root.update() 90 | if hasattr(preview_panel, "_draw_placeholder"): # Ensure initial placeholder drawn 91 | preview_panel._draw_placeholder() 92 | root.update() 93 | 94 | # Check initial state 95 | assert str(refresh_button.cget("state")) == "normal" 96 | assert canvas.find_withtag("placeholder"), "Initial placeholder missing" 97 | assert not canvas.find_withtag("rendering_text"), "Rendering text should not exist initially" 98 | 99 | # Call the state change method 100 | preview_panel.show_rendering_state() 101 | root.update_idletasks() 102 | 103 | # Assert new state 104 | assert str(refresh_button.cget("state")) == "disabled" 105 | assert not canvas.find_withtag("placeholder"), "Placeholder should be removed" 106 | rendering_items = canvas.find_withtag("rendering_text") 107 | assert rendering_items, "Rendering text not found" 108 | # Optional: check text content 109 | # text = canvas.itemcget(rendering_items[0], "text") 110 | # assert "rendering" in text.lower() 111 | 112 | def test_preview_panel_show_idle_state(root, mocker): 113 | """Test that show_idle_state enables button and restores placeholder/image.""" 114 | mock_ui_manager = mocker.Mock() 115 | preview_panel = PreviewPanel(root, mock_ui_manager) 116 | preview_panel.pack(fill=tk.BOTH, expand=True) 117 | canvas = preview_panel.canvas 118 | refresh_button = preview_panel.refresh_button 119 | canvas.config(width=300, height=200) 120 | root.update_idletasks() 121 | root.update() 122 | 123 | # Put panel in rendering state first 124 | preview_panel.show_rendering_state() 125 | root.update_idletasks() 126 | assert str(refresh_button.cget("state")) == "disabled", "Button should be disabled in rendering state" 127 | assert canvas.find_withtag("rendering_text"), "Rendering text missing in rendering state" 128 | assert not canvas.find_withtag("placeholder"), "Placeholder should be absent in rendering state" 129 | 130 | # Call the state change method back to idle 131 | preview_panel.show_idle_state() 132 | root.update_idletasks() 133 | root.update() # Extra update for drawing 134 | if hasattr(preview_panel, "_draw_placeholder"): # Ensure placeholder drawn if applicable 135 | preview_panel._draw_placeholder() 136 | root.update() 137 | 138 | # Assert idle state 139 | assert str(refresh_button.cget("state")) == "normal" 140 | assert not canvas.find_withtag("rendering_text"), "Rendering text should be removed" 141 | assert canvas.find_withtag("placeholder"), "Placeholder should be restored" 142 | 143 | # Helper to create dummy PNG bytes for testing 144 | def create_dummy_png_bytes(width=10, height=10, color="blue") -> bytes: 145 | img = Image.new('RGB', (width, height), color=color) 146 | byte_arr = io.BytesIO() 147 | img.save(byte_arr, format='PNG') 148 | return byte_arr.getvalue() 149 | 150 | def test_preview_panel_display_image(root, mocker): 151 | """Test that display_image shows the image on the canvas.""" 152 | mock_ui_manager = mocker.Mock() 153 | preview_panel = PreviewPanel(root, mock_ui_manager) 154 | preview_panel.pack(fill=tk.BOTH, expand=True) 155 | canvas = preview_panel.canvas 156 | canvas.config(width=300, height=200) 157 | root.update_idletasks() 158 | root.update() 159 | if hasattr(preview_panel, "_draw_placeholder"): # Ensure initial placeholder drawn 160 | preview_panel._draw_placeholder() 161 | root.update() 162 | 163 | # Verify initial state 164 | assert canvas.find_withtag("placeholder"), "Initial placeholder missing" 165 | assert not canvas.find_withtag("preview_image"), "Preview image should not exist initially" 166 | 167 | # Create dummy image data 168 | dummy_bytes = create_dummy_png_bytes(50, 50, "red") 169 | 170 | # Call display_image 171 | preview_panel.display_image(dummy_bytes) 172 | root.update_idletasks() 173 | 174 | # Assert state after displaying image 175 | assert not canvas.find_withtag("placeholder"), "Placeholder should be removed" 176 | image_items = canvas.find_withtag("preview_image") # Assumes implementation uses this tag 177 | assert image_items, "Preview image item not found on canvas" 178 | # Optional: More checks (e.g., check coordinates, although they depend on centering logic) 179 | # coords = canvas.coords(image_items[0]) 180 | # assert coords == [expected_x, expected_y] 181 | 182 | # Verify internal state update (optional) 183 | assert preview_panel._image_on_canvas is not None 184 | assert preview_panel._photo_image is not None 185 | 186 | # Placeholders for future tests 187 | # def test_preview_panel_display_image(root, mocker): 188 | # pass -------------------------------------------------------------------------------- /tests/gui/test_properties_panel.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tkinter as tk 3 | import ttkbootstrap as ttk 4 | from unittest.mock import Mock 5 | 6 | # Assuming PropertiesPanel will be in src/easymanim/gui/properties_panel.py 7 | from easymanim.gui.properties_panel import PropertiesPanel 8 | 9 | # Fixture to create a root window for tests needing GUI elements 10 | @pytest.fixture(scope="function") 11 | def root(): 12 | # Use ttkbootstrap Window instead of tk.Tk 13 | root = ttk.Window() 14 | root.withdraw() # Hide the main window during tests 15 | yield root 16 | root.destroy() 17 | 18 | def test_properties_panel_init_shows_placeholder(root, mocker): 19 | """Test that the PropertiesPanel shows placeholder text on initialization.""" 20 | mock_ui_manager = mocker.Mock() 21 | 22 | # Create the panel instance 23 | properties_panel = PropertiesPanel(root, mock_ui_manager) 24 | properties_panel.pack(fill=tk.BOTH, expand=True) 25 | root.update_idletasks() 26 | 27 | # Find the placeholder label - assuming it's the only widget initially 28 | children = properties_panel.winfo_children() 29 | assert len(children) == 1, f"Expected 1 child widget (placeholder), found {len(children)}" 30 | 31 | placeholder_label = children[0] 32 | assert isinstance(placeholder_label, ttk.Label), "Expected placeholder widget to be a ttk.Label" 33 | 34 | expected_text = "Select an object to edit properties." 35 | actual_text = placeholder_label.cget("text") 36 | assert actual_text == expected_text, f"Expected placeholder text '{expected_text}', but got '{actual_text}'" 37 | 38 | def test_properties_panel_display_properties_shows_widgets(root, mocker): 39 | """Test display_properties creates the correct widgets for a Circle.""" 40 | mock_ui_manager = mocker.Mock() 41 | properties_panel = PropertiesPanel(root, mock_ui_manager) 42 | properties_panel.pack(fill=tk.BOTH, expand=True) 43 | root.update_idletasks() 44 | 45 | test_id = "circle_abc" 46 | # Sample properties for a Circle object 47 | test_props = { 48 | 'type': 'Circle', 49 | 'position': (1.0, -0.5, 0.0), 50 | 'radius': 0.75, 51 | 'color': '#FF0000', # Red 52 | 'opacity': 0.8, 53 | 'animation': 'FadeIn' 54 | } 55 | 56 | # Call the method to display properties 57 | properties_panel.display_properties(test_id, test_props) 58 | root.update_idletasks() 59 | 60 | # Assert placeholder is gone 61 | assert properties_panel._placeholder_label is None, "Placeholder should be removed" 62 | 63 | # Check for expected widgets (Labels + specific input widgets) 64 | # We expect label/widget pairs for: radius, position (frame), color (frame), opacity, animation 65 | expected_widget_count = 5 * 2 # 5 property groups, label+widget/frame each 66 | actual_widget_count = len(properties_panel.widgets) 67 | # Note: properties_panel.widgets will store the *input* widgets, not the labels 68 | # Let's check the frame's children instead for a total count 69 | actual_children_count = len(properties_panel.winfo_children()) 70 | assert actual_children_count == expected_widget_count, \ 71 | f"Expected exactly {expected_widget_count} direct child widgets, found {actual_children_count}" 72 | 73 | # Check specific widget types and initial values (more detailed checks needed) 74 | # Example: Find the Radius Entry 75 | radius_entry = None 76 | for widget in properties_panel.winfo_children(): 77 | if isinstance(widget, ttk.Entry) and hasattr(widget, "_property_key") and widget._property_key == "radius": 78 | radius_entry = widget 79 | break 80 | # assert radius_entry is not None, "Radius Entry widget not found" 81 | # assert radius_entry.get() == str(test_props['radius']) 82 | 83 | # Example: Find the Animation Combobox 84 | anim_combo = None 85 | for widget in properties_panel.winfo_children(): 86 | if isinstance(widget, ttk.Combobox) and hasattr(widget, "_property_key") and widget._property_key == "animation": 87 | anim_combo = widget 88 | break 89 | # assert anim_combo is not None, "Animation Combobox widget not found" 90 | # assert anim_combo.get() == str(test_props['animation']) 91 | 92 | # TODO: Add more detailed checks for other widgets (Position X/Y/Z, Color Button, Opacity) 93 | # This requires the implementation to store references or assign unique names/tags. 94 | # For now, just check the count as a basic verification. 95 | print(f"Found {actual_children_count} children widgets after display_properties.") # Debug 96 | 97 | def test_properties_panel_show_placeholder_clears_widgets(root, mocker): 98 | """Test that show_placeholder removes property widgets and shows the label.""" 99 | mock_ui_manager = mocker.Mock() 100 | properties_panel = PropertiesPanel(root, mock_ui_manager) 101 | properties_panel.pack(fill=tk.BOTH, expand=True) 102 | root.update_idletasks() 103 | 104 | # Display some properties first 105 | test_id = "circle_abc" 106 | test_props = { 107 | 'type': 'Circle', 108 | 'position': (1.0, -0.5, 0.0), 109 | 'radius': 0.75, 110 | 'color': '#FF0000', 111 | 'opacity': 0.8, 112 | 'animation': 'FadeIn' 113 | } 114 | properties_panel.display_properties(test_id, test_props) 115 | root.update_idletasks() 116 | 117 | # Verify widgets were added 118 | assert len(properties_panel.winfo_children()) > 1, "Widgets should have been added by display_properties" 119 | assert properties_panel._placeholder_label is None, "Placeholder should be None after display_properties" 120 | 121 | # Now, call show_placeholder 122 | properties_panel.show_placeholder() 123 | root.update_idletasks() 124 | 125 | # Verify only the placeholder label remains 126 | children = properties_panel.winfo_children() 127 | assert len(children) == 1, f"Expected 1 child widget (placeholder) after show_placeholder, found {len(children)}" 128 | 129 | placeholder_label = children[0] 130 | assert isinstance(placeholder_label, ttk.Label), "Expected placeholder widget to be a ttk.Label" 131 | assert properties_panel._placeholder_label == placeholder_label, "Panel's _placeholder_label reference should be updated" 132 | 133 | expected_text = "Select an object to edit properties." 134 | actual_text = placeholder_label.cget("text") 135 | assert actual_text == expected_text, f"Expected placeholder text '{expected_text}', but got '{actual_text}'" 136 | 137 | # Verify internal widget dict is cleared 138 | assert not properties_panel.widgets, "Internal widget dictionary should be empty after show_placeholder" 139 | 140 | def test_properties_panel_entry_validation_and_update(root, mocker): 141 | """Test Entry field validation and update via UIManager.""" 142 | mock_ui_manager = mocker.Mock() 143 | properties_panel = PropertiesPanel(root, mock_ui_manager) 144 | properties_panel.pack(fill=tk.BOTH, expand=True) 145 | root.update_idletasks() 146 | 147 | # Display properties for a circle 148 | test_id = "circle_val" 149 | test_props = { 150 | 'type': 'Circle', 'radius': 0.75, 'position': (0,0,0), 151 | 'color': '#FFF', 'opacity': 1.0, 'animation': 'None' 152 | } 153 | properties_panel.display_properties(test_id, test_props) 154 | root.update_idletasks() 155 | 156 | # Find the radius entry widget (assuming it's stored in self.widgets['radius']) 157 | assert 'radius' in properties_panel.widgets, "Radius widget not found in internal dict" 158 | radius_entry = properties_panel.widgets['radius'] 159 | assert isinstance(radius_entry, ttk.Entry), "Radius widget is not an Entry" 160 | 161 | # --- Test Validation (requires validation logic to be implemented) --- 162 | # TODO: Add tests for invalid input (e.g., letters) after implementing validation 163 | # Example (will fail until validation is implemented): 164 | # radius_entry.insert(tk.END, "abc") 165 | # assert radius_entry.get() == "0.75", "Validation should prevent non-numeric input" 166 | 167 | # --- Test Update on FocusOut/Return --- 168 | new_radius_value = "1.25" 169 | radius_entry.delete(0, tk.END) 170 | radius_entry.insert(0, new_radius_value) 171 | 172 | # Simulate FocusOut event (requires binding to be implemented) 173 | radius_entry.event_generate("") 174 | root.update() 175 | 176 | # Assert the UIManager was called correctly once 177 | mock_ui_manager.handle_property_change.assert_called_once_with(test_id, 'radius', float(new_radius_value)) 178 | 179 | # # Simulate Return key press (requires binding to be implemented) 180 | # new_radius_value_2 = "0.5" 181 | # radius_entry.delete(0, tk.END) 182 | # radius_entry.insert(0, new_radius_value_2) 183 | # radius_entry.event_generate("") 184 | # root.update() 185 | # # Check the second call 186 | # # mock_ui_manager.handle_property_change.assert_called_with(test_id, 'radius', float(new_radius_value_2)) 187 | # 188 | # # Check call count 189 | # assert mock_ui_manager.handle_property_change.call_count == 2 190 | # 191 | # # Use assert_has_calls to check the sequence of calls 192 | # from unittest.mock import call # Need to import call 193 | # expected_calls = [ 194 | # call(test_id, 'radius', float(new_radius_value)), 195 | # call(test_id, 'radius', float(new_radius_value_2)) 196 | # ] 197 | # mock_ui_manager.handle_property_change.assert_has_calls(expected_calls) 198 | 199 | def test_properties_panel_color_button_updates(root, mocker): 200 | """Test the color picker button updates UIManager and swatch.""" 201 | mock_ui_manager = mocker.Mock() 202 | # Mock the askcolor dialog where it is used 203 | new_color_hex = "#0000ff" # Blue 204 | new_color_rgb = (0, 0, 255) 205 | mock_askcolor = mocker.patch('easymanim.gui.properties_panel.askcolor', return_value=(new_color_rgb, new_color_hex)) 206 | 207 | properties_panel = PropertiesPanel(root, mock_ui_manager) 208 | properties_panel.pack(fill=tk.BOTH, expand=True) 209 | root.update_idletasks() 210 | 211 | # Display properties including a color 212 | test_id = "circle_color" 213 | initial_color = "#ff0000" # Red 214 | test_props = { 215 | 'type': 'Circle', 'radius': 1, 'position': (0,0,0), 216 | 'color': initial_color, 'opacity': 1.0, 'animation': 'None' 217 | } 218 | properties_panel.display_properties(test_id, test_props) 219 | root.update_idletasks() 220 | 221 | # Find the button and swatch (assuming they are stored in self.widgets) 222 | assert 'color' in properties_panel.widgets, "Color button widget not found in internal dict" 223 | assert f"color_swatch" in properties_panel.widgets, "Color swatch widget not found in internal dict" 224 | color_button = properties_panel.widgets['color'] 225 | color_swatch = properties_panel.widgets['color_swatch'] 226 | assert isinstance(color_button, ttk.Button), "Color widget is not a Button" 227 | assert isinstance(color_swatch, ttk.Label), "Color swatch widget is not a Label" 228 | 229 | # Verify initial swatch color 230 | assert str(color_swatch.cget("background")) == initial_color 231 | 232 | # Simulate button click (requires command binding) 233 | color_button.invoke() 234 | root.update() 235 | 236 | # Assert askcolor was called 237 | mock_askcolor.assert_called_once() 238 | 239 | # Assert UIManager was called with the new color 240 | mock_ui_manager.handle_property_change.assert_called_once_with(test_id, 'color', new_color_hex) 241 | 242 | # Assert swatch background was updated 243 | assert str(color_swatch.cget("background")) == new_color_hex 244 | 245 | def test_properties_panel_animation_select_updates(root, mocker): 246 | """Test updating the animation entry calls ui_manager.handle_property_change (TEMP).""" 247 | mock_ui_manager = mocker.Mock() 248 | properties_panel = PropertiesPanel(root, mock_ui_manager) 249 | properties_panel.pack(fill=tk.BOTH, expand=True) 250 | root.update_idletasks() 251 | 252 | # Display properties including animation 253 | test_id = "circle_anim" 254 | initial_animation = "None" 255 | test_props = { 256 | 'type': 'Circle', 'radius': 1, 'position': (0,0,0), 257 | 'color': '#FFF', 'opacity': 1.0, 'animation': initial_animation 258 | } 259 | properties_panel.display_properties(test_id, test_props) 260 | root.update_idletasks() 261 | 262 | # Find the animation entry (assuming it's stored in self.widgets['animation']) 263 | assert 'animation' in properties_panel.widgets, "Animation widget not found in internal dict" 264 | anim_entry = properties_panel.widgets['animation'] 265 | # assert isinstance(anim_combo, ttk.Combobox), "Animation widget is not a Combobox" 266 | assert isinstance(anim_entry, ttk.Entry), "Animation widget is not an Entry (TEMP)" 267 | 268 | # Verify initial value 269 | # assert anim_combo.get() == initial_animation 270 | assert anim_entry.get() == initial_animation 271 | 272 | # Simulate changing the value in the entry 273 | new_animation = "GrowFromCenter" 274 | anim_entry.delete(0, tk.END) 275 | anim_entry.insert(0, new_animation) 276 | 277 | # Simulate FocusOut event 278 | anim_entry.event_generate("") 279 | root.update() # Process events 280 | 281 | # Assert UIManager was called with the new animation value via handle_property_change 282 | # mock_ui_manager.handle_animation_change.assert_called_once_with(test_id, new_animation) 283 | mock_ui_manager.handle_property_change.assert_called_once_with(test_id, 'animation', new_animation) 284 | 285 | # Placeholder for further property tests if needed (e.g., position updates) -------------------------------------------------------------------------------- /tests/gui/test_statusbar_panel.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tkinter as tk 3 | import ttkbootstrap as ttk 4 | from unittest.mock import Mock 5 | 6 | # Assuming StatusBarPanel will be in src/easymanim/gui/statusbar_panel.py 7 | from easymanim.gui.statusbar_panel import StatusBarPanel 8 | 9 | # Fixture to create a root window for tests needing GUI elements 10 | @pytest.fixture(scope="function") # Function scope for isolation 11 | def root(): 12 | root = tk.Tk() 13 | root.withdraw() # Hide the main window during tests 14 | yield root 15 | root.destroy() 16 | 17 | def test_statusbar_panel_set_status(root, mocker): 18 | """Test the initial status and the set_status method.""" 19 | mock_ui_manager = mocker.Mock() # Not really needed, but pass for consistency 20 | 21 | # Create the panel instance 22 | statusbar_panel = StatusBarPanel(root, mock_ui_manager) 23 | statusbar_panel.pack(fill=tk.X) # Status bar typically fills horizontally 24 | root.update_idletasks() 25 | 26 | # Find the Label widget (assuming it's the only direct child) 27 | children = statusbar_panel.winfo_children() 28 | assert len(children) == 1, "Expected 1 child widget (status label)" 29 | status_label = children[0] 30 | assert isinstance(status_label, ttk.Label), "Expected status widget to be a ttk.Label" 31 | 32 | # Check initial text (assuming starts with "Ready") 33 | initial_text = "Ready" 34 | assert status_label.cget("text") == initial_text 35 | 36 | # Call set_status 37 | new_status = "Processing request..." 38 | statusbar_panel.set_status(new_status) 39 | root.update_idletasks() 40 | 41 | # Check updated text 42 | assert status_label.cget("text") == new_status -------------------------------------------------------------------------------- /tests/gui/test_timeline_panel.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tkinter as tk 3 | import ttkbootstrap as ttk 4 | from unittest.mock import Mock 5 | 6 | # Assuming TimelinePanel will be in src/easymanim/gui/timeline_panel.py 7 | from easymanim.gui.timeline_panel import TimelinePanel 8 | 9 | # Fixture to create a root window for tests needing GUI elements 10 | @pytest.fixture(scope="module") 11 | def root(): 12 | root = tk.Tk() 13 | root.withdraw() # Hide the main window during tests 14 | yield root 15 | root.destroy() 16 | 17 | def test_timeline_panel_init_shows_placeholder(root, mocker): 18 | """Test that the TimelinePanel shows placeholder text on initialization.""" 19 | mock_ui_manager = mocker.Mock() 20 | 21 | # Create the panel instance 22 | timeline_panel = TimelinePanel(root, mock_ui_manager) 23 | timeline_panel.pack(fill=tk.BOTH, expand=True) # Need layout for canvas size 24 | 25 | # Find the Canvas widget 26 | canvas = None 27 | for widget in timeline_panel.winfo_children(): 28 | if isinstance(widget, tk.Canvas): 29 | canvas = widget 30 | break 31 | assert canvas is not None, "Canvas widget not found in TimelinePanel" 32 | 33 | # Explicitly set a size for the canvas in the test 34 | canvas.config(width=300, height=200) 35 | 36 | # Force tkinter to update geometry/layout so canvas has dimensions 37 | root.update_idletasks() 38 | root.update() 39 | 40 | # Explicitly call draw method after update to ensure text is drawn 41 | # Handles potential timing issues with event in tests 42 | timeline_panel._draw_placeholder_text() 43 | root.update() # Force full update after drawing 44 | 45 | # Find the placeholder text item by tag (assuming we implement it with this tag) 46 | placeholder_tag = "placeholder_text" 47 | placeholder_items = canvas.find_withtag(placeholder_tag) 48 | 49 | assert len(placeholder_items) == 1, f"Expected 1 item with tag '{placeholder_tag}', found {len(placeholder_items)}" 50 | 51 | placeholder_id = placeholder_items[0] 52 | placeholder_text = canvas.itemcget(placeholder_id, "text") 53 | 54 | expected_text = "Timeline - Add objects using the toolbar" 55 | assert placeholder_text == expected_text, f"Expected placeholder text '{expected_text}', but got '{placeholder_text}'" 56 | 57 | # Placeholder for future tests 58 | def test_timeline_panel_add_block_creates_items(root, mocker): 59 | """Test that add_block adds rectangle and text items to the canvas.""" 60 | mock_ui_manager = mocker.Mock() 61 | timeline_panel = TimelinePanel(root, mock_ui_manager) 62 | timeline_panel.pack(fill=tk.BOTH, expand=True) 63 | root.update_idletasks() 64 | canvas = timeline_panel.canvas 65 | 66 | # Ensure placeholder is there initially 67 | # Explicitly call draw method after update to ensure text is drawn 68 | timeline_panel._draw_placeholder_text() 69 | assert canvas.find_withtag("placeholder_text"), "Placeholder should exist initially" 70 | 71 | # Call add_block 72 | test_id = "circle_abc" 73 | test_type = "Circle" 74 | timeline_panel.add_block(obj_id=test_id, obj_type=test_type) 75 | root.update_idletasks() # Ensure drawing updates 76 | 77 | # Assert placeholder is gone 78 | assert not canvas.find_withtag("placeholder_text"), "Placeholder should be removed after adding a block" 79 | 80 | # Assert block items exist (assuming a common tag 'timeline_block' and specific id tag) 81 | block_items = canvas.find_withtag(f"obj_{test_id}") 82 | assert len(block_items) >= 2, f"Expected at least 2 items (rect, text) for obj_{test_id}, found {len(block_items)}" 83 | 84 | # Find the text item specifically (might need a more specific tag or check item type) 85 | text_item_id = None 86 | rect_item_id = None 87 | for item_id in block_items: 88 | try: # Check item type safely 89 | if canvas.type(item_id) == "text": 90 | text_item_id = item_id 91 | elif canvas.type(item_id) == "rectangle": 92 | rect_item_id = item_id 93 | except tk.TclError: # Item might have been deleted? 94 | pass 95 | 96 | assert text_item_id is not None, "Text item for the block not found" 97 | assert rect_item_id is not None, "Rectangle item for the block not found" 98 | 99 | # Check text content 100 | block_text = canvas.itemcget(text_item_id, "text") 101 | assert test_id in block_text, f"Block text '{block_text}' should contain object ID '{test_id}'" 102 | assert test_type in block_text, f"Block text '{block_text}' should contain object type '{test_type}'" 103 | 104 | # Check internal mapping 105 | # We need to know which item ID (rect or text) is used as the key 106 | # Let's assume the rectangle ID is the key for now (implementation detail) 107 | assert rect_item_id in timeline_panel.object_canvas_items, "Rectangle ID not found in internal mapping" 108 | assert timeline_panel.object_canvas_items[rect_item_id] == test_id, "Internal mapping does not point to the correct object ID" 109 | 110 | def test_timeline_panel_click_block_selects(root, mocker): 111 | """Test clicking a block calls ui_manager.handle_timeline_selection with the object ID.""" 112 | mock_ui_manager = mocker.Mock() 113 | timeline_panel = TimelinePanel(root, mock_ui_manager) 114 | timeline_panel.pack(fill=tk.BOTH, expand=True) 115 | root.update_idletasks() 116 | canvas = timeline_panel.canvas 117 | timeline_panel._draw_placeholder_text() # Ensure placeholder drawn initially if needed 118 | 119 | # Add a block 120 | test_id = "circle_xyz" 121 | timeline_panel.add_block(obj_id=test_id, obj_type="Circle") 122 | root.update_idletasks() 123 | 124 | # Find the rectangle ID for the added block 125 | rect_item_id = None 126 | block_items = canvas.find_withtag(f"obj_{test_id}") 127 | for item_id in block_items: 128 | try: 129 | if canvas.type(item_id) == "rectangle": 130 | rect_item_id = item_id 131 | break 132 | except tk.TclError: 133 | pass 134 | assert rect_item_id is not None, "Rectangle for block not found" 135 | 136 | # Get coordinates of the rectangle to simulate a click inside it 137 | coords = canvas.coords(rect_item_id) 138 | assert len(coords) == 4, "Expected 4 coordinates for rectangle" 139 | click_x = (coords[0] + coords[2]) / 2 # Middle X 140 | click_y = (coords[1] + coords[3]) / 2 # Middle Y 141 | 142 | # Unbind the default configure event temporarily if it interferes 143 | # canvas.unbind("") 144 | 145 | # Simulate the click event 146 | # We need to ensure the _on_canvas_click handler is bound first (will be done in Green step) 147 | # For now, we can call it directly if the binding isn't active 148 | # timeline_panel._on_canvas_click(mocker.Mock(x=click_x, y=click_y)) 149 | 150 | # Directly call the handler with a mock event 151 | mock_event = mocker.Mock() 152 | mock_event.x = int(click_x) 153 | mock_event.y = int(click_y) 154 | timeline_panel._on_canvas_click(mock_event) 155 | 156 | # Assert UIManager was called with the correct ID 157 | mock_ui_manager.handle_timeline_selection.assert_called_once_with(test_id) 158 | 159 | def test_timeline_panel_click_background_deselects(root, mocker): 160 | """Test clicking the background calls ui_manager.handle_timeline_selection with None.""" 161 | mock_ui_manager = mocker.Mock() 162 | timeline_panel = TimelinePanel(root, mock_ui_manager) 163 | # Give the canvas a defined size 164 | timeline_panel.pack(padx=20, pady=20) 165 | timeline_panel.config(width=200, height=100) 166 | canvas = timeline_panel.canvas 167 | canvas.pack(fill=tk.BOTH, expand=True) 168 | root.update_idletasks() 169 | timeline_panel._draw_placeholder_text() # Ensure placeholder drawn initially if needed 170 | 171 | # Optional: Add a block to ensure we're not clicking it 172 | timeline_panel.add_block(obj_id="dummy_id", obj_type="Square") 173 | root.update_idletasks() 174 | 175 | # Simulate a click far away from any potential blocks (e.g., near corner) 176 | # Use coordinates guaranteed to be outside the first block 177 | click_x = 190 178 | click_y = 90 179 | 180 | # Ensure find_closest at these coordinates returns nothing or the canvas itself 181 | # This depends slightly on canvas implementation details, but should not find a block 182 | # items_at_click = canvas.find_closest(click_x, click_y) 183 | # is_background_click = True 184 | # if items_at_click: 185 | # item_id = items_at_click[0] 186 | # tags = canvas.gettags(item_id) 187 | # if any(tag.startswith("obj_") for tag in tags): 188 | # is_background_click = False 189 | # 190 | # assert is_background_click, f"Click at ({click_x},{click_y}) incorrectly found an object block: {items_at_click}" 191 | 192 | # Directly call the handler with a mock event 193 | mock_event = mocker.Mock() 194 | mock_event.x = int(click_x) 195 | mock_event.y = int(click_y) 196 | timeline_panel._on_canvas_click(mock_event) 197 | 198 | # Assert UIManager was called with None 199 | mock_ui_manager.handle_timeline_selection.assert_called_once_with(None) 200 | 201 | def test_timeline_panel_highlight_block(root, mocker): 202 | """Test that highlight_block changes the outline of the correct block.""" 203 | mock_ui_manager = mocker.Mock() # Not strictly needed, but keep pattern 204 | timeline_panel = TimelinePanel(root, mock_ui_manager) 205 | timeline_panel.pack(fill=tk.BOTH, expand=True) 206 | root.update_idletasks() 207 | canvas = timeline_panel.canvas 208 | timeline_panel._draw_placeholder_text() 209 | 210 | # Add two blocks 211 | id1 = "circle_1" 212 | id2 = "square_2" 213 | timeline_panel.add_block(obj_id=id1, obj_type="Circle") 214 | timeline_panel.add_block(obj_id=id2, obj_type="Square") 215 | root.update_idletasks() 216 | 217 | # Helper to find rectangle ID for an object ID 218 | def find_rect_id(obj_id): 219 | for rect_id, mapped_obj_id in timeline_panel.object_canvas_items.items(): 220 | if mapped_obj_id == obj_id: 221 | # Verify it's actually a rectangle before returning 222 | try: 223 | if canvas.type(rect_id) == "rectangle": 224 | return rect_id 225 | except tk.TclError: 226 | pass # Item might not exist 227 | return None 228 | 229 | rect_id1 = find_rect_id(id1) 230 | rect_id2 = find_rect_id(id2) 231 | assert rect_id1 is not None, f"Rectangle for {id1} not found" 232 | assert rect_id2 is not None, f"Rectangle for {id2} not found" 233 | 234 | default_outline = "black" # As defined in add_block 235 | highlight_outline = "red" # Define highlight color 236 | highlight_width = 2 # Define highlight width 237 | default_width = 1 238 | 239 | # Initial state: both should have default outline 240 | assert canvas.itemcget(rect_id1, "outline") == default_outline 241 | assert canvas.itemcget(rect_id2, "outline") == default_outline 242 | assert int(float(canvas.itemcget(rect_id1, "width"))) == default_width 243 | assert int(float(canvas.itemcget(rect_id2, "width"))) == default_width 244 | 245 | 246 | # Highlight block 1 247 | timeline_panel.highlight_block(id1) 248 | root.update_idletasks() 249 | assert canvas.itemcget(rect_id1, "outline") == highlight_outline 250 | assert int(float(canvas.itemcget(rect_id1, "width"))) == highlight_width 251 | assert canvas.itemcget(rect_id2, "outline") == default_outline # Block 2 unchanged 252 | assert int(float(canvas.itemcget(rect_id2, "width"))) == default_width 253 | 254 | # Highlight block 2 (should deselect block 1) 255 | timeline_panel.highlight_block(id2) 256 | root.update_idletasks() 257 | assert canvas.itemcget(rect_id1, "outline") == default_outline # Block 1 back to default 258 | assert int(float(canvas.itemcget(rect_id1, "width"))) == default_width 259 | assert canvas.itemcget(rect_id2, "outline") == highlight_outline 260 | assert int(float(canvas.itemcget(rect_id2, "width"))) == highlight_width 261 | 262 | # Deselect all 263 | timeline_panel.highlight_block(None) 264 | root.update_idletasks() 265 | assert canvas.itemcget(rect_id1, "outline") == default_outline 266 | assert int(float(canvas.itemcget(rect_id1, "width"))) == default_width 267 | assert canvas.itemcget(rect_id2, "outline") == default_outline 268 | assert int(float(canvas.itemcget(rect_id2, "width"))) == default_width 269 | 270 | # def test_timeline_panel_highlight_block(root, mocker): 271 | # pass -------------------------------------------------------------------------------- /tests/gui/test_toolbar_panel.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tkinter as tk 3 | import ttkbootstrap as ttk # Import ttk 4 | from unittest.mock import Mock 5 | 6 | # Assuming ToolbarPanel will be in src/easymanim/gui/toolbar_panel.py 7 | # We'll need to adjust the import path if the structure differs or use editable install. 8 | # For now, let's assume it's findable. 9 | from easymanim.gui.toolbar_panel import ToolbarPanel 10 | # UIManager might not be directly needed if we just mock its interface 11 | # from easymanim.ui.ui_manager import UIManager # Keep commented unless needed 12 | 13 | # Fixture to create a root window for tests needing GUI elements 14 | @pytest.fixture(scope="function") 15 | def root(): 16 | # Use ttkbootstrap Themed Tk for consistency if needed, though Tk() often works 17 | # root = ttk.Window() 18 | root = tk.Tk() 19 | root.withdraw() # Hide the main window during tests 20 | yield root 21 | root.destroy() 22 | 23 | def test_toolbar_add_circle_button_command(root, mocker): 24 | """Test that clicking the 'Add Circle' button calls the correct UIManager method.""" 25 | mock_ui_manager = mocker.Mock() 26 | 27 | # Create the panel instance 28 | toolbar_panel = ToolbarPanel(root, mock_ui_manager) 29 | toolbar_panel.pack() # Necessary for widget geometry/finding 30 | 31 | # Find the 'Add Circle' button - brittle, depends on implementation details 32 | # A better way might be to store button references on the instance if needed often 33 | add_circle_button = None 34 | for widget in toolbar_panel.winfo_children(): 35 | # Check for ttk.Button instead of tk.Button 36 | if isinstance(widget, ttk.Button) and widget.cget("text") == "Add Circle": 37 | add_circle_button = widget 38 | break 39 | 40 | assert add_circle_button is not None, "'Add Circle' button not found" 41 | 42 | # Simulate the button click 43 | add_circle_button.invoke() 44 | 45 | # Assert that the UIManager method was called correctly 46 | mock_ui_manager.handle_add_object_request.assert_called_once_with('Circle') 47 | 48 | # Placeholder for future tests 49 | def test_toolbar_add_square_button_command(root, mocker): 50 | """Test that clicking the 'Add Square' button calls the correct UIManager method.""" 51 | mock_ui_manager = mocker.Mock() 52 | toolbar_panel = ToolbarPanel(root, mock_ui_manager) 53 | toolbar_panel.pack() 54 | 55 | add_square_button = None 56 | for widget in toolbar_panel.winfo_children(): 57 | if isinstance(widget, ttk.Button) and widget.cget("text") == "Add Square": 58 | add_square_button = widget 59 | break 60 | assert add_square_button is not None, "'Add Square' button not found" 61 | 62 | add_square_button.invoke() 63 | mock_ui_manager.handle_add_object_request.assert_called_once_with('Square') 64 | 65 | def test_toolbar_add_text_button_command(root, mocker): 66 | """Test that clicking the 'Add Text' button calls the correct UIManager method.""" 67 | mock_ui_manager = mocker.Mock() 68 | toolbar_panel = ToolbarPanel(root, mock_ui_manager) 69 | toolbar_panel.pack() 70 | 71 | add_text_button = None 72 | for widget in toolbar_panel.winfo_children(): 73 | if isinstance(widget, ttk.Button) and widget.cget("text") == "Add Text": 74 | add_text_button = widget 75 | break 76 | assert add_text_button is not None, "'Add Text' button not found" 77 | 78 | add_text_button.invoke() 79 | mock_ui_manager.handle_add_object_request.assert_called_once_with('Text') -------------------------------------------------------------------------------- /tests/interface/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/interface/test_manim_interface.py: -------------------------------------------------------------------------------- 1 | # tests/interface/test_manim_interface.py 2 | """Tests for the ManimInterface class.""" 3 | 4 | import pytest 5 | from unittest.mock import MagicMock, patch, mock_open 6 | import pathlib 7 | import tempfile # Import tempfile 8 | import threading # Import threading 9 | import subprocess # Import subprocess 10 | import os # Import os for cleanup test later 11 | 12 | # We expect ManimInterface to be in src/easymanim/interface/manim_interface.py 13 | from easymanim.interface.manim_interface import ManimInterface 14 | 15 | # Define a dummy class to mock MainApplication for type hints and basic function 16 | class MockMainApplication: 17 | def schedule_task(self, callback, *args): 18 | # In tests, we might just call the callback immediately 19 | # or use a mock to check if it was called 20 | pass 21 | 22 | class TestManimInterface: 23 | """Test suite for the ManimInterface.""" 24 | 25 | def test_init_stores_root_app_and_defines_temp_dir(self): 26 | """Verify __init__ stores root_app and sets up temp_script_dir. 27 | Red Step: Requires __init__ method implementation. 28 | """ 29 | mock_app = MockMainApplication() 30 | interface = ManimInterface(root_app=mock_app) 31 | 32 | assert interface.root_app is mock_app, "Should store the root_app reference" 33 | 34 | assert hasattr(interface, 'temp_script_dir'), "Should have a temp_script_dir attribute" 35 | assert isinstance(interface.temp_script_dir, pathlib.Path), "temp_script_dir should be a Path object" 36 | # We might test directory *creation* later or assume it happens here 37 | # For now, just check the attribute exists and is the right type 38 | 39 | @patch('tempfile.NamedTemporaryFile') 40 | def test_render_async_writes_script_to_temp_file(self, mock_named_temp_file, mocker): 41 | """Verify render_async creates and writes to a temporary script file. 42 | Red Step: Requires render_async method implementation. 43 | Uses mocker to patch tempfile.NamedTemporaryFile. 44 | """ 45 | # Setup mock for the file handle returned by NamedTemporaryFile 46 | mock_file_handle = MagicMock() 47 | # Configure the context manager behavior 48 | mock_named_temp_file.return_value.__enter__.return_value = mock_file_handle 49 | # Store the dummy path in the mock file handle 50 | dummy_script_path = "/tmp/dummy_script_xyz.py" 51 | mock_file_handle.name = dummy_script_path 52 | 53 | mock_app = MockMainApplication() 54 | interface = ManimInterface(root_app=mock_app) 55 | 56 | dummy_script_content = "from manim import *\nclass TestScene(Scene): pass" 57 | dummy_scene_name = "TestScene" 58 | dummy_flags = ["-ql"] 59 | dummy_format = "png" 60 | mock_callback = MagicMock() 61 | 62 | # Call the method under test 63 | interface.render_async( 64 | script_content=dummy_script_content, 65 | scene_name=dummy_scene_name, 66 | quality_flags=dummy_flags, 67 | output_format=dummy_format, 68 | callback=mock_callback 69 | ) 70 | 71 | # Assert NamedTemporaryFile was called correctly 72 | mock_named_temp_file.assert_called_once_with( 73 | mode='w', 74 | suffix='.py', 75 | delete=False, # Important for passing path to subprocess 76 | dir=str(interface.temp_script_dir), # Ensure it's a string if needed by mock 77 | encoding='utf-8' # Good practice to specify encoding 78 | ) 79 | 80 | # Assert write was called with the script content on the mock file handle 81 | mock_file_handle.write.assert_called_once_with(dummy_script_content) 82 | # Assert the context manager was used (implies close) 83 | assert mock_named_temp_file.return_value.__exit__.called 84 | 85 | @patch('threading.Thread') # Patch the Thread class 86 | @patch('tempfile.NamedTemporaryFile') # Still need to patch file writing 87 | def test_render_async_starts_thread(self, mock_named_temp_file, mock_thread, mocker): 88 | """Verify render_async starts a thread targeting _run_manim_thread. 89 | Red Step: Requires render_async to instantiate and start threading.Thread. 90 | """ 91 | # Setup mock for file writing 92 | mock_file_handle = MagicMock() 93 | mock_named_temp_file.return_value.__enter__.return_value = mock_file_handle 94 | dummy_script_path = "/tmp/dummy_script_thread.py" 95 | mock_file_handle.name = dummy_script_path 96 | 97 | mock_app = MockMainApplication() 98 | interface = ManimInterface(root_app=mock_app) 99 | # Add a dummy _run_manim_thread for the target check to work during test 100 | interface._run_manim_thread = MagicMock() 101 | 102 | # Dummy args for render_async 103 | dummy_script_content = "Script Content" 104 | dummy_scene_name = "SceneName" 105 | dummy_flags = ["-flag"] 106 | dummy_format = "mp4" 107 | mock_callback = MagicMock() 108 | 109 | # Call the method 110 | interface.render_async( 111 | script_content=dummy_script_content, 112 | scene_name=dummy_scene_name, 113 | quality_flags=dummy_flags, 114 | output_format=dummy_format, 115 | callback=mock_callback 116 | ) 117 | 118 | # Assert threading.Thread was called 119 | mock_thread.assert_called_once() 120 | 121 | # Get the arguments passed to the Thread constructor 122 | call_args, call_kwargs = mock_thread.call_args 123 | 124 | # Check the target 125 | assert call_kwargs.get('target') == interface._run_manim_thread 126 | 127 | # Check the arguments passed to the target 128 | expected_args = ( 129 | dummy_script_path, # Path from mock file handle 130 | dummy_scene_name, 131 | dummy_flags, 132 | dummy_format, 133 | mock_callback 134 | ) 135 | assert call_kwargs.get('args') == expected_args 136 | 137 | # Assert the start method was called on the thread instance 138 | mock_thread.return_value.start.assert_called_once() 139 | 140 | @patch('subprocess.run') 141 | def test_run_manim_thread_calls_subprocess_correctly_preview(self, mock_subprocess_run, mocker): 142 | """Verify _run_manim_thread calls subprocess.run with correct preview args. 143 | Red Step: Requires _run_manim_thread to construct and run the command. 144 | """ 145 | # Mock the return value of subprocess.run to avoid errors in this test 146 | mock_subprocess_run.return_value = MagicMock(returncode=0, stdout="", stderr="") 147 | 148 | mock_app = MockMainApplication() 149 | # Mock the schedule_task to check callbacks later if needed 150 | mock_app.schedule_task = MagicMock() 151 | interface = ManimInterface(root_app=mock_app) 152 | 153 | # Arguments for the thread function 154 | dummy_script_path = "/tmp/dummy_preview.py" 155 | dummy_scene_name = "PreviewScene" 156 | # Preview flags according to checklist/architecture 157 | dummy_flags = ["-s", "-ql"] 158 | dummy_format = "png" 159 | mock_callback = MagicMock() 160 | 161 | # Call the method directly (it runs synchronously in the test) 162 | interface._run_manim_thread( 163 | script_path=dummy_script_path, 164 | scene_name=dummy_scene_name, 165 | flags=dummy_flags, 166 | output_format=dummy_format, 167 | callback=mock_callback 168 | ) 169 | 170 | # Construct the expected command list 171 | expected_command = [ 172 | 'python', '-m', 'manim', 173 | dummy_script_path, 174 | dummy_scene_name 175 | ] + dummy_flags 176 | 177 | # Assert subprocess.run was called correctly 178 | mock_subprocess_run.assert_called_once_with( 179 | expected_command, 180 | capture_output=True, 181 | text=True, 182 | check=False # Important: we check returncode manually 183 | ) 184 | 185 | @patch('subprocess.run') 186 | def test_run_manim_thread_calls_subprocess_correctly_render(self, mock_subprocess_run, mocker): 187 | """Verify _run_manim_thread calls subprocess.run with correct render args. 188 | Green Step: Should pass with current implementation. 189 | """ 190 | mock_subprocess_run.return_value = MagicMock(returncode=0, stdout="", stderr="") 191 | mock_app = MockMainApplication() 192 | mock_app.schedule_task = MagicMock() 193 | interface = ManimInterface(root_app=mock_app) 194 | 195 | dummy_script_path = "/tmp/dummy_render.py" 196 | dummy_scene_name = "EasyManimScene" 197 | # Render flags according to checklist/architecture 198 | dummy_flags = ["-ql"] 199 | dummy_format = "mp4" 200 | mock_callback = MagicMock() 201 | 202 | interface._run_manim_thread( 203 | script_path=dummy_script_path, 204 | scene_name=dummy_scene_name, 205 | flags=dummy_flags, 206 | output_format=dummy_format, 207 | callback=mock_callback 208 | ) 209 | 210 | expected_command = [ 211 | 'python', '-m', 'manim', 212 | dummy_script_path, 213 | dummy_scene_name 214 | ] + dummy_flags 215 | 216 | mock_subprocess_run.assert_called_once_with( 217 | expected_command, 218 | capture_output=True, 219 | text=True, 220 | check=False 221 | ) 222 | 223 | @patch('os.remove') # Mock cleanup 224 | @patch('pathlib.Path') # Mock Path for glob and read_bytes 225 | @patch('subprocess.run') # Mock subprocess 226 | def test_run_manim_thread_schedules_success_callback_png(self, mock_subprocess_run, MockPath, mock_os_remove, mocker): 227 | """Verify _run_manim_thread schedules callback with PNG bytes on success. 228 | Red Step: Requires result checking, file finding, reading, and callback scheduling. 229 | """ 230 | # --- Mock subprocess success --- 231 | mock_subprocess_run.return_value = MagicMock(returncode=0, stdout="Success!", stderr="") 232 | 233 | # --- Mock pathlib.Path --- 234 | # Instance needed for the glob call 235 | mock_path_instance = MagicMock() 236 | # Configure the glob method to return a list with one mock file path 237 | mock_output_file_path = MagicMock() 238 | mock_path_instance.glob.return_value = [mock_output_file_path] 239 | # Configure the read_bytes method on the mock file path 240 | mock_png_bytes = b'\x89PNG\r\n\x1a\n' # Minimal PNG header 241 | mock_output_file_path.read_bytes.return_value = mock_png_bytes 242 | # Make Path('.') return our mock instance that has glob 243 | MockPath.return_value = mock_path_instance 244 | 245 | # --- Mock App and Interface --- 246 | mock_app = MockMainApplication() 247 | mock_app.schedule_task = MagicMock() 248 | interface = ManimInterface(root_app=mock_app) 249 | 250 | # --- Args for thread function --- 251 | dummy_script_path = "/tmp/dummy_success.py" 252 | dummy_scene_name = "SuccessScene" 253 | dummy_flags = ["-s", "-ql"] 254 | dummy_format = "png" 255 | mock_callback = MagicMock() 256 | 257 | # --- Call the method --- 258 | interface._run_manim_thread( 259 | script_path=dummy_script_path, 260 | scene_name=dummy_scene_name, 261 | flags=dummy_flags, 262 | output_format=dummy_format, 263 | callback=mock_callback 264 | ) 265 | 266 | # --- Assertions --- 267 | # 1. Check that glob was called correctly to find the output file 268 | expected_glob_pattern = f"media/images/{pathlib.Path(dummy_script_path).stem}/{dummy_scene_name}*.png" 269 | mock_path_instance.glob.assert_called_once_with(expected_glob_pattern) 270 | 271 | # 2. Check that read_bytes was called on the found file 272 | mock_output_file_path.read_bytes.assert_called_once() 273 | 274 | # 3. Check that schedule_task was called with success and image bytes 275 | mock_app.schedule_task.assert_called_once_with(mock_callback, True, mock_png_bytes) 276 | 277 | # 4. Check cleanup (will be tested more thoroughly later) 278 | # mock_os_remove.assert_called_once_with(dummy_script_path) 279 | 280 | @patch('os.remove') # Mock cleanup 281 | @patch('subprocess.run') 282 | # Patch os.path.exists now 283 | @patch('os.path.exists') 284 | def test_run_manim_thread_schedules_success_callback_mp4(self, mock_os_path_exists, mock_subprocess_run, mock_os_remove, mocker): 285 | """Verify _run_manim_thread schedules callback with MP4 path on success. 286 | Uses os.path.exists mock. 287 | """ 288 | # --- Mock subprocess --- 289 | mock_result = MagicMock(returncode=0, stdout="Video Success!", stderr="") 290 | mock_subprocess_run.return_value = mock_result # Assign directly 291 | 292 | # --- Mock App and Interface --- 293 | mock_app = MockMainApplication() 294 | mock_app.schedule_task = MagicMock() 295 | interface = ManimInterface(root_app=mock_app) 296 | 297 | # --- Args for thread function --- 298 | dummy_script_path_str = "/tmp/dummy_mp4_success_ospath.py" 299 | dummy_scene_name = "SuccessScene" 300 | dummy_flags = ["-ql"] 301 | dummy_format = "mp4" 302 | mock_callback = MagicMock() 303 | 304 | # --- Determine Expected Path String --- 305 | # Still need stem, use real pathlib locally for this 306 | script_stem = pathlib.Path(dummy_script_path_str).stem 307 | quality_dir = interface._get_quality_directory(dummy_flags) 308 | # Use os.path.join to match implementation 309 | expected_path_str = os.path.join('.', "media", "videos", script_stem, quality_dir, f"{dummy_scene_name}.mp4") 310 | 311 | # --- Configure the side effect for the mocked os.path.exists --- 312 | def os_path_exists_side_effect(path_arg): 313 | # path_arg is the string passed to os.path.exists 314 | print(f"*** os.path.exists called with: {path_arg} ***") 315 | if path_arg == expected_path_str: 316 | print(f" Matched MP4 Path -> TRUE") 317 | return True 318 | elif path_arg == dummy_script_path_str: 319 | print(f" Matched Script Path -> TRUE") 320 | return True # For cleanup check in finally block 321 | else: 322 | print(f" OTHER Path -> FALSE") 323 | return False 324 | mock_os_path_exists.side_effect = os_path_exists_side_effect 325 | 326 | # --- Call the method --- 327 | interface._run_manim_thread( 328 | script_path=dummy_script_path_str, 329 | scene_name=dummy_scene_name, 330 | flags=dummy_flags, 331 | output_format=dummy_format, 332 | callback=mock_callback 333 | ) 334 | 335 | # --- Assertions --- 336 | # 1. Check that os.path.exists was called with expected paths 337 | mock_os_path_exists.assert_any_call(expected_path_str) 338 | mock_os_path_exists.assert_any_call(dummy_script_path_str) 339 | 340 | # 2. Check that schedule_task was called with success and the path string 341 | mock_app.schedule_task.assert_called_once_with(mock_callback, True, expected_path_str) 342 | 343 | # 3. Check cleanup 344 | mock_os_remove.assert_called_once_with(dummy_script_path_str) 345 | 346 | @patch('os.remove') 347 | @patch('os.path.exists') 348 | @patch('subprocess.run') 349 | def test_run_manim_thread_schedules_failure_callback(self, mock_subprocess_run, mock_os_path_exists, mock_os_remove): 350 | """Verify _run_manim_thread schedules callback with error message on failure. 351 | Red Step: Requires handling of non-zero return code. 352 | """ 353 | # --- Mock subprocess failure --- 354 | error_output = "Traceback:\nSomething went wrong!" 355 | mock_subprocess_run.return_value = MagicMock(returncode=1, stdout="", stderr=error_output) 356 | 357 | # --- Mock os.path.exists for cleanup check --- 358 | dummy_script_path_str = "/tmp/dummy_fail.py" 359 | def exists_side_effect(path_arg): 360 | if path_arg == dummy_script_path_str: 361 | return True # Assume script exists for cleanup 362 | return False 363 | mock_os_path_exists.side_effect = exists_side_effect 364 | 365 | # --- Mock App and Interface --- 366 | mock_app = MockMainApplication() 367 | mock_app.schedule_task = MagicMock() 368 | interface = ManimInterface(root_app=mock_app) 369 | 370 | # --- Args for thread function --- 371 | dummy_scene_name = "FailScene" 372 | dummy_flags = ["-ql"] 373 | dummy_format = "mp4" # Format doesn't matter much for failure 374 | mock_callback = MagicMock() 375 | 376 | # --- Call the method --- 377 | interface._run_manim_thread( 378 | script_path=dummy_script_path_str, 379 | scene_name=dummy_scene_name, 380 | flags=dummy_flags, 381 | output_format=dummy_format, 382 | callback=mock_callback 383 | ) 384 | 385 | # --- Assertions --- 386 | # 1. Check schedule_task called with failure and error message 387 | # schedule_task(callback, success, result_data) 388 | mock_app.schedule_task.assert_called_once() 389 | call_args = mock_app.schedule_task.call_args[0] 390 | assert call_args[0] == mock_callback, "Callback function mismatch" 391 | assert call_args[1] is False, "Success flag should be False" 392 | assert isinstance(call_args[2], str), "Result data should be an error string" 393 | assert "Manim failed (code 1)" in call_args[2], "Error message should contain failure code" 394 | assert error_output in call_args[2], "Error message should contain stderr" 395 | 396 | # 2. Check cleanup 397 | mock_os_remove.assert_called_once_with(dummy_script_path_str) 398 | 399 | @patch('os.remove') 400 | @patch('os.path.exists') 401 | @patch('subprocess.run') 402 | def test_run_manim_thread_cleans_up_temp_file(self, mock_subprocess_run, mock_os_path_exists, mock_os_remove): 403 | """Verify _run_manim_thread removes the temp script file in finally block. 404 | Red Step: Requires cleanup implementation in the finally block. 405 | """ 406 | # --- Mock subprocess (result doesn't matter for cleanup) --- 407 | mock_subprocess_run.return_value = MagicMock(returncode=0) 408 | 409 | # --- Mock os.path.exists --- 410 | dummy_script_path_str = "/tmp/dummy_cleanup.py" 411 | # Simulate file existing initially, then not existing after remove is called 412 | # (Though we only check remove was called once) 413 | exist_results = {dummy_script_path_str: True} 414 | def exists_side_effect(path_arg): 415 | return exist_results.get(path_arg, False) 416 | mock_os_path_exists.side_effect = exists_side_effect 417 | 418 | # --- Mock App and Interface --- 419 | mock_app = MockMainApplication() 420 | mock_app.schedule_task = MagicMock() # Need mock schedule_task 421 | interface = ManimInterface(root_app=mock_app) 422 | 423 | # --- Args for thread function --- 424 | dummy_scene_name = "CleanupScene" 425 | dummy_flags = ["-ql"] 426 | dummy_format = "mp4" 427 | mock_callback = MagicMock() 428 | 429 | # --- Call the method --- 430 | interface._run_manim_thread( 431 | script_path=dummy_script_path_str, 432 | scene_name=dummy_scene_name, 433 | flags=dummy_flags, 434 | output_format=dummy_format, 435 | callback=mock_callback 436 | ) 437 | 438 | # --- Assertions --- 439 | # 1. Check os.path.exists was called for the script path 440 | mock_os_path_exists.assert_any_call(dummy_script_path_str) 441 | 442 | # 2. Check os.remove was called exactly once with the script path 443 | mock_os_remove.assert_called_once_with(dummy_script_path_str) 444 | 445 | # Tests for failure callback, cleanup will go here -------------------------------------------------------------------------------- /tests/logic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/logic/test_scene_builder.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | # We expect SceneBuilder to be in src/easymanim/logic/scene_builder.py 3 | from easymanim.logic.scene_builder import SceneBuilder 4 | 5 | def test_init_creates_empty_object_list(): 6 | """ 7 | Verify that SceneBuilder initializes with an empty list of objects. 8 | Red Step: This test expects SceneBuilder to exist and have an 'objects' list. 9 | """ 10 | builder = SceneBuilder() 11 | # Assert that the 'objects' attribute exists and is an empty list 12 | assert hasattr(builder, 'objects'), "SceneBuilder instance should have an 'objects' attribute" 13 | assert builder.objects == [], "SceneBuilder should initialize with an empty 'objects' list" 14 | 15 | def test_add_object_returns_unique_id(): 16 | """Verify that adding objects returns unique IDs. 17 | Red Step: This test requires the add_object method to exist and return something. 18 | """ 19 | builder = SceneBuilder() 20 | # Assume 'Circle' is a valid type for now 21 | id1 = builder.add_object('Circle') 22 | id2 = builder.add_object('Square') # Use a different type for variety 23 | 24 | assert isinstance(id1, str), "ID should be a string" 25 | assert isinstance(id2, str), "ID should be a string" 26 | assert id1 != id2, "Consecutive calls to add_object should return unique IDs" 27 | assert len(id1) > 0, "ID should not be empty" 28 | assert len(id2) > 0, "ID should not be empty" 29 | 30 | def test_add_object_adds_correct_type_to_list(): 31 | """Verify add_object adds a dictionary with the correct type and ID to the internal list. 32 | Red Step: This requires add_object to modify self.objects. 33 | """ 34 | builder = SceneBuilder() 35 | obj_type_to_add = "Circle" 36 | returned_id = builder.add_object(obj_type_to_add) 37 | 38 | assert len(builder.objects) == 1, "objects list should contain one item after adding" 39 | 40 | added_object = builder.objects[0] 41 | assert isinstance(added_object, dict), "Item in objects list should be a dictionary" 42 | assert added_object.get('id') == returned_id, "Object dictionary should have the correct 'id'" 43 | assert added_object.get('type') == obj_type_to_add, "Object dictionary should have the correct 'type'" 44 | 45 | # Check for existence of a properties dictionary (content tested later) 46 | assert 'properties' in added_object, "Object dictionary should have a 'properties' key" 47 | assert isinstance(added_object['properties'], dict), "'properties' should be a dictionary" 48 | 49 | # Check for default animation state 50 | assert 'animation' in added_object, "Object dictionary should have an 'animation' key" 51 | assert added_object['animation'] == 'None', "Default animation should be 'None'" 52 | 53 | # Add another object to ensure list grows correctly 54 | builder.add_object("Square") 55 | assert len(builder.objects) == 2, "objects list should contain two items after adding a second" 56 | assert builder.objects[1].get('type') == "Square", "Second object should have the correct type" 57 | 58 | def test_get_object_properties_retrieves_correct_data(): 59 | """Verify get_object_properties returns the correct properties for a valid ID. 60 | Red Step: Requires the get_object_properties method. 61 | """ 62 | builder = SceneBuilder() 63 | circle_id = builder.add_object('Circle') 64 | square_id = builder.add_object('Square') 65 | 66 | circle_props = builder.get_object_properties(circle_id) 67 | square_props = builder.get_object_properties(square_id) 68 | 69 | assert isinstance(circle_props, dict), "Should return a dictionary for valid ID" 70 | assert circle_props.get('radius') == 1.0, "Returned properties should match defaults (Circle)" 71 | assert circle_props.get('fill_color') == '#58C4DD' 72 | 73 | assert isinstance(square_props, dict), "Should return a dictionary for valid ID" 74 | assert square_props.get('side_length') == 2.0, "Returned properties should match defaults (Square)" 75 | 76 | # Ensure the returned dict is the properties dict, not the whole object dict 77 | assert 'id' not in circle_props 78 | assert 'type' not in circle_props 79 | assert 'animation' not in circle_props 80 | 81 | def test_get_object_properties_returns_none_for_invalid_id(): 82 | """Verify get_object_properties returns None for an invalid or non-existent ID. 83 | Red Step: Requires the get_object_properties method. 84 | """ 85 | builder = SceneBuilder() 86 | builder.add_object('Circle') # Add something so the list isn't empty 87 | 88 | props = builder.get_object_properties("invalid_id_123") 89 | assert props is None, "Should return None for an invalid ID" 90 | 91 | props_empty = builder.get_object_properties("") 92 | assert props_empty is None, "Should return None for an empty string ID" 93 | 94 | def test_update_object_property_modifies_internal_state(): 95 | """Verify update_object_property correctly modifies the internal state. 96 | Red Step: Requires the update_object_property method. 97 | """ 98 | builder = SceneBuilder() 99 | circle_id = builder.add_object('Circle') 100 | initial_props = builder.get_object_properties(circle_id) 101 | assert initial_props['pos_x'] == 0.0 # Verify initial state 102 | 103 | # --- Test updating a valid property --- 104 | new_pos_x = -5.5 105 | builder.update_object_property(circle_id, 'pos_x', new_pos_x) 106 | 107 | updated_props = builder.get_object_properties(circle_id) 108 | assert updated_props['pos_x'] == new_pos_x, "pos_x should be updated" 109 | assert updated_props['pos_y'] == 0.0, "Other properties should remain unchanged" 110 | assert updated_props['radius'] == 1.0, "Other properties should remain unchanged" 111 | 112 | # --- Test updating a different property (color) --- 113 | new_color = "#FF0000" 114 | builder.update_object_property(circle_id, 'fill_color', new_color) 115 | updated_props_color = builder.get_object_properties(circle_id) 116 | assert updated_props_color['fill_color'] == new_color, "fill_color should be updated" 117 | assert updated_props_color['pos_x'] == new_pos_x, "Previous updates should persist" 118 | 119 | # --- Test updating non-existent ID --- 120 | # Ensure updating an invalid ID doesn't crash and doesn't affect existing objects 121 | try: 122 | builder.update_object_property("invalid_id", 'pos_x', 100.0) 123 | except Exception as e: 124 | pytest.fail(f"Updating invalid ID raised an unexpected exception: {e}") 125 | 126 | props_after_invalid_update = builder.get_object_properties(circle_id) 127 | assert props_after_invalid_update['pos_x'] == new_pos_x, "Updating invalid ID shouldn't affect valid objects" 128 | 129 | # --- Test updating non-existent property key --- 130 | # Ensure updating an invalid property key doesn't crash and doesn't add the key (for now) 131 | try: 132 | builder.update_object_property(circle_id, 'non_existent_prop', 'some_value') 133 | except Exception as e: 134 | pytest.fail(f"Updating invalid property key raised an unexpected exception: {e}") 135 | 136 | props_after_invalid_key = builder.get_object_properties(circle_id) 137 | assert 'non_existent_prop' not in props_after_invalid_key, "Updating invalid key shouldn't add the key" 138 | assert props_after_invalid_key['pos_x'] == new_pos_x, "Updating invalid key shouldn't affect other properties" 139 | 140 | def test_set_object_animation_modifies_internal_state(): 141 | """Verify set_object_animation correctly updates the animation state. 142 | Red Step: Requires the set_object_animation method. 143 | """ 144 | builder = SceneBuilder() 145 | circle_id = builder.add_object('Circle') 146 | square_id = builder.add_object('Square') 147 | 148 | # Check initial state 149 | assert builder.objects[0]['animation'] == 'None' 150 | assert builder.objects[1]['animation'] == 'None' 151 | 152 | # Set animation for the circle 153 | anim_name = "FadeIn" 154 | builder.set_object_animation(circle_id, anim_name) 155 | 156 | # Verify circle animation updated, square unchanged 157 | assert builder.objects[0]['animation'] == anim_name, "Circle animation should be updated" 158 | assert builder.objects[1]['animation'] == 'None', "Square animation should remain unchanged" 159 | 160 | # Set animation for the square 161 | builder.set_object_animation(square_id, "Grow") # Use a different hypothetical anim name 162 | assert builder.objects[0]['animation'] == anim_name, "Circle animation should persist" 163 | assert builder.objects[1]['animation'] == "Grow", "Square animation should be updated" 164 | 165 | # Reset circle animation 166 | builder.set_object_animation(circle_id, "None") 167 | assert builder.objects[0]['animation'] == 'None', "Circle animation should be reset to None" 168 | 169 | # Test updating non-existent ID 170 | try: 171 | builder.set_object_animation("invalid_id", "FadeOut") 172 | except Exception as e: 173 | pytest.fail(f"Setting animation for invalid ID raised an unexpected exception: {e}") 174 | 175 | # Ensure other animations weren't affected 176 | assert builder.objects[0]['animation'] == 'None' 177 | assert builder.objects[1]['animation'] == "Grow" 178 | 179 | def test_get_all_objects_returns_copy(): 180 | """Verify get_all_objects returns a copy of the internal objects list. 181 | Red Step: Requires the get_all_objects method. 182 | """ 183 | builder = SceneBuilder() 184 | assert builder.get_all_objects() == [], "Should return empty list initially" 185 | 186 | id1 = builder.add_object('Circle') 187 | id2 = builder.add_object('Square') 188 | builder.update_object_property(id1, 'pos_x', 1.0) 189 | builder.set_object_animation(id2, 'FadeIn') 190 | 191 | all_objects = builder.get_all_objects() 192 | 193 | assert isinstance(all_objects, list), "Should return a list" 194 | assert len(all_objects) == 2, "Should return all added objects" 195 | assert all_objects[0]['id'] == id1 196 | assert all_objects[1]['id'] == id2 197 | assert all_objects[0]['type'] == 'Circle' 198 | assert all_objects[1]['type'] == 'Square' 199 | assert all_objects[0]['properties']['pos_x'] == 1.0 # Check if properties are present 200 | assert all_objects[1]['animation'] == 'FadeIn' # Check if animation state is present 201 | 202 | # Verify it returns a COPY, not the internal list itself 203 | assert all_objects is not builder.objects, "Should return a copy, not the internal list reference" 204 | 205 | # Further check for copy: modify the returned list and ensure internal state is unchanged 206 | all_objects[0]['properties']['pos_x'] = 99.0 207 | internal_object_props = builder.get_object_properties(id1) 208 | # assert internal_object_props['pos_x'] == 1.0, "Modifying the returned list should not affect internal state (shallow copy check)" 209 | # Corrected assertion for shallow copy: 210 | assert internal_object_props['pos_x'] == 99.0, "Modifying the shallow copy's nested dict *should* affect internal state" 211 | 212 | # Optional deep copy check (modifying a nested dict): 213 | # Note: The current implementation likely needs deepcopy for this to pass 214 | # all_objects[0]['properties']['pos_x'] = 99.0 215 | # internal_props = builder.get_object_properties(id1) 216 | # assert internal_props['pos_x'] == 1.0 # This would fail with just .copy() 217 | # For V1, a shallow copy via .copy() is acceptable as per checklist note. 218 | 219 | def test_generate_script_preview_empty(): 220 | """Verify generate_script('preview') returns a valid empty scene script. 221 | Red Step: Requires the generate_script method. 222 | """ 223 | builder = SceneBuilder() 224 | script_content, scene_name = builder.generate_script('preview') 225 | 226 | assert scene_name == "PreviewScene", "Scene name for preview should be PreviewScene" 227 | assert isinstance(script_content, str), "Script content should be a string" 228 | assert "from manim import *" in script_content, "Script should import manim" 229 | assert "class PreviewScene(Scene):" in script_content, "Script should define PreviewScene class" 230 | assert "def construct(self):" in script_content, "Scene should have a construct method" 231 | # Check for emptiness - simplest check is absence of common object/add lines 232 | assert "Circle" not in script_content 233 | assert "Square" not in script_content 234 | assert "Text" not in script_content 235 | assert "self.add" not in script_content 236 | assert "self.play" not in script_content 237 | 238 | def test_generate_script_preview_with_objects(): 239 | """Verify generate_script('preview') creates code for added objects. 240 | Red Step: Requires generate_script to process self.objects. 241 | """ 242 | builder = SceneBuilder() 243 | circle_id = builder.add_object('Circle') 244 | text_id = builder.add_object('Text') 245 | 246 | # Modify a property to check if it's reflected 247 | builder.update_object_property(text_id, 'pos_y', 1.5) 248 | builder.update_object_property(text_id, 'text_content', 'Hello') 249 | builder.update_object_property(circle_id, 'fill_color', '#FF0000') 250 | 251 | script_content, scene_name = builder.generate_script('preview') 252 | 253 | assert scene_name == "PreviewScene" 254 | 255 | # Check for variable names based on ID suffix (implementation detail, but testable) 256 | circle_var = f"circle_{circle_id[-6:]}" 257 | text_var = f"text_{text_id[-6:]}" 258 | assert circle_var in script_content, "Should generate variable name for circle" 259 | assert text_var in script_content, "Should generate variable name for text" 260 | 261 | # Check for object instantiation with properties 262 | # Note: Formatting might vary slightly, focus on key elements 263 | assert f"{circle_var} = Circle(radius=1.0, fill_color='#FF0000')" in script_content 264 | # Position check needs .move_to() 265 | assert f".move_to([0.0, 0.0, 0.0])" in script_content # Default position for circle 266 | 267 | assert f"{text_var} = Text('Hello', fill_color='#FFFFFF')" in script_content 268 | assert f".move_to([0.0, 1.5, 0.0])" in script_content # Updated position for text 269 | 270 | # Check for self.add calls 271 | assert f"self.add({circle_var})" in script_content 272 | assert f"self.add({text_var})" in script_content 273 | 274 | # Ensure no animations are included in preview 275 | assert "self.play" not in script_content 276 | 277 | # Check overall structure again 278 | assert "class PreviewScene(Scene):" in script_content 279 | assert "def construct(self):" in script_content 280 | 281 | def test_generate_script_render_empty(): 282 | """Verify generate_script('render') returns a valid empty scene script. 283 | Red Step: Requires generate_script to handle the 'render' type. 284 | """ 285 | builder = SceneBuilder() 286 | script_content, scene_name = builder.generate_script('render') 287 | 288 | assert scene_name == "EasyManimScene", "Scene name for render should be EasyManimScene" 289 | assert isinstance(script_content, str), "Script content should be a string" 290 | assert "from manim import *" in script_content, "Script should import manim" 291 | assert "class EasyManimScene(Scene):" in script_content, "Script should define EasyManimScene class" 292 | assert "def construct(self):" in script_content, "Scene should have a construct method" 293 | # Check for emptiness 294 | assert "Circle" not in script_content 295 | assert "Square" not in script_content 296 | assert "Text" not in script_content 297 | assert "self.add" not in script_content 298 | assert "self.play" not in script_content 299 | 300 | def test_generate_script_render_no_animation(): 301 | """Verify render script generation with objects but no animations. 302 | Green Step: This should pass with current implementation if preview works. 303 | """ 304 | builder = SceneBuilder() 305 | circle_id = builder.add_object('Circle') 306 | text_id = builder.add_object('Text') 307 | # Ensure animations are 'None' (which is the default) 308 | assert builder.objects[0]['animation'] == 'None' 309 | assert builder.objects[1]['animation'] == 'None' 310 | 311 | script_content, scene_name = builder.generate_script('render') 312 | 313 | assert scene_name == "EasyManimScene", "Scene name should be EasyManimScene" 314 | 315 | # Check object creation code exists (similar to preview test) 316 | circle_var = f"circle_{circle_id[-6:]}" 317 | text_var = f"text_{text_id[-6:]}" 318 | assert f"{circle_var} = Circle(radius=1.0" in script_content 319 | assert f"{text_var} = Text('Text'" in script_content 320 | assert f"self.add({circle_var})" in script_content 321 | assert f"self.add({text_var})" in script_content 322 | 323 | # Crucially, check NO self.play calls exist 324 | assert "self.play" not in script_content, "Render script without animations should not have self.play" 325 | 326 | # Check overall structure 327 | assert "class EasyManimScene(Scene):" in script_content 328 | assert "def construct(self):" in script_content 329 | 330 | def test_generate_script_render_with_fadein(): 331 | """Verify render script generation includes FadeIn animation code. 332 | Red Step: Requires generate_script to handle 'animation' field for render. 333 | """ 334 | builder = SceneBuilder() 335 | circle_id = builder.add_object('Circle') 336 | square_id = builder.add_object('Square') 337 | text_id = builder.add_object('Text') 338 | 339 | # Set animation ONLY for the square 340 | builder.set_object_animation(square_id, 'FadeIn') 341 | 342 | script_content, scene_name = builder.generate_script('render') 343 | 344 | assert scene_name == "EasyManimScene" 345 | 346 | # Define expected variable names 347 | circle_var = f"circle_{circle_id[-6:]}" 348 | square_var = f"square_{square_id[-6:]}" 349 | text_var = f"text_{text_id[-6:]}" 350 | 351 | # Check that object creation and add lines still exist for all 352 | assert f"self.add({circle_var})" in script_content 353 | assert f"self.add({square_var})" in script_content 354 | assert f"self.add({text_var})" in script_content 355 | 356 | # Check that FadeIn import is added (needed for the animation) 357 | # We can check this by ensuring FadeIn is used 358 | assert "FadeIn" in script_content, "Script should use FadeIn if animation is set" 359 | 360 | # Check specifically for the FadeIn play call for the square 361 | expected_play_line = f"self.play(FadeIn({square_var}))" 362 | assert expected_play_line in script_content, "Script should contain self.play(FadeIn(...)) for the animated object" 363 | 364 | # Ensure FadeIn wasn't added for other objects 365 | assert f"self.play(FadeIn({circle_var}))" not in script_content 366 | assert f"self.play(FadeIn({text_var}))" not in script_content 367 | 368 | # Check order (rough check: play comes after add for the same object) 369 | add_square_index = script_content.find(f"self.add({square_var})") 370 | play_square_index = script_content.find(expected_play_line) 371 | assert add_square_index != -1 and play_square_index != -1, "Both add and play lines must exist" 372 | assert play_square_index > add_square_index, "self.play should come after self.add for the animated object" -------------------------------------------------------------------------------- /tests/ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ui/test_ui_manager.py: -------------------------------------------------------------------------------- 1 | # tests/ui/test_ui_manager.py 2 | """Tests for the UIManager class.""" 3 | 4 | import pytest 5 | from unittest.mock import MagicMock, patch 6 | import tkinter.messagebox # Import messagebox for mocking 7 | 8 | # Imports needed for type hinting and instantiation 9 | from easymanim.ui.ui_manager import UIManager 10 | # Import the classes to be mocked 11 | from easymanim.logic.scene_builder import SceneBuilder 12 | from easymanim.interface.manim_interface import ManimInterface 13 | # Define a dummy MainApplication again (or import if it exists later) 14 | class MockMainApplication: 15 | def schedule_task(self, callback, *args): 16 | # Simplest mock: call immediately for testing flow 17 | # More complex tests might check args or delay 18 | callback(*args) 19 | 20 | # REMOVE Dummy Panel classes - Use MagicMock directly in fixture 21 | # class MockTimelinePanel: ... 22 | # class MockPropertiesPanel: ... 23 | # class MockPreviewPanel: ... 24 | # class MockStatusBarPanel: ... 25 | 26 | # --- Test Setup Fixture (Optional but helpful) --- 27 | @pytest.fixture 28 | def ui_manager_fixture(): 29 | """Provides a UIManager instance with mocked dependencies.""" 30 | mock_root_app = MockMainApplication() 31 | mock_scene_builder = MagicMock(spec=SceneBuilder) 32 | mock_manim_interface = MagicMock(spec=ManimInterface) 33 | 34 | ui_manager = UIManager(mock_root_app, mock_scene_builder, mock_manim_interface) 35 | 36 | # Create and register mock panels using MagicMock 37 | mock_timeline = MagicMock() # Removed spec=MockTimelinePanel 38 | mock_properties = MagicMock() # Removed spec=MockPropertiesPanel 39 | mock_preview = MagicMock() # Removed spec=MockPreviewPanel 40 | mock_statusbar = MagicMock() # Removed spec=MockStatusBarPanel 41 | 42 | ui_manager.register_panel("timeline", mock_timeline) 43 | ui_manager.register_panel("properties", mock_properties) 44 | ui_manager.register_panel("preview", mock_preview) 45 | ui_manager.register_panel("statusbar", mock_statusbar) 46 | 47 | # Return tuple: manager and mocks for assertion checks 48 | return ui_manager, mock_root_app, mock_scene_builder, mock_manim_interface, { 49 | "timeline": mock_timeline, 50 | "properties": mock_properties, 51 | "preview": mock_preview, 52 | "statusbar": mock_statusbar 53 | } 54 | 55 | # --- Test Class --- 56 | class TestUIManager: 57 | """Test suite for the UIManager.""" 58 | 59 | def test_init_and_register(self, ui_manager_fixture): 60 | """Test basic initialization and panel registration.""" 61 | ui_manager, root_app, scene_builder, manim_interface, panels = ui_manager_fixture 62 | assert ui_manager.root_app is root_app 63 | assert ui_manager.scene_builder is scene_builder 64 | assert ui_manager.manim_interface is manim_interface 65 | assert ui_manager.selected_object_id is None 66 | assert len(ui_manager.panels) == 4 # Check all panels were registered 67 | assert ui_manager.panels["timeline"] is panels["timeline"] 68 | 69 | def test_handle_add_object_calls_scenebuilder_and_timeline_panel(self, ui_manager_fixture): 70 | """Verify handle_add_object_request calls SceneBuilder and updates TimelinePanel. 71 | Red Step: Requires handle_add_object_request implementation. 72 | """ 73 | ui_manager, _, mock_scene_builder, _, panels = ui_manager_fixture 74 | mock_timeline = panels['timeline'] 75 | mock_statusbar = panels['statusbar'] # Get status bar mock too 76 | 77 | # Configure mock SceneBuilder to return a dummy ID 78 | dummy_id = "circle_test123" 79 | mock_scene_builder.add_object.return_value = dummy_id 80 | # Spy on the add_block method of the mock TimelinePanel 81 | mock_timeline.add_block = MagicMock() 82 | # Spy on status bar 83 | mock_statusbar.set_status = MagicMock() 84 | 85 | # Call the handler 86 | object_type = "Circle" 87 | ui_manager.handle_add_object_request(object_type) 88 | 89 | # Assert SceneBuilder was called 90 | mock_scene_builder.add_object.assert_called_once_with(object_type) 91 | 92 | # Assert TimelinePanel was updated 93 | # For now, assume a simple label format (this might change) 94 | # The exact label generation isn't the focus here, just the call. 95 | mock_timeline.add_block.assert_called_once() 96 | # Check the first argument passed to add_block was the id 97 | assert mock_timeline.add_block.call_args[0][0] == dummy_id 98 | # We can refine the label check later if needed 99 | assert isinstance(mock_timeline.add_block.call_args[0][1], str) 100 | 101 | # Assert Status bar was updated (optional but good practice) 102 | mock_statusbar.set_status.assert_called() 103 | 104 | def test_handle_timeline_selection_updates_properties_panel(self, ui_manager_fixture): 105 | """Verify timeline selection updates internal state and PropertiesPanel. 106 | Red Step: Requires handle_timeline_selection implementation. 107 | """ 108 | ui_manager, _, mock_scene_builder, _, panels = ui_manager_fixture 109 | mock_properties = panels['properties'] 110 | mock_statusbar = panels['statusbar'] 111 | 112 | # --- Mock setup --- 113 | dummy_id_1 = "circle_sel123" 114 | dummy_props_1 = {'radius': 1.0, 'fill_color': '#FFFFFF'} 115 | dummy_id_2 = "square_sel456" 116 | dummy_props_2 = {'side_length': 2.0, 'fill_color': '#00FF00'} 117 | 118 | # Configure SceneBuilder mock 119 | def get_props_side_effect(obj_id): 120 | if obj_id == dummy_id_1: return dummy_props_1 121 | if obj_id == dummy_id_2: return dummy_props_2 122 | return None 123 | mock_scene_builder.get_object_properties.side_effect = get_props_side_effect 124 | 125 | # Spy on PropertiesPanel methods 126 | mock_properties.display_properties = MagicMock() 127 | mock_properties.show_placeholder = MagicMock() 128 | mock_statusbar.set_status = MagicMock() # Reset mock for this test 129 | 130 | # --- Test Selection 1 --- 131 | ui_manager.handle_timeline_selection(dummy_id_1) 132 | 133 | assert ui_manager.selected_object_id == dummy_id_1 134 | mock_scene_builder.get_object_properties.assert_called_with(dummy_id_1) 135 | mock_properties.display_properties.assert_called_once_with(dummy_id_1, dummy_props_1) 136 | mock_properties.show_placeholder.assert_not_called() 137 | mock_statusbar.set_status.assert_called() # Check status updated 138 | status_call_args = mock_statusbar.set_status.call_args[0] 139 | assert dummy_id_1 in status_call_args[0] # Status includes ID 140 | 141 | # Reset mocks for next check 142 | mock_properties.display_properties.reset_mock() 143 | mock_statusbar.set_status.reset_mock() 144 | mock_scene_builder.get_object_properties.reset_mock() 145 | 146 | # --- Test Selection 2 --- 147 | ui_manager.handle_timeline_selection(dummy_id_2) 148 | 149 | assert ui_manager.selected_object_id == dummy_id_2 150 | mock_scene_builder.get_object_properties.assert_called_with(dummy_id_2) 151 | mock_properties.display_properties.assert_called_once_with(dummy_id_2, dummy_props_2) 152 | mock_properties.show_placeholder.assert_not_called() 153 | mock_statusbar.set_status.assert_called() 154 | status_call_args = mock_statusbar.set_status.call_args[0] 155 | assert dummy_id_2 in status_call_args[0] 156 | 157 | # Reset mocks 158 | mock_properties.display_properties.reset_mock() 159 | mock_statusbar.set_status.reset_mock() 160 | mock_scene_builder.get_object_properties.reset_mock() 161 | 162 | # --- Test Deselection --- 163 | ui_manager.handle_timeline_selection(None) 164 | 165 | assert ui_manager.selected_object_id is None 166 | mock_scene_builder.get_object_properties.assert_not_called() # Shouldn't fetch props 167 | mock_properties.display_properties.assert_not_called() 168 | mock_properties.show_placeholder.assert_called_once() 169 | mock_statusbar.set_status.assert_called() 170 | status_call_args = mock_statusbar.set_status.call_args[0] 171 | assert "Deselected" in status_call_args[0] or "Ready" in status_call_args[0] # Status cleared 172 | 173 | def test_handle_property_change_updates_scenebuilder(self, ui_manager_fixture): 174 | """Verify property/animation changes call SceneBuilder update methods. 175 | Red Step: Requires handle_property_change and handle_animation_change. 176 | """ 177 | ui_manager, _, mock_scene_builder, _, panels = ui_manager_fixture 178 | mock_statusbar = panels['statusbar'] 179 | mock_statusbar.set_status = MagicMock() # Spy on status bar 180 | 181 | # --- Test Property Change --- 182 | dummy_id = "circle_prop123" 183 | prop_key = "pos_x" 184 | new_value = -3.14 185 | 186 | # Assume object is selected (though not strictly needed for this call) 187 | ui_manager.selected_object_id = dummy_id 188 | 189 | # Call the handler for a regular property 190 | ui_manager.handle_property_change(dummy_id, prop_key, new_value) 191 | 192 | # Assert SceneBuilder was called correctly 193 | mock_scene_builder.update_object_property.assert_called_once_with( 194 | dummy_id, prop_key, new_value 195 | ) 196 | mock_scene_builder.set_object_animation.assert_not_called() # Ensure wrong method wasn't called 197 | mock_statusbar.set_status.assert_called() # Check status updated 198 | 199 | # Reset mocks 200 | mock_scene_builder.reset_mock() 201 | mock_statusbar.reset_mock() 202 | 203 | # --- Test Animation Change --- 204 | anim_prop_key = "animation" # Although we have a dedicated handler 205 | new_anim = "FadeIn" 206 | 207 | # Call the dedicated handler for animation 208 | ui_manager.handle_animation_change(dummy_id, new_anim) 209 | 210 | # Assert SceneBuilder was called correctly 211 | mock_scene_builder.set_object_animation.assert_called_once_with(dummy_id, new_anim) 212 | mock_scene_builder.update_object_property.assert_not_called() 213 | mock_statusbar.set_status.assert_called() # Check status updated 214 | 215 | def test_handle_refresh_preview_calls_scenebuilder_and_maniminterface(self, ui_manager_fixture): 216 | """Verify refresh preview request coordinates script generation and render call. 217 | Red Step: Requires handle_refresh_preview_request implementation. 218 | """ 219 | ui_manager, _, mock_scene_builder, mock_manim_interface, panels = ui_manager_fixture 220 | mock_preview = panels['preview'] 221 | mock_statusbar = panels['statusbar'] 222 | 223 | # --- Mock setup --- 224 | dummy_script = "# Preview Script" 225 | dummy_scene = "PreviewScene" 226 | mock_scene_builder.generate_script.return_value = (dummy_script, dummy_scene) 227 | 228 | # Spy on methods 229 | mock_preview.show_rendering_state = MagicMock() 230 | mock_manim_interface.render_async = MagicMock() 231 | mock_statusbar.set_status = MagicMock() 232 | 233 | # --- Call handler --- 234 | ui_manager.handle_refresh_preview_request() 235 | 236 | # --- Assertions --- 237 | # 1. SceneBuilder called correctly 238 | mock_scene_builder.generate_script.assert_called_once_with('preview') 239 | 240 | # 2. PreviewPanel state updated 241 | mock_preview.show_rendering_state.assert_called_once() 242 | mock_statusbar.set_status.assert_called() # Check status updated 243 | 244 | # 3. ManimInterface called correctly 245 | mock_manim_interface.render_async.assert_called_once() 246 | call_args, call_kwargs = mock_manim_interface.render_async.call_args 247 | 248 | assert call_kwargs.get('script_content') == dummy_script 249 | assert call_kwargs.get('scene_name') == dummy_scene 250 | # Check for preview flags (as defined in architecture/checklist) 251 | assert call_kwargs.get('quality_flags') == ['-s', '-ql'] 252 | assert call_kwargs.get('output_format') == 'png' 253 | # Check that the callback passed is the UIManager's internal method 254 | assert call_kwargs.get('callback') == ui_manager._preview_callback 255 | 256 | def test_preview_callback_success_updates_panel(self, ui_manager_fixture): 257 | """Verify _preview_callback updates panel correctly on success. 258 | Red Step: Requires implementation of _preview_callback success path. 259 | """ 260 | ui_manager, _, _, _, panels = ui_manager_fixture 261 | mock_preview = panels['preview'] 262 | mock_statusbar = panels['statusbar'] 263 | 264 | # Spy on panel methods 265 | mock_preview.display_image = MagicMock() 266 | mock_preview.show_idle_state = MagicMock() 267 | mock_statusbar.set_status = MagicMock() 268 | 269 | dummy_image_bytes = b'imagedata' 270 | 271 | # Call the callback directly, simulating success 272 | ui_manager._preview_callback(True, dummy_image_bytes) 273 | 274 | # Assert PreviewPanel methods called 275 | mock_preview.display_image.assert_called_once_with(dummy_image_bytes) 276 | mock_preview.show_idle_state.assert_called_once() 277 | 278 | # Assert Statusbar updated 279 | mock_statusbar.set_status.assert_called_once() 280 | status_message = mock_statusbar.set_status.call_args[0][0] 281 | assert "Preview updated" in status_message or "success" in status_message.lower() 282 | 283 | @patch('tkinter.messagebox.showerror') # Mock the showerror function 284 | def test_preview_callback_failure_shows_error(self, mock_showerror, ui_manager_fixture): 285 | """Verify _preview_callback handles failure correctly. 286 | Red Step: Requires implementation of _preview_callback failure path. 287 | """ 288 | ui_manager, _, _, _, panels = ui_manager_fixture 289 | mock_preview = panels['preview'] 290 | mock_statusbar = panels['statusbar'] 291 | 292 | # Spy on panel methods 293 | mock_preview.show_idle_state = MagicMock() 294 | mock_statusbar.set_status = MagicMock() 295 | 296 | dummy_error_message = "Manim failed spectacularly!" 297 | 298 | # Call the callback directly, simulating failure 299 | ui_manager._preview_callback(False, dummy_error_message) 300 | 301 | # Assert messagebox.showerror was called 302 | mock_showerror.assert_called_once() 303 | # Check title and message passed to showerror 304 | assert "Preview Failed" in mock_showerror.call_args[0][0] # Title check 305 | assert dummy_error_message in mock_showerror.call_args[0][1] # Message check 306 | 307 | # Assert PreviewPanel state reset 308 | mock_preview.show_idle_state.assert_called_once() 309 | 310 | # Assert Statusbar updated 311 | mock_statusbar.set_status.assert_called_once() 312 | status_message = mock_statusbar.set_status.call_args[0][0] 313 | assert "Preview failed" in status_message or "error" in status_message.lower() 314 | 315 | # --- Render Request and Callback Tests --- 316 | 317 | def test_handle_render_video_request(self, ui_manager_fixture): 318 | """Verify render request coordinates script generation and render call. 319 | Red Step: Requires handle_render_video_request implementation. 320 | """ 321 | ui_manager, _, mock_scene_builder, mock_manim_interface, panels = ui_manager_fixture 322 | mock_preview = panels['preview'] # May need to disable refresh btn 323 | mock_statusbar = panels['statusbar'] 324 | 325 | dummy_script = "# Render Script" 326 | dummy_scene = "EasyManimScene" 327 | mock_scene_builder.generate_script.return_value = (dummy_script, dummy_scene) 328 | 329 | # Spy on methods 330 | # Assume Render button would be disabled elsewhere, potentially statusbar update is enough 331 | mock_manim_interface.render_async = MagicMock() 332 | mock_statusbar.set_status = MagicMock() 333 | 334 | ui_manager.handle_render_video_request() 335 | 336 | mock_scene_builder.generate_script.assert_called_once_with('render') 337 | mock_statusbar.set_status.assert_called() # Check status updated (e.g., "Rendering video...") 338 | status_message = mock_statusbar.set_status.call_args[0][0] 339 | assert "Rendering video" in status_message 340 | 341 | mock_manim_interface.render_async.assert_called_once() 342 | call_args, call_kwargs = mock_manim_interface.render_async.call_args 343 | assert call_kwargs.get('script_content') == dummy_script 344 | assert call_kwargs.get('scene_name') == dummy_scene 345 | assert call_kwargs.get('quality_flags') == ['-ql'] # Render flags 346 | assert call_kwargs.get('output_format') == 'mp4' 347 | assert call_kwargs.get('callback') == ui_manager._render_callback 348 | 349 | @patch('tkinter.messagebox.showinfo') 350 | def test_render_callback_success(self, mock_showinfo, ui_manager_fixture): 351 | """Verify _render_callback handles success (shows info message). 352 | Red Step: Requires _render_callback success path implementation. 353 | """ 354 | ui_manager, _, _, _, panels = ui_manager_fixture 355 | mock_statusbar = panels['statusbar'] 356 | mock_statusbar.set_status = MagicMock() 357 | # Assume some UI elements might need state reset (e.g., buttons enabled) 358 | mock_preview_panel = panels['preview'] # Example: preview panel button 359 | mock_preview_panel.show_idle_state = MagicMock() 360 | 361 | dummy_video_path = "media/videos/render/480p/EasyManimScene.mp4" 362 | 363 | ui_manager._render_callback(True, dummy_video_path) 364 | 365 | mock_showinfo.assert_called_once() 366 | assert "Render Complete" in mock_showinfo.call_args[0][0] # Title 367 | assert dummy_video_path in mock_showinfo.call_args[0][1] # Message includes path 368 | 369 | mock_statusbar.set_status.assert_called_once() 370 | assert "Video render complete" in mock_statusbar.set_status.call_args[0][0] 371 | assert dummy_video_path in mock_statusbar.set_status.call_args[0][0] 372 | 373 | mock_preview_panel.show_idle_state.assert_called_once() # Example state reset 374 | 375 | @patch('tkinter.messagebox.showerror') 376 | def test_render_callback_failure(self, mock_showerror, ui_manager_fixture): 377 | """Verify _render_callback handles failure (shows error message). 378 | Red Step: Requires _render_callback failure path implementation. 379 | """ 380 | ui_manager, _, _, _, panels = ui_manager_fixture 381 | mock_statusbar = panels['statusbar'] 382 | mock_statusbar.set_status = MagicMock() 383 | mock_preview_panel = panels['preview'] 384 | mock_preview_panel.show_idle_state = MagicMock() 385 | 386 | dummy_error_msg = "Render exploded!" 387 | 388 | ui_manager._render_callback(False, dummy_error_msg) 389 | 390 | mock_showerror.assert_called_once() 391 | assert "Render Failed" in mock_showerror.call_args[0][0] # Title 392 | assert dummy_error_msg in mock_showerror.call_args[0][1] # Message 393 | 394 | mock_statusbar.set_status.assert_called_once() 395 | assert "Video render failed" in mock_statusbar.set_status.call_args[0][0] 396 | 397 | mock_preview_panel.show_idle_state.assert_called_once() # Example state reset --------------------------------------------------------------------------------