Repository: Textualize/rich

Issue:
[BUG] `Syntax` wrapping doesn't account for padding
- [x] I've checked [docs](https://rich.readthedocs.io/en/latest/introduction.html) and [closed issues](https://github.com/Textualize/rich/issues?q=is%3Aissue+is%3Aclosed) for possible solutions.
- [x] I can't find my issue in the [FAQ](https://github.com/Textualize/rich/blob/master/FAQ.md).

**Describe the bug**

I originally discovered this issue in Textual, but it looks like a possible bug in `Syntax`.

Here's a MRE with a narrow console width to demonstrate. The first syntax with wrapping enabled works as expected, however notice that the syntax with padding is missing some code:

```python
from rich.console import Console
from rich.syntax import Syntax

EXAMPLE_CODE = """\
class ListViewExample(App):
    def compose(self) -> ComposeResult:
        yield ListView(
            ListItem(Label("One")),
            ListItem(Label("Two")),
            ListItem(Label("Three")),
        )
        yield Footer()\
"""

console = Console(width=32)

console.print("Syntax with wrapping enabled:", style="bold")
syntax = Syntax(EXAMPLE_CODE, lexer="python", word_wrap=True)
console.print(syntax)
console.print()

console.print("After adding padding:", style="bold")
syntax.padding = (1, 2)
console.print(syntax)
```

Tested with the latest version of rich (14.0.0) on Linux.

Hints from issue comments:
@github-actions: Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://github.com/textualize/rich/blob/master/FAQ.md) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)@github-actions: I hope I helped!

