From e8433e3aa69bac5da7514e53282844c56d9c99d9 Mon Sep 17 00:00:00 2001 From: philippe-oger Date: Thu, 15 Jan 2026 17:29:13 +0100 Subject: [PATCH] feat: Color preservation after resizing --- .gitignore | 6 + README.md | 12 +- docs/Table.md | 19 ++- manim_table/cell.py | 24 +++- manim_table/table.py | 128 ++++++++++++++---- manimal/examples.py | 282 ++++++++++++++++++++++++++++++++++++++++ tests/scenes_tests.py | 100 +++++++++++++- tests/test_rendering.py | 1 + 8 files changed, 518 insertions(+), 54 deletions(-) create mode 100644 manimal/examples.py diff --git a/.gitignore b/.gitignore index c0f68a6..3cf554c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -113,3 +115,7 @@ videos/ .vscode/ *.swp *.swo + +# MacOS +.DS_Store + diff --git a/README.md b/README.md index 3af0ac9..b142098 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,12 @@ You can add or remove rows and columns with animations. ```python # Add a new row -new_row, animations = table.add_row(["Charlie", "35", "London"]) -self.play(*animations) +new_row, anims = table.add_row(["Charlie", "35", "London"]) +self.play(AnimationGroup(*anims, lag_ratio=0.05)) # Delete a row (index starts at 1 for data rows) -deleted_row, shift_anims, resize_anims = table.delete_row(1) - -self.play(FadeOut(deleted_row)) -self.play(*shift_anims) -if resize_anims: - self.play(*resize_anims) +deleted_row, anims = table.delete_row(1) +self.play(AnimationGroup(*anims, lag_ratio=0.05)) ``` ### Styling diff --git a/docs/Table.md b/docs/Table.md index 09000bf..f249efd 100644 --- a/docs/Table.md +++ b/docs/Table.md @@ -129,34 +129,33 @@ def add_row(self, values: List[str]) -> Tuple[Row, List] **Returns:** `(new_row, animations)` - `new_row`: The created Row object. -- `animations`: List of `FadeIn` animations. +- `animations`: List of animations (resize transforms + FadeIn for new cells). **Example:** ```python new_row, anims = table.add_row(["New", "Entry"]) -self.play(*anims) +self.play(AnimationGroup(*anims, lag_ratio=0.05)) ``` #### `delete_row` -Delete a row from the table (animates shifting subsequent rows). +Delete a row from the table (animates fading out, shifting, and resizing). ```python -def delete_row(self, index: int) -> Tuple[Row, List, List] +def delete_row(self, index: int) -> Tuple[Row, List] ``` **Arguments:** - `index`: Row index to delete (1-indexed; cannot delete header). -**Returns:** `(deleted_row, shift_animations, resize_animations)` +**Returns:** `(deleted_row, animations)` +- `deleted_row`: The deleted Row object. +- `animations`: List of animations (FadeOut + shift + resize). **Example:** ```python -deleted, shift, resize = table.delete_row(1) -self.play(FadeOut(deleted)) -self.play(*shift) -if resize: - self.play(*resize) +deleted, anims = table.delete_row(1) +self.play(AnimationGroup(*anims, lag_ratio=0.05)) ``` #### `add_column` diff --git a/manim_table/cell.py b/manim_table/cell.py index 4befeec..2fefcc2 100644 --- a/manim_table/cell.py +++ b/manim_table/cell.py @@ -41,6 +41,12 @@ def __init__( self.is_header = is_header self.show_border = show_border + # Store color properties for copying + self._font_color = None + self._background_color = None + self._background_opacity = 0.0 + self._border_color = None + # Create invisible bounding box to enforce dimensions self.invisible_box = Rectangle( width=width, @@ -131,13 +137,14 @@ def get_resized_copy(self, new_width: float) -> "Cell": """ Create a copy of this cell with a new width. The copy is positioned at the same center as this cell. + Preserves font color, background color, and border color. Useful for animating width changes with Transform. Args: new_width: The new width for the cell Returns: - A new Cell with the same value but different width + A new Cell with the same value and styling but different width """ new_cell = Cell( value=self.value, @@ -148,6 +155,15 @@ def get_resized_copy(self, new_width: float) -> "Cell": show_border=self.show_border, ) new_cell.move_to(self.get_center()) + + # Copy styling colors + if self._font_color is not None: + new_cell.set_font_color(self._font_color) + if self._background_color is not None: + new_cell.set_background_color(self._background_color, self._background_opacity) + if self._border_color is not None: + new_cell.set_border_color(self._border_color) + return new_cell def resize_width(self, new_width: float): @@ -185,6 +201,7 @@ def set_font_color(self, color): Args: color: A manimlib color (e.g., RED, BLUE, "#FF0000") """ + self._font_color = color # Store for copying self.text.set_color(color) return self @@ -195,6 +212,7 @@ def set_border_color(self, color): Args: color: A manimlib color (e.g., RED, BLUE, "#FF0000") """ + self._border_color = color # Store for copying if self.border is not None: for line in self.border: line.set_color(color) @@ -209,6 +227,10 @@ def set_background_color(self, color, opacity: float = 0.5): color: A manimlib color (e.g., RED, BLUE, "#FF0000") opacity: Opacity of the background (0 to 1) """ + # Store for copying + self._background_color = color + self._background_opacity = opacity + # Remove existing background if any if hasattr(self, 'background') and self.background is not None: self.remove(self.background) diff --git a/manim_table/table.py b/manim_table/table.py index b8511cf..3a2296a 100644 --- a/manim_table/table.py +++ b/manim_table/table.py @@ -264,13 +264,65 @@ def add_row( Add a new row to the bottom of the table. Returns: - Tuple of (new_row, animations) where animations is a list of - FadeIn animations for animating the new row into view. + Tuple of (new_row, animations) where animations includes both: + - Resize transforms for existing cells (if column widths change) + - FadeIn for the new row cells + + All animations can be played together with AnimationGroup. Example: new_row, anims = table.add_row(["Alice", "Smith", "30"]) - self.play(*anims) + self.play(AnimationGroup(*anims, lag_ratio=0.05)) """ + # First, add the new row values to internal tracking + self.row_values.append(values) + + # Check if we need to resize columns (before creating the row) + resize_animations = [] + new_widths = self.column_widths # Default to current widths + + if self.auto_fit: + new_widths = self.calculate_column_widths() + + # Check if any column width changed + width_changed = any( + abs(old_w - new_w) > 0.01 + for old_w, new_w in zip(self.column_widths, new_widths) + ) + + if width_changed: + # Build resize animations for all existing rows + table_left = self.header_row.get_left()[0] + + # Create target cells for header + x_offset = table_left + for col_idx, new_w in enumerate(new_widths): + target_x = x_offset + new_w / 2 + + header_cell = self.header_row[col_idx] + target_header = header_cell.get_resized_copy(new_w) + target_header.move_to([target_x, header_cell.get_center()[1], 0]) + resize_animations.append(Transform(header_cell, target_header)) + + x_offset += new_w + + # Create target cells for existing data rows + for row in self.rows: + x_offset = table_left + for col_idx, new_w in enumerate(new_widths): + target_x = x_offset + new_w / 2 + + cell = row[col_idx] + target_cell = cell.get_resized_copy(new_w) + target_cell.move_to([target_x, cell.get_center()[1], 0]) + resize_animations.append(Transform(cell, target_cell)) + + x_offset += new_w + + # Update stored widths + self.column_widths = new_widths + + # Now create the new row with the updated column widths index = len(self.rows) + 1 new_row = Row( values=values, @@ -282,24 +334,37 @@ def add_row( index=index, ) - # Position below last row + # Position the new row cells at correct positions + table_left = self.header_row.get_left()[0] + + # Calculate y position: below the last row (or header if no rows) if len(self.rows) > 0: - new_row.next_to(self.rows[-1], DOWN, buff=0) + y_pos = self.rows[-1].get_bottom()[1] - self.cell_height / 2 else: - new_row.next_to(self.header_row, DOWN, buff=0) + y_pos = self.header_row.get_bottom()[1] - self.cell_height / 2 - self.row_values.append(values) + # Position each cell at the correct x based on new column widths + x_offset = table_left + for col_idx, width in enumerate(self.column_widths): + cell = new_row.cells[col_idx] + target_x = x_offset + width / 2 + cell.move_to([target_x, y_pos, 0]) + x_offset += width + + # Add to table structure self.rows.append(new_row) self.add(new_row) - # Return animations for each cell to fade in - animations = [FadeIn(cell) for cell in new_row.cells] - return new_row, animations + # Combine all animations: resize first, then fade in new row + appear_animations = [FadeIn(cell) for cell in new_row.cells] + all_animations = resize_animations + appear_animations + + return new_row, all_animations def delete_row( self, index: int - ) -> Tuple[Row, List, List]: + ) -> Tuple[Row, List]: """ Delete a row from the table. @@ -307,16 +372,16 @@ def delete_row( index: Row index (1-indexed, i.e., header is 0, first data row is 1) Returns: - Tuple of (deleted_row, shift_animations, resize_animations) where: - - shift_animations move remaining rows up - - resize_animations resize columns if the deleted row had the longest value + Tuple of (deleted_row, animations) where animations includes: + - FadeOut for the deleted row + - Shift animations for remaining rows + - Resize animations if column widths change + + All animations can be played together with AnimationGroup. Example: - deleted, shift_anims, resize_anims = table.delete_row(1) - self.play(FadeOut(deleted)) - self.play(*shift_anims) - if resize_anims: - self.play(*resize_anims) + deleted, anims = table.delete_row(1) + self.play(AnimationGroup(*anims, lag_ratio=0.05)) """ if index == 0: raise ValueError("Cannot delete header row") @@ -335,16 +400,20 @@ def delete_row( # Track which rows will be shifted (rows at and after the deleted index) rows_to_shift = set(self.rows[data_index:]) + # Calculate actual rendered height (accounts for table scaling) + actual_height = deleted_row.get_height() + + # Start with FadeOut for deleted row + all_animations = [FadeOut(deleted_row)] + # Create shift-up animations for remaining rows - shift_animations = [] for row in rows_to_shift: - shift_animations.append( - row.animate.shift([0, self.cell_height, 0]) + all_animations.append( + row.animate.shift([0, actual_height, 0]) ) row.index -= 1 # Recalculate column widths if auto_fit is enabled - resize_animations = [] if self.auto_fit: new_widths = self.calculate_column_widths() @@ -369,7 +438,7 @@ def delete_row( target_header = header_cell.get_resized_copy(new_w) # Position at target x, same y target_header.move_to([target_x, header_cell.get_center()[1], 0]) - resize_animations.append(Transform(header_cell, target_header)) + all_animations.append(Transform(header_cell, target_header)) x_offset += new_w @@ -387,14 +456,14 @@ def delete_row( # Use the y position AFTER shift would complete target_y = cell.get_center()[1] + y_shift target_cell.move_to([target_x, target_y, 0]) - resize_animations.append(Transform(cell, target_cell)) + all_animations.append(Transform(cell, target_cell)) x_offset += new_w # Update stored widths self.column_widths = new_widths - return deleted_row, shift_animations, resize_animations + return deleted_row, all_animations def add_column( self, @@ -535,9 +604,12 @@ def delete_column( self.header_row.remove(header_cell) # Remove from VGroup deleted_cells.append(header_cell) + # Calculate actual rendered width (accounts for table scaling) + actual_width = header_cell.get_width() + # Shift remaining cells left for cell in self.header_row.cells[index:]: - shift_animations.append(cell.animate.shift(LEFT * col_width)) + shift_animations.append(cell.animate.shift(LEFT * actual_width)) # 2. Data Rows for row in self.rows: @@ -546,7 +618,7 @@ def delete_column( deleted_cells.append(cell) for c in row.cells[index:]: - shift_animations.append(c.animate.shift(LEFT * col_width)) + shift_animations.append(c.animate.shift(LEFT * actual_width)) deleted_group = VGroup(*deleted_cells) diff --git a/manimal/examples.py b/manimal/examples.py new file mode 100644 index 0000000..ba2b233 --- /dev/null +++ b/manimal/examples.py @@ -0,0 +1,282 @@ +""" +Example scenes demonstrating the manimal database animation extension. +""" + +from manimlib import Scene, Write, FadeOut, FadeIn, FlashAround, AnimationGroup, ORIGIN, LEFT, RIGHT, UP, DOWN, UL, ReplacementTransform, VGroup, RED, GREEN, BLUE, YELLOW, WHITE, GREY + +# Import from the manimal package +from manimal import Table + + +class TableDemo(Scene): + """ + Demonstrates basic table creation, row operations, and column selection. + """ + + def construct(self): + # Create a table from data (first row is header) + table = Table([ + ["first_name", "last_name", "age"], + ["PhilippeMarcelClaudeOger", "Oger", "26"], + ["Renata", "Oger", "25"], + ["Vera", "Oger", "10"], + ]) + table.move_to(ORIGIN) + + # Animate table appearing + self.play(Write(table, run_time=2)) + self.wait(1) + + # Move table to the left + self.play(table.animate.shift(LEFT * 3)) + self.wait(0.5) + + # Move table to the right + self.play(table.animate.shift(RIGHT * 6)) + self.wait(0.5) + + # Move back to center + self.play(table.animate.move_to(ORIGIN)) + self.wait(0.5) + + # Flash around the whole table + self.play(FlashAround(table, buff=0.1)) + self.wait(0.5) + + # Flash around a specific cell (row 1, column 2 = "26") + self.play(FlashAround(table.get_cell(1, 2), buff=0.1)) + self.wait(0.5) + + # Flash around a column by name + self.play(FlashAround(table.get_column_by_name("age"), buff=0.1)) + self.wait(0.5) + + # Add a new row with animation + new_row, animations = table.add_row(["Alice", "Smith", "30"]) + self.play(AnimationGroup(*animations, lag_ratio=0.2)) + self.wait(1) + + # Delete row 1 (PhilippeMarcelClaudeOger) with animation - this will trigger column resize + deleted_row, anims = table.delete_row(1) + self.play(AnimationGroup(*anims, lag_ratio=0.05)) + self.wait(1) + + # Change a cell value with animation (Philippe's age: 26 -> 27) + cell = table.get_cell(1, 2) # First data row, age column + old_text = cell.text.copy() + cell.set_value("27") + self.play(ReplacementTransform(old_text, cell.text)) + self.wait(1) + + # Move table after cell update to verify it still works + self.play(table.animate.shift(LEFT * 2)) + self.wait(0.3) + self.play(table.animate.shift(RIGHT * 4)) + self.wait(0.3) + self.play(table.animate.move_to(ORIGIN)) + self.wait(1) + + # Move table to top-left corner and scale down + self.play( + table.animate.scale(0.5).to_corner(UL, buff=0.5) + ) + self.wait(0.5) + + # Create 3 copies of the table and position them below + table_copies = [] + for i in range(3): + table_copy = table.copy() + table_copy.next_to( + table if i == 0 else table_copies[i - 1], + DOWN, + buff=0.3 + ) + table_copies.append(table_copy) + + # Animate the copies appearing one by one + for table_copy in table_copies: + self.play(FadeIn(table_copy), run_time=0.5) + + self.wait(2) + + +class TableWithExplicitHeader(Scene): + """ + Shows alternative table creation with explicit header. + """ + + def construct(self): + table = Table( + header=["id", "product", "price"], + rows=[ + ["1", "Apple", "$1.50"], + ["2", "Banana", "$0.75"], + ["3", "Orange", "$2.00"], + ], + cell_width=2.0, + cell_height=0.6, + font_size=24, + ) + table.move_to(ORIGIN) + + self.play(Write(table)) + self.wait(2) + + +class BorderlessTable(Scene): + """ + Demonstrates a table without borders. + """ + + def construct(self): + table = Table( + data=[ + ["Name", "Score"], + ["Alice", "95"], + ["Bob", "87"], + ["Charlie", "92"], + ], + show_border=False, + font_size=28, + ) + table.move_to(ORIGIN) + + self.play(Write(table)) + self.wait(2) + + +class TableColorScene(Scene): + """ + Demonstrates color customization for tables. + """ + + def construct(self): + # Create a products table + table = Table([ + ["Product", "Category", "Price", "Stock"], + ["Apple", "Fruit", "$1.50", "150"], + ["Banana", "Fruit", "$0.75", "200"], + ["Carrot", "Vegetable", "$0.50", "100"], + ["Milk", "Dairy", "$2.00", "50"], + ]) + table.move_to(ORIGIN) + + # Style the header with a blue background + table.set_header_background_color(BLUE, opacity=0.3) + table.set_header_font_color(WHITE) + + # Color the Price column green + table.set_column_font_color(2, GREEN) # Price column + + # Color low stock items red + table.get_cell(4, 3).set_font_color(RED) # Milk stock = 50 + table.get_cell(4, 3).set_background_color(RED, opacity=0.2) + + # Add yellow background to Fruit category rows + table.get_cell(1, 1).set_background_color(YELLOW, opacity=0.2) # Apple + table.get_cell(2, 1).set_background_color(YELLOW, opacity=0.2) # Banana + + # Set border color for the Product column + table.set_column_border_color(0, GREY) + + self.play(Write(table, run_time=2)) + self.wait(2) + + # Demonstrate dynamic color change + self.play(FlashAround(table.get_column(2), buff=0.1)) # Flash Price column + self.wait(1) + + # Move table to top-left corner and scale down + self.play( + table.animate.scale(0.4).to_corner(UL, buff=0.3) + ) + self.wait(0.5) + + # Create 4 copies of the table and position them below + table_copies = [] + for i in range(4): + table_copy = table.copy() + table_copy.next_to( + table if i == 0 else table_copies[i - 1], + DOWN, + buff=0.2 + ) + table_copies.append(table_copy) + + # Animate the copies appearing one by one + for table_copy in table_copies: + self.play(FadeIn(table_copy), run_time=0.4) + + self.wait(2) + + +class TableColumnOperationsScene(Scene): + """ + Demonstrates adding and deleting columns. + """ + + def construct(self): + # Create initial table + table = Table([ + ["ID", "Name"], + ["1", "Alice"], + ["2", "Bob"], + ["3", "Charlie"], + ]) + table.move_to(ORIGIN) + + self.play(Write(table)) + self.wait(1) + + # 1. Add a column at the end + new_col, shift_anims, appear_anims = table.add_column( + header="Score", + values=["85", "92", "78"] + ) + if shift_anims: + self.play(AnimationGroup(*shift_anims, lag_ratio=0.1)) + if appear_anims: + self.play(AnimationGroup(*appear_anims, lag_ratio=0.1)) + self.wait(1) + + # 2. Add a column in the middle (index 1) + new_col, shift_anims, appear_anims = table.add_column( + header="Role", + values=["Dev", "Manager", "Tester"], + index=1 + ) + if shift_anims: + self.play(AnimationGroup(*shift_anims, lag_ratio=0.1)) + if appear_anims: + self.play(AnimationGroup(*appear_anims, lag_ratio=0.1)) + self.wait(1) + + # 3. Delete a column (ID column, index 0) + deleted_col, shift_anims = table.delete_column(0) + self.play(FadeOut(deleted_col)) + if shift_anims: + self.play(AnimationGroup(*shift_anims, lag_ratio=0.1)) + self.wait(1) + + # Integrity check: Move to top-left corner and duplicate + self.play( + table.animate.scale(0.4).to_corner(UL, buff=0.3) + ) + self.wait(0.5) + + # Create 4 copies of the table and position them below + table_copies = [] + for i in range(4): + table_copy = table.copy() + table_copy.next_to( + table if i == 0 else table_copies[i - 1], + DOWN, + buff=0.2 + ) + table_copies.append(table_copy) + + # Animate the copies appearing one by one + for table_copy in table_copies: + self.play(FadeIn(table_copy), run_time=0.4) + + self.wait(2) diff --git a/tests/scenes_tests.py b/tests/scenes_tests.py index 8c44db8..c1acd50 100644 --- a/tests/scenes_tests.py +++ b/tests/scenes_tests.py @@ -56,16 +56,13 @@ def construct(self): self.wait(0.5) # Add a new row with animation - new_row, animations = table.add_row(["Alice", "Smith", "30"]) - self.play(AnimationGroup(*animations, lag_ratio=0.2)) + new_row, anims = table.add_row(["Alice", "Smith", "30"]) + self.play(AnimationGroup(*anims, lag_ratio=0.05)) self.wait(1) # Delete row 1 (John) with animation - this will trigger column resize - deleted_row, shift_anims, resize_anims = table.delete_row(1) - self.play(FadeOut(deleted_row)) - self.play(AnimationGroup(*shift_anims, lag_ratio=0.1)) - if resize_anims: - self.play(AnimationGroup(*resize_anims, lag_ratio=0.05)) + deleted_row, anims = table.delete_row(1) + self.play(AnimationGroup(*anims, lag_ratio=0.05)) self.wait(1) # Change a cell value with animation (Jane's age: 32 -> 33) @@ -217,6 +214,95 @@ def construct(self): self.wait(2) +class TableColorResizePreservationScene(Scene): + """ + Tests that cell colors (font, background, border) are preserved + when a new row with a long string triggers column resizing. + """ + + def construct(self): + # Create a table with short initial values + table = Table([ + ["Name", "Role", "Status"], + ["Alice", "Dev", "Active"], + ["Bob", "QA", "Inactive"], + ]) + table.move_to(ORIGIN) + + # Apply various colors to the table + # 1. Header styling + table.set_header_background_color(BLUE, opacity=0.3) + table.set_header_font_color(WHITE) + + # 2. Column styling - set Status column to RED font + table.set_column_font_color(2, RED) # Status column + + # 3. Individual cell styling + table.get_cell(1, 0).set_background_color(GREEN, opacity=0.2) # Alice row, Name cell + table.get_cell(2, 1).set_border_color(YELLOW) # Bob row, Role cell + table.get_cell(1, 1).set_font_color(BLUE) # Alice row, Role cell + + # Show initial table + self.play(Write(table, run_time=2)) + self.wait(1) + + # Flash around cells to highlight their colors + self.play(FlashAround(table.get_cell(1, 0), buff=0.1)) # Green background cell + self.wait(0.3) + self.play(FlashAround(table.get_cell(2, 1), buff=0.1)) # Yellow border cell + self.wait(0.3) + + # Add a new row with a VERY LONG string that will trigger column resizing + # This tests that colors are preserved when cells are resized + long_role = "Senior Principal Software Engineer Lead" + new_row, anims = table.add_row([ + "Christopher", + long_role, # This will force the Role column to expand + "Active" + ]) + + # Play all animations (resize + fade in new row) + self.play(AnimationGroup(*anims, lag_ratio=0.05)) + self.wait(1) + + # Flash to verify colors are still applied after resize + # The Status column should still have RED font + self.play(FlashAround(table.get_column(2), buff=0.1)) + self.wait(0.3) + + # Alice's Name cell should still have GREEN background + self.play(FlashAround(table.get_cell(1, 0), buff=0.1)) + self.wait(0.3) + + # Bob's Role cell should still have YELLOW border + self.play(FlashAround(table.get_cell(2, 1), buff=0.1)) + self.wait(0.3) + + # Header should still have BLUE background and WHITE font + self.play(FlashAround(table.header_row, buff=0.1)) + self.wait(1) + + # Move table to verify integrity + self.play(table.animate.shift(LEFT * 2)) + self.wait(0.3) + self.play(table.animate.shift(RIGHT * 4)) + self.wait(0.3) + self.play(table.animate.move_to(ORIGIN)) + self.wait(1) + + # Scale and copy to verify colors are preserved after copy + self.play( + table.animate.scale(0.5).to_corner(UL, buff=0.5) + ) + self.wait(0.5) + + # Create copies to verify color preservation in copies + table_copy = table.copy() + table_copy.next_to(table, DOWN, buff=0.3) + self.play(FadeIn(table_copy)) + self.wait(2) + + class TableColumnOperationsScene(Scene): """ Demonstrates adding and deleting columns. diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 40f55f3..77956c9 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -9,6 +9,7 @@ "TableWithExplicitHeader", "BorderlessTable", "TableColorScene", + "TableColorResizePreservationScene", "TableColumnOperationsScene", ]