diff --git a/manim/mobject/table.py b/manim/mobject/table.py index ca4c96a7f8..540b1b212d 100644 --- a/manim/mobject/table.py +++ b/manim/mobject/table.py @@ -354,8 +354,16 @@ def _add_labels(self, mob_table: list[list[VMobject]]) -> list[list[VMobject]]: def _add_horizontal_lines(self) -> Table: """Adds the horizontal lines to the table.""" - anchor_left = self.get_left()[0] - 0.5 * self.h_buff - anchor_right = self.get_right()[0] + 0.5 * self.h_buff + anchor_left = ( + min(self.get_left()[0], self._get_column_x_edges(0)[0]) - 0.5 * self.h_buff + ) + anchor_right = ( + max( + self.get_right()[0], + self._get_column_x_edges(len(self.get_columns()) - 1)[1], + ) + + 0.5 * self.h_buff + ) line_group = VGroup() if self.include_outer_lines: anchor = self.get_rows()[0].get_top()[1] + 0.5 * self.v_buff @@ -386,30 +394,80 @@ def _add_horizontal_lines(self) -> Table: self.horizontal_lines = line_group return self + def _get_column_x_edges(self, col_index: int) -> tuple[float, float]: + """Return the ``(left, right)`` x-coordinates of a column's arranged slot. + + When ``col_widths`` (in :attr:`arrange_in_grid_config`) makes a slot wider + than its contents, grid lines should follow the slot edges rather than the + content bounding box. The column's horizontal alignment (``col_alignments`` + or ``cell_alignment``) determines where the content sits within the slot. + With no width set, the slot equals the content, so the previous behaviour + is preserved. + + Parameters + ---------- + col_index + Index of the column whose slot edges are returned. + """ + col = self.get_columns()[col_index] + left, right = col.get_left()[0], col.get_right()[0] + + col_widths = self.arrange_in_grid_config.get("col_widths") + if ( + col_widths is None + or col_index >= len(col_widths) + or col_widths[col_index] is None + ): + return left, right + + width = col_widths[col_index] + + # Determine the column's horizontal alignment the same way + # ``arrange_in_grid`` does: an explicit ``col_alignments`` entry wins, + # otherwise fall back to the horizontal component of ``cell_alignment`` + # (which defaults to centered). + col_alignments = self.arrange_in_grid_config.get("col_alignments") + if col_alignments is not None and col_index < len(col_alignments): + align = col_alignments[col_index] + else: + cell_alignment = self.arrange_in_grid_config.get("cell_alignment") + x = cell_alignment[0] if cell_alignment is not None else 0 + align = "l" if x < 0 else "r" if x > 0 else "c" + + if align == "l": + # left-aligned: the content hugs the left edge of the slot + return left, left + width + if align == "r": + # right-aligned: the content hugs the right edge of the slot + return right - width, right + # centered: the content centre coincides with the slot centre + center_x = col.get_center()[0] + return center_x - width / 2, center_x + width / 2 + def _add_vertical_lines(self) -> Table: """Adds the vertical lines to the table""" anchor_top = self.get_rows().get_top()[1] + 0.5 * self.v_buff anchor_bottom = self.get_rows().get_bottom()[1] - 0.5 * self.v_buff line_group = VGroup() + num_cols = len(self.get_columns()) if self.include_outer_lines: - anchor = self.get_columns()[0].get_left()[0] - 0.5 * self.h_buff + anchor = self._get_column_x_edges(0)[0] - 0.5 * self.h_buff line = Line( [anchor, anchor_top, 0], [anchor, anchor_bottom, 0], **self.line_config ) line_group.add(line) self.add(line) - anchor = self.get_columns()[-1].get_right()[0] + 0.5 * self.h_buff + anchor = self._get_column_x_edges(num_cols - 1)[1] + 0.5 * self.h_buff line = Line( [anchor, anchor_top, 0], [anchor, anchor_bottom, 0], **self.line_config ) line_group.add(line) self.add(line) if self.include_inner_lines: - for k in range(len(self.mob_table[0]) - 1): - anchor = self.get_columns()[k + 1].get_left()[0] + 0.5 * ( - self.get_columns()[k].get_right()[0] - - self.get_columns()[k + 1].get_left()[0] - ) + for k in range(num_cols - 1): + left_col_right = self._get_column_x_edges(k)[1] + right_col_left = self._get_column_x_edges(k + 1)[0] + anchor = 0.5 * (left_col_right + right_col_left) line = Line( [anchor, anchor_bottom, 0], [anchor, anchor_top, 0], @@ -818,24 +876,24 @@ def construct(self): self.add(table, cell) """ row = self.get_rows()[pos[0] - 1] - col = self.get_columns()[pos[1] - 1] + col_left, col_right = self._get_column_x_edges(pos[1] - 1) edge_UL = [ - col.get_left()[0] - self.h_buff / 2, + col_left - self.h_buff / 2, row.get_top()[1] + self.v_buff / 2, 0, ] edge_UR = [ - col.get_right()[0] + self.h_buff / 2, + col_right + self.h_buff / 2, row.get_top()[1] + self.v_buff / 2, 0, ] edge_DL = [ - col.get_left()[0] - self.h_buff / 2, + col_left - self.h_buff / 2, row.get_bottom()[1] - self.v_buff / 2, 0, ] edge_DR = [ - col.get_right()[0] + self.h_buff / 2, + col_right + self.h_buff / 2, row.get_bottom()[1] - self.v_buff / 2, 0, ] diff --git a/tests/module/mobject/test_table.py b/tests/module/mobject/test_table.py index e4f7b186a2..0a9fc5ccef 100644 --- a/tests/module/mobject/test_table.py +++ b/tests/module/mobject/test_table.py @@ -4,7 +4,7 @@ import numpy as np -from manim import Circle, Table +from manim import LEFT, Circle, Table, Text from manim.utils.color import GREEN @@ -45,6 +45,67 @@ def test_table_include_inner_lines_true(): assert len(table.get_vertical_lines()) == 3 +def test_table_col_widths_are_honored(): + """Fixed ``col_widths`` should be applied to every column, including the + first and last, instead of being truncated to the cell contents. + + Regression test for https://github.com/ManimCommunity/manim/issues/3446 + """ + table = Table( + [["1", "10", "100", "1000"], ["0", "0", "0", "0"]], + h_buff=0.1, + v_buff=0.1, + include_outer_lines=True, + arrange_in_grid_config={"col_widths": [3] * 4, "col_alignments": ["c"] * 4}, + ) + line_xs = sorted(line.get_center()[0] for line in table.get_vertical_lines()) + drawn_widths = [line_xs[i + 1] - line_xs[i] for i in range(len(line_xs) - 1)] + + # All columns, outer ones included, must have the same drawn width. + assert max(drawn_widths) - min(drawn_widths) < 1e-6 + # Each drawn column spans its slot width plus the horizontal buffer. + for width in drawn_widths: + assert abs(width - (3 + 0.1)) < 1e-6 + + +def test_table_col_widths_with_labels(): + """``col_widths`` should be honored even when row/column labels are present. + + Regression test for https://github.com/ManimCommunity/manim/issues/3446 + """ + table = Table( + [["a", "b"], ["c", "d"]], + row_labels=[Text("r1"), Text("r2")], + col_labels=[Text("c1"), Text("c2")], + top_left_entry=Text(""), + h_buff=0.1, + include_outer_lines=True, + arrange_in_grid_config={"col_widths": [2, 3, 3], "col_alignments": "ccc"}, + ) + line_xs = sorted(line.get_center()[0] for line in table.get_vertical_lines()) + drawn_widths = [line_xs[i + 1] - line_xs[i] for i in range(len(line_xs) - 1)] + for width, expected in zip(drawn_widths, [2 + 0.1, 3 + 0.1, 3 + 0.1], strict=True): + assert abs(width - expected) < 1e-6 + + +def test_table_col_widths_respects_cell_alignment(): + """A non-centered ``cell_alignment`` must not be treated as centered when + positioning the grid lines around a fixed-width column. + + Regression test for https://github.com/ManimCommunity/manim/issues/3446 + """ + table = Table( + [["a"]], + h_buff=0.1, + include_outer_lines=True, + arrange_in_grid_config={"col_widths": [3], "cell_alignment": LEFT}, + ) + column = table.get_columns()[0] + actual_left = min(line.get_center()[0] for line in table.get_vertical_lines()) + expected_left = column.get_left()[0] - table.h_buff / 2 + assert abs(actual_left - expected_left) < 1e-6 + + def test_table_accepts_iterable_data(): """Table data can be any iterable of iterables.""" data = (iter(row) for row in [["A", "B"], ["C", "D"]])