Consider [sponsoring](https://github.com/sponsors/willmcgugan) my work on Rich. I give tech support for free, in addition to maintaining Rich and Textual.

If you like using Rich, you might also enjoy [Textual](https://textual.textualize.io).

Will McGugan

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5b18772..a9947656 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,10 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - Removed `typing_extensions` from runtime dependencies https://github.com/Textualize/rich/pull/3763
 - Live objects (including Progress) may now be nested https://github.com/Textualize/rich/pull/3768
+- Added padding property to Syntax which returns a tuple of four integers https://github.com/Textualize/rich/pull/3782
 
 ### Fixed
 
 - Fixed extraction of recursive exceptions https://github.com/Textualize/rich/pull/3772
+- Fixed padding applied to Syntax https://github.com/Textualize/rich/pull/3782
 
 ### Added
 
diff --git a/rich/syntax.py b/rich/syntax.py
index cff8fd23..5e17b48f 100644
--- a/rich/syntax.py
+++ b/rich/syntax.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import os.path
 import re
 import sys
@@ -224,6 +226,17 @@ class _SyntaxHighlightRange(NamedTuple):
     style_before: bool = False
 
 
+class PaddingProperty:
+    """Descriptor to get and set padding."""
+
+    def __get__(self, obj: Syntax, objtype: Type[Syntax]) -> Tuple[int, int, int, int]:
+        """Space around the Syntax."""
+        return obj._padding
+
+    def __set__(self, obj: Syntax, padding: PaddingDimensions) -> None:
+        obj._padding = Padding.unpack(padding)
+
+
 class Syntax(JupyterMixin):
     """Construct a Syntax object to render syntax highlighted code.
 
@@ -293,11 +306,13 @@ class Syntax(JupyterMixin):
             Style(bgcolor=background_color) if background_color else Style()
         )
         self.indent_guides = indent_guides
-        self.padding = padding
+        self._padding = Padding.unpack(padding)
 
         self._theme = self.get_theme(theme)
         self._stylized_ranges: List[_SyntaxHighlightRange] = []
 
+    padding = PaddingProperty()
+
     @classmethod
     def from_path(
         cls,
@@ -371,8 +386,8 @@ class Syntax(JupyterMixin):
         is supplied, the lexer will be chosen based on the file extension..
 
         Args:
-             path (AnyStr): The path to the file containing the code you wish to know the lexer for.
-             code (str, optional): Optional string of code that will be used as a fallback if no lexer
+            path (AnyStr): The path to the file containing the code you wish to know the lexer for.
+            code (str, optional): Optional string of code that will be used as a fallback if no lexer
                 is found for the supplied path.
 
         Returns:
@@ -607,7 +622,7 @@ class Syntax(JupyterMixin):
     def __rich_measure__(
         self, console: "Console", options: "ConsoleOptions"
     ) -> "Measurement":
-        _, right, _, left = Padding.unpack(self.padding)
+        _, right, _, left = self.padding
         padding = left + right
         if self.code_width is not None:
             width = self.code_width + self._numbers_column_width + padding + 1
@@ -626,7 +641,7 @@ class Syntax(JupyterMixin):
         self, console: Console, options: ConsoleOptions
     ) -> RenderResult:
         segments = Segments(self._get_syntax(console, options))
-        if self.padding:
+        if any(self.padding):
             yield Padding(segments, style=self._get_base_style(), pad=self.padding)
         else:
             yield segments
@@ -640,15 +655,19 @@ class Syntax(JupyterMixin):
         Get the Segments for the Syntax object, excluding any vertical/horizontal padding
         """
         transparent_background = self._get_base_style().transparent_background
+        _pad_top, pad_right, _pad_bottom, pad_left = self.padding
+        horizontal_padding = pad_left + pad_right
         code_width = (
             (
                 (options.max_width - self._numbers_column_width - 1)
                 if self.line_numbers
                 else options.max_width
             )
+            - horizontal_padding
             if self.code_width is None
             else self.code_width
         )
+        code_width = max(0, code_width)
 
         ends_on_nl, processed_code = self._process_code(self.code)
         text = self.highlight(processed_code, self.line_range)


Test patch:
diff --git a/tests/test_syntax.py b/tests/test_syntax.py
index 04edb9cc..3537ab5b 100644
--- a/tests/test_syntax.py
+++ b/tests/test_syntax.py
@@ -2,6 +2,7 @@ import io
 import os
 import sys
 import tempfile
+from importlib.metadata import Distribution
 
 import pytest
 from pygments.lexers import PythonLexer
@@ -19,7 +20,6 @@ from rich.syntax import (
 )
 
 from .render import render
-from importlib.metadata import Distribution
 
 PYGMENTS_VERSION = Distribution.from_name("pygments").version
 OLD_PYGMENTS = PYGMENTS_VERSION == "2.13.0"
@@ -40,7 +40,7 @@ def loop_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]:
     yield first, True, previous_value'''
 
 
-def test_blank_lines():
+def test_blank_lines() -> None:
     code = "\n\nimport this\n\n"
     syntax = Syntax(
         code, lexer="python", theme="ascii_light", code_width=30, line_numbers=True
@@ -53,7 +53,7 @@ def test_blank_lines():
     )
 
 
-def test_python_render():
+def test_python_render() -> None:
     syntax = Panel.fit(
         Syntax(
             CODE,
@@ -72,7 +72,7 @@ def test_python_render():
     assert rendered_syntax == expected
 
 
-def test_python_render_simple():
+def test_python_render_simple() -> None:
     syntax = Syntax(
         CODE,
         lexer="python",
@@ -87,7 +87,7 @@ def test_python_render_simple():
     assert rendered_syntax == expected
 
 
-def test_python_render_simple_passing_lexer_instance():
+def test_python_render_simple_passing_lexer_instance() -> None:
     syntax = Syntax(
         CODE,
         lexer=PythonLexer(),
@@ -103,7 +103,7 @@ def test_python_render_simple_passing_lexer_instance():
 
 
 @pytest.mark.skipif(OLD_PYGMENTS, reason="Pygments changed their tokenizer")
-def test_python_render_simple_indent_guides():
+def test_python_render_simple_indent_guides() -> None:
     syntax = Syntax(
         CODE,
         lexer="python",
@@ -120,7 +120,7 @@ def test_python_render_simple_indent_guides():
 
 
 @pytest.mark.skipif(OLD_PYGMENTS, reason="Pygments changed their tokenizer")
-def test_python_render_line_range_indent_guides():
+def test_python_render_line_range_indent_guides() -> None:
     syntax = Syntax(
         CODE,
         lexer="python",
@@ -137,7 +137,7 @@ def test_python_render_line_range_indent_guides():
     assert rendered_syntax == expected
 
 
-def test_python_render_indent_guides():
+def test_python_render_indent_guides() -> None:
     syntax = Panel.fit(
         Syntax(
             CODE,
@@ -157,19 +157,19 @@ def test_python_render_indent_guides():
     assert rendered_syntax == expected
 
 
-def test_pygments_syntax_theme_non_str():
+def test_pygments_syntax_theme_non_str() -> None:
     from pygments.style import Style as PygmentsStyle
 
     style = PygmentsSyntaxTheme(PygmentsStyle())
     assert style.get_background_style().bgcolor == Color.parse("#ffffff")
 
 
-def test_pygments_syntax_theme():
+def test_pygments_syntax_theme() -> None:
     style = PygmentsSyntaxTheme("default")
     assert style.get_style_for_token("abc") == Style.parse("none")
 
 
-def test_get_line_color_none():
+def test_get_line_color_none() -> None:
     style = PygmentsSyntaxTheme("default")
     style._background_style = Style(bgcolor=None)
     syntax = Syntax(
@@ -185,7 +185,7 @@ def test_get_line_color_none():
     assert syntax._get_line_numbers_color() == Color.default()
 
 
-def test_highlight_background_color():
+def test_highlight_background_color() -> None:
     syntax = Syntax(
         CODE,
         lexer="python",
@@ -199,7 +199,7 @@ def test_highlight_background_color():
     assert syntax.highlight(CODE).style == Style.parse("on red")
 
 
-def test_get_number_styles():
+def test_get_number_styles() -> None:
     syntax = Syntax(CODE, "python", theme="monokai", line_numbers=True)
     console = Console(color_system="windows")
     assert syntax._get_number_styles(console=console) == (
@@ -209,7 +209,7 @@ def test_get_number_styles():
     )
 
 
-def test_get_style_for_token():
+def test_get_style_for_token() -> None:
     # from pygments.style import Style as PygmentsStyle
     # pygments_style = PygmentsStyle()
     from pygments.style import Token
@@ -230,7 +230,7 @@ def test_get_style_for_token():
     assert syntax._get_line_numbers_color() == Color.default()
 
 
-def test_option_no_wrap():
+def test_option_no_wrap() -> None:
     syntax = Syntax(
         CODE,
         lexer="python",
@@ -247,7 +247,7 @@ def test_option_no_wrap():
     assert rendered_syntax == expected
 
 
-def test_syntax_highlight_ranges():
+def test_syntax_highlight_ranges() -> None:
     syntax = Syntax(
         CODE,
         lexer="python",
@@ -302,7 +302,7 @@ def test_syntax_highlight_ranges():
     assert rendered_syntax == expected
 
 
-def test_ansi_theme():
+def test_ansi_theme() -> None:
     style = Style(color="red")
     theme = ANSISyntaxTheme({("foo", "bar"): style})
     assert theme.get_style_for_token(("foo", "bar", "baz")) == style
@@ -315,7 +315,7 @@ skip_windows_permission_error = pytest.mark.skipif(
 
 
 @skip_windows_permission_error
-def test_from_path():
+def test_from_path() -> None:
     fh, path = tempfile.mkstemp("example.py")
     try:
         os.write(fh, b"import this\n")
@@ -328,7 +328,7 @@ def test_from_path():
 
 
 @skip_windows_permission_error
-def test_from_path_unknown_lexer():
+def test_from_path_unknown_lexer() -> None:
     fh, path = tempfile.mkstemp("example.nosuchtype")
     try:
         os.write(fh, b"import this\n")
@@ -340,7 +340,7 @@ def test_from_path_unknown_lexer():
 
 
 @skip_windows_permission_error
-def test_from_path_lexer_override():
+def test_from_path_lexer_override() -> None:
     fh, path = tempfile.mkstemp("example.nosuchtype")
     try:
         os.write(fh, b"import this\n")
@@ -352,7 +352,7 @@ def test_from_path_lexer_override():
 
 
 @skip_windows_permission_error
-def test_from_path_lexer_override_invalid_lexer():
+def test_from_path_lexer_override_invalid_lexer() -> None:
     fh, path = tempfile.mkstemp("example.nosuchtype")
     try:
         os.write(fh, b"import this\n")
@@ -363,7 +363,7 @@ def test_from_path_lexer_override_invalid_lexer():
         os.remove(path)
 
 
-def test_syntax_guess_lexer():
+def test_syntax_guess_lexer() -> None:
     assert Syntax.guess_lexer("banana.py") == "python"
     assert Syntax.guess_lexer("banana.py", "import this") == "python"
     assert Syntax.guess_lexer("banana.html", "<a href='#'>hello</a>") == "html"
@@ -371,7 +371,7 @@ def test_syntax_guess_lexer():
     assert Syntax.guess_lexer("banana.html", "{{something|filter:3}}") == "html+django"
 
 
-def test_syntax_padding():
+def test_syntax_padding() -> None:
     syntax = Syntax("x = 1", lexer="python", padding=(1, 3))
     console = Console(
         width=20,
@@ -387,7 +387,7 @@ def test_syntax_padding():
     )
 
 
-def test_syntax_measure():
+def test_syntax_measure() -> None:
     console = Console()
     code = Syntax("Hello, World", "python")
     assert code.__rich_measure__(console, console.options) == Measurement(0, 12)
@@ -402,7 +402,7 @@ def test_syntax_measure():
     assert code.__rich_measure__(console, console.options) == Measurement(3, 24)
 
 
-def test_background_color_override_includes_padding():
+def test_background_color_override_includes_padding() -> None:
     """Regression test for https://github.com/Textualize/rich/issues/3295"""
 
     syntax = Syntax(
@@ -419,6 +419,22 @@ def test_background_color_override_includes_padding():
     )
 
 
+def test_padding_plus_wrap() -> None:
+    """Regression test for https://github.com/Textualize/rich/issues/3727"""
+    console = Console(width=24, file=io.StringIO(), legacy_windows=False)
+    syntax = Syntax(
+        "'Hello, World. This should wrap.'",
+        lexer="python",
+        padding=(0, 3),
+        word_wrap=True,
+    )
+    console.print(syntax)
+    output = console.file.getvalue()
+    print(repr(output))
+    expected = "   'Hello, World.       \n   This should wrap.'   \n"
+    assert output == expected
+
+
 if __name__ == "__main__":
     syntax = Panel.fit(
         Syntax(