Repository: Textualize/rich (python)

Issue:
Add padding to Syntax
We often want to add padding around Syntax. This can be done with `Padding` but it would be better if Syntax itself supported padding so we could use the theme colors.

The Syntax object should grow a `padding` argument on the constructor.

Pull request:
Add padding support to `Syntax`
## Type of changes

- [ ] Bug fix
- [x] New feature
- [ ] Documentation / docstrings
- [ ] Tests
- [ ] Other

## Checklist

- [x] I've run the latest [black](https://github.com/psf/black) with default args on new code.
- [x] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate.
- [x] I've added tests for new code.
- [x] I accept that @willmcgugan may be pedantic in the code review.

## Description

Add padding support to `Syntax`.

Closes #2240 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e14a3690..eb65a531 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Change SVG export to create a simpler SVG
 - Fix render_lines crash when render height was negative https://github.com/Textualize/rich/pull/2246
 
+### Added
+
+- Add `padding` to Syntax constructor https://github.com/Textualize/rich/pull/2247
+
 ## [12.3.0] - 2022-04-26
 
 ### Added
diff --git a/rich/syntax.py b/rich/syntax.py
index 6a337e40..cb34855a 100644
--- a/rich/syntax.py
+++ b/rich/syntax.py
@@ -23,13 +23,14 @@ from pygments.token import (
 from pygments.util import ClassNotFound
 
 from rich.containers import Lines
+from rich.padding import Padding, PaddingDimensions
 
 from ._loop import loop_first
 from .color import Color, blend_rgb
 from .console import Console, ConsoleOptions, JustifyMethod, RenderResult
 from .jupyter import JupyterMixin
 from .measure import Measurement
-from .segment import Segment
+from .segment import Segment, Segments
 from .style import Style
 from .text import Text
 
@@ -100,6 +101,7 @@ ANSI_DARK: Dict[TokenType, Style] = {
 }
 
 RICH_SYNTAX_THEMES = {"ansi_light": ANSI_LIGHT, "ansi_dark": ANSI_DARK}
+NUMBERS_COLUMN_DEFAULT_PADDING = 2
 
 
 class SyntaxTheme(ABC):
@@ -209,6 +211,7 @@ class Syntax(JupyterMixin):
         word_wrap (bool, optional): Enable word wrapping.
         background_color (str, optional): Optional background color, or None to use theme color. Defaults to None.
         indent_guides (bool, optional): Show indent guides. Defaults to False.
+        padding (PaddingDimensions): Padding to apply around the syntax. Defaults to 0 (no padding).
     """
 
     _pygments_style_class: Type[PygmentsStyle]
@@ -242,6 +245,7 @@ class Syntax(JupyterMixin):
         word_wrap: bool = False,
         background_color: Optional[str] = None,
         indent_guides: bool = False,
+        padding: PaddingDimensions = 0,
     ) -> None:
         self.code = code
         self._lexer = lexer
@@ -258,6 +262,7 @@ class Syntax(JupyterMixin):
             Style(bgcolor=background_color) if background_color else Style()
         )
         self.indent_guides = indent_guides
+        self.padding = padding
 
         self._theme = self.get_theme(theme)
 
@@ -278,6 +283,7 @@ class Syntax(JupyterMixin):
         word_wrap: bool = False,
         background_color: Optional[str] = None,
         indent_guides: bool = False,
+        padding: PaddingDimensions = 0,
     ) -> "Syntax":
         """Construct a Syntax object from a file.
 
@@ -296,6 +302,7 @@ class Syntax(JupyterMixin):
             word_wrap (bool, optional): Enable word wrapping of code.
             background_color (str, optional): Optional background color, or None to use theme color. Defaults to None.
             indent_guides (bool, optional): Show indent guides. Defaults to False.
+            padding (PaddingDimensions): Padding to apply around the syntax. Defaults to 0 (no padding).
 
         Returns:
             [Syntax]: A Syntax object that may be printed to the console
@@ -320,6 +327,7 @@ class Syntax(JupyterMixin):
             word_wrap=word_wrap,
             background_color=background_color,
             indent_guides=indent_guides,
+            padding=padding,
         )
 
     @classmethod
@@ -498,7 +506,10 @@ class Syntax(JupyterMixin):
         """Get the number of characters used to render the numbers column."""
         column_width = 0
         if self.line_numbers:
-            column_width = len(str(self.start_line + self.code.count("\n"))) + 2
+            column_width = (
+                len(str(self.start_line + self.code.count("\n")))
+                + NUMBERS_COLUMN_DEFAULT_PADDING
+            )
         return column_width
 
     def _get_number_styles(self, console: Console) -> Tuple[Style, Style, Style]:
@@ -527,15 +538,31 @@ class Syntax(JupyterMixin):
     def __rich_measure__(
         self, console: "Console", options: "ConsoleOptions"
     ) -> "Measurement":
+        _, right, _, left = Padding.unpack(self.padding)
         if self.code_width is not None:
-            width = self.code_width + self._numbers_column_width
+            width = self.code_width + self._numbers_column_width + right + left
             return Measurement(self._numbers_column_width, width)
         return Measurement(self._numbers_column_width, options.max_width)
 
     def __rich_console__(
         self, console: Console, options: ConsoleOptions
     ) -> RenderResult:
+        segments = Segments(self._get_syntax(console, options))
+        if self.padding:
+            yield Padding(
+                segments, style=self._theme.get_background_style(), pad=self.padding
+            )
+        else:
+            yield segments
 
+    def _get_syntax(
+        self,
+        console: Console,
+        options: ConsoleOptions,
+    ) -> Iterable[Segment]:
+        """
+        Get the Segments for the Syntax object, excluding any vertical/horizontal padding
+        """
         transparent_background = self._get_base_style().transparent_background
         code_width = (
             (
@@ -553,12 +580,6 @@ class Syntax(JupyterMixin):
         code = code.expandtabs(self.tab_size)
         text = self.highlight(code, self.line_range)
 
-        (
-            background_style,
-            number_style,
-            highlight_number_style,
-        ) = self._get_number_styles(console)
-
         if not self.line_numbers and not self.word_wrap and not self.line_range:
             if not ends_on_nl:
                 text.remove_suffix("\n")
@@ -615,11 +636,16 @@ class Syntax(JupyterMixin):
 
         highlight_line = self.highlight_lines.__contains__
         _Segment = Segment
-        padding = _Segment(" " * numbers_column_width + " ", background_style)
         new_line = _Segment("\n")
 
         line_pointer = "> " if options.legacy_windows else "❱ "
 
+        (
+            background_style,
+            number_style,
+            highlight_number_style,
+        ) = self._get_number_styles(console)
+
         for line_no, line in enumerate(lines, self.start_line + line_offset):
             if self.word_wrap:
                 wrapped_lines = console.render_lines(
@@ -628,7 +654,6 @@ class Syntax(JupyterMixin):
                     style=background_style,
                     pad=not transparent_background,
                 )
-
             else:
                 segments = list(line.render(console, end=""))
                 if options.no_wrap:
@@ -642,7 +667,11 @@ class Syntax(JupyterMixin):
                             pad=not transparent_background,
                         )
                     ]
+
             if self.line_numbers:
+                wrapped_line_left_pad = _Segment(
+                    " " * numbers_column_width + " ", background_style
+                )
                 for first, wrapped_line in loop_first(wrapped_lines):
                     if first:
                         line_column = str(line_no).rjust(numbers_column_width - 2) + " "
@@ -653,7 +682,7 @@ class Syntax(JupyterMixin):
                             yield _Segment("  ", highlight_number_style)
                             yield _Segment(line_column, number_style)
                     else:
-                        yield padding
+                        yield wrapped_line_left_pad
                     yield from wrapped_line
                     yield new_line
             else:
@@ -739,6 +768,16 @@ if __name__ == "__main__":  # pragma: no cover
         dest="lexer_name",
         help="Lexer name",
     )
+    parser.add_argument(
+        "-p", "--padding", type=int, default=0, dest="padding", help="Padding"
+    )
+    parser.add_argument(
+        "--highlight-line",
+        type=int,
+        default=None,
+        dest="highlight_line",
+        help="The line number (not index!) to highlight",
+    )
     args = parser.parse_args()
 
     from rich.console import Console
@@ -755,6 +794,8 @@ if __name__ == "__main__":  # pragma: no cover
             theme=args.theme,
             background_color=args.background_color,
             indent_guides=args.indent_guides,
+            padding=args.padding,
+            highlight_lines={args.highlight_line},
         )
     else:
         syntax = Syntax.from_path(
@@ -765,5 +806,7 @@ if __name__ == "__main__":  # pragma: no cover
             theme=args.theme,
             background_color=args.background_color,
             indent_guides=args.indent_guides,
+            padding=args.padding,
+            highlight_lines={args.highlight_line},
         )
     console.print(syntax, soft_wrap=args.soft_wrap)


Test patch:
diff --git a/tests/test_syntax.py b/tests/test_syntax.py
index e5d904f3..a88ce0a3 100644
--- a/tests/test_syntax.py
+++ b/tests/test_syntax.py
@@ -1,5 +1,5 @@
 # coding=utf-8
-
+import io
 import os
 import sys
 import tempfile
@@ -303,6 +303,22 @@ def test_syntax_guess_lexer():
     assert Syntax.guess_lexer("banana.html", "{{something|filter:3}}") == "html+django"
 
 
+def test_syntax_padding():
+    syntax = Syntax("x = 1", lexer="python", padding=(1, 3))
+    console = Console(
+        width=20,
+        file=io.StringIO(),
+        color_system="truecolor",
+        legacy_windows=False,
+        record=True,
+    )
+    console.print(syntax)
+    output = console.export_text()
+    assert (
+        output == "                    \n   x = 1            \n                    \n"
+    )
+
+
 if __name__ == "__main__":
     syntax = Panel.fit(
         Syntax(