Repository: Textualize/rich
Pull request #2543 (2022-09-23 14:03:12)

fix for text in panel title
Fixes https://github.com/Textualize/rich/issues/2466


Patch:
diff --git a/rich/console.py b/rich/console.py
index 36537216..33e9d422 100644
--- a/rich/console.py
+++ b/rich/console.py
@@ -2262,12 +2262,12 @@ class Console:
         clear: bool = True,
         code_format: str = CONSOLE_SVG_FORMAT,
         font_aspect_ratio: float = 0.61,
-        unique_id: Optional[str] = None,
     ) -> str:
         """
         Generate an SVG from the console contents (requires record=True in Console constructor).
 
         Args:
+            path (str): The path to write the SVG to.
             title (str, optional): The title of the tab in the output image
             theme (TerminalTheme, optional): The ``TerminalTheme`` object to use to style the terminal
             clear (bool, optional): Clear record buffer after exporting. Defaults to ``True``
@@ -2277,8 +2277,6 @@ class Console:
             font_aspect_ratio (float, optional): The width to height ratio of the font used in the ``code_format``
                 string. Defaults to 0.61, which is the width to height ratio of Fira Code (the default font).
                 If you aren't specifying a different font inside ``code_format``, you probably don't need this.
-            unique_id (str, optional): unique id that is used as the prefix for various elements (CSS styles, node
-                ids). If not set, this defaults to a computed value based on the recorded content.
         """
 
         from rich.cells import cell_len
@@ -2374,16 +2372,14 @@ class Console:
             if clear:
                 self._record_buffer.clear()
 
-        if unique_id is None:
-            unique_id = "terminal-" + str(
-                zlib.adler32(
-                    ("".join(repr(segment) for segment in segments)).encode(
-                        "utf-8",
-                        "ignore",
-                    )
-                    + title.encode("utf-8", "ignore")
+        unique_id = "terminal-" + str(
+            zlib.adler32(
+                ("".join(repr(segment) for segment in segments)).encode(
+                    "utf-8", "ignore"
                 )
+                + title.encode("utf-8", "ignore")
             )
+        )
         y = 0
         for y, line in enumerate(Segment.split_and_crop_lines(segments, length=width)):
             x = 0
@@ -2514,7 +2510,6 @@ class Console:
         clear: bool = True,
         code_format: str = CONSOLE_SVG_FORMAT,
         font_aspect_ratio: float = 0.61,
-        unique_id: Optional[str] = None,
     ) -> None:
         """Generate an SVG file from the console contents (requires record=True in Console constructor).
 
@@ -2529,8 +2524,6 @@ class Console:
             font_aspect_ratio (float, optional): The width to height ratio of the font used in the ``code_format``
                 string. Defaults to 0.61, which is the width to height ratio of Fira Code (the default font).
                 If you aren't specifying a different font inside ``code_format``, you probably don't need this.
-            unique_id (str, optional): unique id that is used as the prefix for various elements (CSS styles, node
-                ids). If not set, this defaults to a computed value based on the recorded content.
         """
         svg = self.export_svg(
             title=title,
@@ -2538,7 +2531,6 @@ class Console:
             clear=clear,
             code_format=code_format,
             font_aspect_ratio=font_aspect_ratio,
-            unique_id=unique_id,
         )
         with open(path, "wt", encoding="utf-8") as write_file:
             write_file.write(svg)
diff --git a/rich/panel.py b/rich/panel.py
index fc2807c3..d522d80b 100644
--- a/rich/panel.py
+++ b/rich/panel.py
@@ -2,11 +2,12 @@ from typing import TYPE_CHECKING, Optional
 
 from .align import AlignMethod
 from .box import ROUNDED, Box
+from .cells import cell_len
 from .jupyter import JupyterMixin
 from .measure import Measurement, measure_renderables
 from .padding import Padding, PaddingDimensions
 from .segment import Segment
-from .style import StyleType
+from .style import Style, StyleType
 from .text import Text, TextType
 
 if TYPE_CHECKING:
@@ -149,9 +150,53 @@ class Panel(JupyterMixin):
         safe_box: bool = console.safe_box if self.safe_box is None else self.safe_box
         box = self.box.substitute(options, safe=safe_box)
 
+        def align_text(
+            text: Text, width: int, align: str, character: str, style: Style
+        ) -> Text:
+            """Gets new aligned text.
+
+            Args:
+                text (Text): Title or subtitle text.
+                width (int): Desired width.
+                align (str): Alignment.
+                character (str): Character for alignment.
+                style (Style): Border style
+
+            Returns:
+                Text: New text instance
+            """
+            text = text.copy()
+            text.truncate(width)
+            excess_space = width - cell_len(text.plain)
+            if excess_space:
+                if align == "left":
+                    return Text.assemble(
+                        text,
+                        (character * excess_space, style),
+                        no_wrap=True,
+                        end="",
+                    )
+                elif align == "center":
+                    left = excess_space // 2
+                    return Text.assemble(
+                        (character * left, style),
+                        text,
+                        (character * (excess_space - left), style),
+                        no_wrap=True,
+                        end="",
+                    )
+                else:
+                    return Text.assemble(
+                        (character * excess_space, style),
+                        text,
+                        no_wrap=True,
+                        end="",
+                    )
+            return text
+
         title_text = self._title
         if title_text is not None:
-            title_text.style = border_style
+            title_text.stylize_before(border_style)
 
         child_width = (
             width - 2
@@ -180,7 +225,13 @@ class Panel(JupyterMixin):
         if title_text is None or width <= 4:
             yield Segment(box.get_top([width - 2]), border_style)
         else:
-            title_text.align(self.title_align, width - 4, character=box.top)
+            title_text = align_text(
+                title_text,
+                width - 4,
+                self.title_align,
+                box.top,
+                border_style,
+            )
             yield Segment(box.top_left + box.top, border_style)
             yield from console.render(title_text, child_options.update_width(width - 4))
             yield Segment(box.top + box.top_right, border_style)
@@ -194,12 +245,18 @@ class Panel(JupyterMixin):
 
         subtitle_text = self._subtitle
         if subtitle_text is not None:
-            subtitle_text.style = border_style
+            subtitle_text.stylize_before(border_style)
 
         if subtitle_text is None or width <= 4:
             yield Segment(box.get_bottom([width - 2]), border_style)
         else:
-            subtitle_text.align(self.subtitle_align, width - 4, character=box.bottom)
+            subtitle_text = align_text(
+                subtitle_text,
+                width - 4,
+                self.subtitle_align,
+                box.bottom,
+                border_style,
+            )
             yield Segment(box.bottom_left + box.bottom, border_style)
             yield from console.render(
                 subtitle_text, child_options.update_width(width - 4)
diff --git a/rich/text.py b/rich/text.py
index 1471fac2..5b5ead0f 100644
--- a/rich/text.py
+++ b/rich/text.py
@@ -450,7 +450,6 @@ class Text(JupyterMixin):
             style (Union[str, Style]): Style instance or style definition to apply.
             start (int): Start offset (negative indexing is supported). Defaults to 0.
             end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
-
         """
         if style:
             length = len(self)
@@ -465,6 +464,32 @@ class Text(JupyterMixin):
                 return
             self._spans.append(Span(start, min(length, end), style))
 
+    def stylize_before(
+        self,
+        style: Union[str, Style],
+        start: int = 0,
+        end: Optional[int] = None,
+    ) -> None:
+        """Apply a style to the text, or a portion of the text. Styles will be applied before other styles already present.
+
+        Args:
+            style (Union[str, Style]): Style instance or style definition to apply.
+            start (int): Start offset (negative indexing is supported). Defaults to 0.
+            end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
+        """
+        if style:
+            length = len(self)
+            if start < 0:
+                start = length + start
+            if end is None:
+                end = length
+            if end < 0:
+                end = length + end
+            if start >= length or end <= start:
+                # Span not in text or not valid
+                return
+            self._spans.insert(0, Span(start, min(length, end), style))
+
     def apply_meta(
         self, meta: Dict[str, Any], start: int = 0, end: Optional[int] = None
     ) -> None:


Test patch:
diff --git a/tests/test_console.py b/tests/test_console.py
index e1aaaefd..0b46272f 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -554,18 +554,6 @@ def test_export_svg():
     assert svg == EXPECTED_SVG
 
 
-def test_export_svg_specified_unique_id():
-    expected_svg = EXPECTED_SVG.replace("terminal-3526644552", "given-id")
-    console = Console(record=True, width=100)
-    console.print(
-        "[b red on blue reverse]foo[/] [blink][link=https://example.org]Click[/link]"
-    )
-    svg = console.export_svg(unique_id="given-id")
-    print(repr(svg))
-
-    assert svg == expected_svg
-
-
 def test_save_svg():
     console = Console(record=True, width=100)
     console.print(
diff --git a/tests/test_log.py b/tests/test_log.py
index 22bb8b7b..37fdda46 100644
--- a/tests/test_log.py
+++ b/tests/test_log.py
@@ -37,7 +37,7 @@ def render_log():
 
 def test_log():
     expected = replace_link_ids(
-        "\x1b[2;36m[TIME]\x1b[0m\x1b[2;36m \x1b[0m                                                           \x1b]8;id=0;foo\x1b\\\x1b[2msource.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:\x1b[0m\x1b]8;id=0;foo\x1b\\\x1b[2m32\x1b[0m\x1b]8;;\x1b\\\n\x1b[2;36m      \x1b[0m\x1b[2;36m \x1b[0mHello from \x1b[1m<\x1b[0m\x1b[1;95mconsole\x1b[0m\x1b[39m \x1b[0m\x1b[33mwidth\x1b[0m\x1b[39m=\x1b[0m\x1b[1;36m80\x1b[0m\x1b[39m ColorSystem.TRUECOLOR\x1b[0m\x1b[1m>\x1b[0m !      \x1b]8;id=0;foo\x1b\\\x1b[2msource.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:\x1b[0m\x1b]8;id=0;foo\x1b\\\x1b[2m33\x1b[0m\x1b]8;;\x1b\\\n\x1b[2;36m      \x1b[0m\x1b[2;36m \x1b[0m\x1b[1m[\x1b[0m\x1b[1;36m1\x1b[0m, \x1b[1;36m2\x1b[0m, \x1b[1;36m3\x1b[0m\x1b[1m]\x1b[0m                                                  \x1b]8;id=0;foo\x1b\\\x1b[2msource.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:\x1b[0m\x1b]8;id=0;foo\x1b\\\x1b[2m34\x1b[0m\x1b]8;;\x1b\\\n\x1b[2;36m       \x1b[0m\x1b[34m╭─\x1b[0m\x1b[34m───────────────────── \x1b[0m\x1b[3;34mlocals\x1b[0m\x1b[34m ─────────────────────\x1b[0m\x1b[34m─╮\x1b[0m     \x1b[2m              \x1b[0m\n\x1b[2;36m       \x1b[0m\x1b[34m│\x1b[0m \x1b[3;33mconsole\x1b[0m\x1b[31m =\x1b[0m \x1b[1m<\x1b[0m\x1b[1;95mconsole\x1b[0m\x1b[39m \x1b[0m\x1b[33mwidth\x1b[0m\x1b[39m=\x1b[0m\x1b[1;36m80\x1b[0m\x1b[39m ColorSystem.TRUECOLOR\x1b[0m\x1b[1m>\x1b[0m \x1b[34m│\x1b[0m     \x1b[2m              \x1b[0m\n\x1b[2;36m       \x1b[0m\x1b[34m╰────────────────────────────────────────────────────╯\x1b[0m     \x1b[2m              \x1b[0m\n"
+        "\x1b[2;36m[TIME]\x1b[0m\x1b[2;36m \x1b[0m                                                           \x1b]8;id=0;foo\x1b\\\x1b[2msource.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:\x1b[0m\x1b]8;id=0;foo\x1b\\\x1b[2m32\x1b[0m\x1b]8;;\x1b\\\n\x1b[2;36m      \x1b[0m\x1b[2;36m \x1b[0mHello from \x1b[1m<\x1b[0m\x1b[1;95mconsole\x1b[0m\x1b[39m \x1b[0m\x1b[33mwidth\x1b[0m\x1b[39m=\x1b[0m\x1b[1;36m80\x1b[0m\x1b[39m ColorSystem.TRUECOLOR\x1b[0m\x1b[1m>\x1b[0m !      \x1b]8;id=0;foo\x1b\\\x1b[2msource.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:\x1b[0m\x1b]8;id=0;foo\x1b\\\x1b[2m33\x1b[0m\x1b]8;;\x1b\\\n\x1b[2;36m      \x1b[0m\x1b[2;36m \x1b[0m\x1b[1m[\x1b[0m\x1b[1;36m1\x1b[0m, \x1b[1;36m2\x1b[0m, \x1b[1;36m3\x1b[0m\x1b[1m]\x1b[0m                                                  \x1b]8;id=0;foo\x1b\\\x1b[2msource.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:\x1b[0m\x1b]8;id=0;foo\x1b\\\x1b[2m34\x1b[0m\x1b]8;;\x1b\\\n\x1b[2;36m       \x1b[0m\x1b[34m╭─\x1b[0m\x1b[34m─────────────────────\x1b[0m\x1b[34m \x1b[0m\x1b[3;34mlocals\x1b[0m\x1b[34m \x1b[0m\x1b[34m─────────────────────\x1b[0m\x1b[34m─╮\x1b[0m     \x1b[2m              \x1b[0m\n\x1b[2;36m       \x1b[0m\x1b[34m│\x1b[0m \x1b[3;33mconsole\x1b[0m\x1b[31m =\x1b[0m \x1b[1m<\x1b[0m\x1b[1;95mconsole\x1b[0m\x1b[39m \x1b[0m\x1b[33mwidth\x1b[0m\x1b[39m=\x1b[0m\x1b[1;36m80\x1b[0m\x1b[39m ColorSystem.TRUECOLOR\x1b[0m\x1b[1m>\x1b[0m \x1b[34m│\x1b[0m     \x1b[2m              \x1b[0m\n\x1b[2;36m       \x1b[0m\x1b[34m╰────────────────────────────────────────────────────╯\x1b[0m     \x1b[2m              \x1b[0m\n"
     )
     rendered = render_log()
     print(repr(rendered))
diff --git a/tests/test_panel.py b/tests/test_panel.py
index 5ae3babe..a79a4a91 100644
--- a/tests/test_panel.py
+++ b/tests/test_panel.py
@@ -63,9 +63,9 @@ def test_render_size():
     expected = [
         [
             Segment("╭─", Style()),
-            Segment(
-                "────────────────────────────────── Hello ───────────────────────────────────"
-            ),
+            Segment("──────────────────────────────────", Style()),
+            Segment(" Hello ", Style()),
+            Segment("───────────────────────────────────", Style()),
             Segment("─╮", Style()),
         ],
         [
diff --git a/tests/test_text.py b/tests/test_text.py
index 169931a7..5f06715d 100644
--- a/tests/test_text.py
+++ b/tests/test_text.py
@@ -144,6 +144,13 @@ def test_stylize():
     assert text._spans == [Span(7, 11, "bold")]
 
 
+def test_stylize_before():
+    text = Text("Hello, World!")
+    text.stylize("bold", 0, 5)
+    text.stylize_before("italic", 2, 7)
+    assert text._spans == [Span(2, 7, "italic"), Span(0, 5, "bold")]
+
+
 def test_stylize_negative_index():
     text = Text("Hello, World!")
     text.stylize("bold", -6, -1)