Skip to content

Commit

Permalink
Support zero-indexed line numbers in TextArea (#4471)
Browse files Browse the repository at this point in the history
* widgets: text-area: Make the starting line number a kwarg

* Add snapshot test for TextArea.line_number_start and update docs

* Update snapshots

* Update snapshots using latest Textual version

---------

Co-authored-by: Darren Burns <darrenb900@gmail.com>
  • Loading branch information
royatt and darrenburns committed Jul 11, 2024
1 parent fad03c8 commit b61028d
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 2 deletions.
3 changes: 3 additions & 0 deletions docs/widgets/text_area.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,8 @@ the `show_line_numbers` attribute to `True` or `False`.

Setting this attribute will immediately repaint the `TextArea` to reflect the new value.

You can also change the start line number (the topmost line number in the gutter) by setting the `line_number_start` reactive attribute.

### Extending `TextArea`

Sometimes, you may wish to subclass `TextArea` to add some extra functionality.
Expand Down Expand Up @@ -506,6 +508,7 @@ A detailed view of these classes is out of scope, but do note that a lot of the
| `theme` | `str` | `"css"` | The theme to use. |
| `selection` | `Selection` | `Selection()` | The current selection. |
| `show_line_numbers` | `bool` | `False` | Show or hide line numbers. |
| `line_number_start` | `int` | `1` | The start line number in the gutter. |
| `indent_width` | `int` | `4` | The number of spaces to indent and width of tabs. |
| `match_cursor_bracket` | `bool` | `True` | Enable/disable highlighting matching brackets under cursor. |
| `cursor_blink` | `bool` | `True` | Enable/disable blinking of the cursor when the widget has focus. |
Expand Down
21 changes: 19 additions & 2 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ class TextArea(ScrollView):
Changing this value will immediately re-render the `TextArea`."""

line_number_start: Reactive[int] = reactive(1, init=False)
"""The line number the first line should be."""

indent_width: Reactive[int] = reactive(4, init=False)
"""The width of tabs or the multiple of spaces to align to on pressing the `tab` key.
Expand Down Expand Up @@ -370,6 +373,7 @@ def __init__(
tab_behavior: Literal["focus", "indent"] = "focus",
read_only: bool = False,
show_line_numbers: bool = False,
line_number_start: int = 1,
max_checkpoints: int = 50,
name: str | None = None,
id: str | None = None,
Expand All @@ -387,6 +391,7 @@ def __init__(
tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab.
read_only: Enable read-only mode. This prevents edits using the keyboard.
show_line_numbers: Show line numbers on the left edge.
line_number_start: What line number to start on.
max_checkpoints: The maximum number of undo history checkpoints to retain.
name: The name of the `TextArea` widget.
id: The ID of the widget, used to refer to it from Textual CSS.
Expand Down Expand Up @@ -455,6 +460,7 @@ def __init__(
self.set_reactive(TextArea.soft_wrap, soft_wrap)
self.set_reactive(TextArea.read_only, read_only)
self.set_reactive(TextArea.show_line_numbers, show_line_numbers)
self.set_reactive(TextArea.line_number_start, line_number_start)

self.tab_behavior = tab_behavior

Expand All @@ -475,6 +481,7 @@ def code_editor(
tab_behavior: Literal["focus", "indent"] = "indent",
read_only: bool = False,
show_line_numbers: bool = True,
line_number_start: int = 1,
max_checkpoints: int = 50,
name: str | None = None,
id: str | None = None,
Expand All @@ -494,6 +501,7 @@ def code_editor(
soft_wrap: Enable soft wrapping.
tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab.
show_line_numbers: Show line numbers on the left edge.
line_number_start: What line number to start on.
name: The name of the `TextArea` widget.
id: The ID of the widget, used to refer to it from Textual CSS.
classes: One or more Textual CSS compatible class names separated by spaces.
Expand All @@ -508,6 +516,7 @@ def code_editor(
tab_behavior=tab_behavior,
read_only=read_only,
show_line_numbers=show_line_numbers,
line_number_start=line_number_start,
max_checkpoints=max_checkpoints,
name=name,
id=id,
Expand Down Expand Up @@ -691,6 +700,11 @@ def _watch_show_line_numbers(self) -> None:
self._rewrap_and_refresh_virtual_size()
self.scroll_cursor_visible()

def _watch_line_number_start(self) -> None:
"""The line number gutter max size might change and contributes to virtual size, so recalculate."""
self._rewrap_and_refresh_virtual_size()
self.scroll_cursor_visible()

def _watch_indent_width(self) -> None:
"""Changing width of tabs will change the document display width."""
self._rewrap_and_refresh_virtual_size()
Expand Down Expand Up @@ -1142,7 +1156,9 @@ def render_line(self, y: int) -> Strip:
gutter_style = theme.gutter_style

gutter_width_no_margin = gutter_width - 2
gutter_content = str(line_index + 1) if section_offset == 0 else ""
gutter_content = (
str(line_index + self.line_number_start) if section_offset == 0 else ""
)
gutter = Text(
f"{gutter_content:>{gutter_width_no_margin}} ",
style=gutter_style or "",
Expand Down Expand Up @@ -1467,7 +1483,8 @@ def gutter_width(self) -> int:
# The longest number in the gutter plus two extra characters: `│ `.
gutter_margin = 2
gutter_width = (
len(str(self.document.line_count)) + gutter_margin
len(str(self.document.line_count - 1 + self.line_number_start))
+ gutter_margin
if self.show_line_numbers
else 0
)
Expand Down
97 changes: 97 additions & 0 deletions tests/snapshot_tests/__snapshots__/test_snapshots.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -45462,6 +45462,103 @@

'''
# ---
# name: test_text_area_line_number_start
'''
<svg class="rich-terminal" viewBox="0 0 409 245.2" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>

@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}

.terminal-3789936591-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}

.terminal-3789936591-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}

.terminal-3789936591-r1 { fill: #1e1e1e }
.terminal-3789936591-r2 { fill: #0178d4 }
.terminal-3789936591-r3 { fill: #c5c8c6 }
.terminal-3789936591-r4 { fill: #a8a8a8;font-weight: bold }
.terminal-3789936591-r5 { fill: #151515 }
.terminal-3789936591-r6 { fill: #e2e2e2 }
.terminal-3789936591-r7 { fill: #787878 }
.terminal-3789936591-r8 { fill: #e1e1e1 }
</style>

<defs>
<clipPath id="terminal-3789936591-clip-terminal">
<rect x="0" y="0" width="389.4" height="194.2" />
</clipPath>
<clipPath id="terminal-3789936591-line-0">
<rect x="0" y="1.5" width="390.4" height="24.65"/>
</clipPath>
<clipPath id="terminal-3789936591-line-1">
<rect x="0" y="25.9" width="390.4" height="24.65"/>
</clipPath>
<clipPath id="terminal-3789936591-line-2">
<rect x="0" y="50.3" width="390.4" height="24.65"/>
</clipPath>
<clipPath id="terminal-3789936591-line-3">
<rect x="0" y="74.7" width="390.4" height="24.65"/>
</clipPath>
<clipPath id="terminal-3789936591-line-4">
<rect x="0" y="99.1" width="390.4" height="24.65"/>
</clipPath>
<clipPath id="terminal-3789936591-line-5">
<rect x="0" y="123.5" width="390.4" height="24.65"/>
</clipPath>
<clipPath id="terminal-3789936591-line-6">
<rect x="0" y="147.9" width="390.4" height="24.65"/>
</clipPath>
</defs>

<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="407" height="243.2" rx="8"/><text class="terminal-3789936591-title" fill="#c5c8c6" text-anchor="middle" x="203" y="27">LineNumbersReactive</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>

<g transform="translate(9, 41)" clip-path="url(#terminal-3789936591-clip-terminal)">
<rect fill="#0178d4" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="1.5" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="24.4" y="25.9" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#d8d8d8" x="109.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="122" y="25.9" width="244" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="366" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="378.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="50.3" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="109.8" y="50.3" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="366" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="378.2" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="74.7" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="109.8" y="74.7" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="366" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="378.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="99.1" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="109.8" y="99.1" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="366" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="378.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="123.5" width="366" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="378.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="147.9" width="366" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="378.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="172.3" width="378.2" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-3789936591-matrix">
<text class="terminal-3789936591-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-3789936591-line-0)">▊</text><text class="terminal-3789936591-r2" x="12.2" y="20" textLength="378.2" clip-path="url(#terminal-3789936591-line-0)">▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎</text><text class="terminal-3789936591-r3" x="390.4" y="20" textLength="12.2" clip-path="url(#terminal-3789936591-line-0)">
</text><text class="terminal-3789936591-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-3789936591-line-1)">▊</text><text class="terminal-3789936591-r4" x="24.4" y="44.4" textLength="85.4" clip-path="url(#terminal-3789936591-line-1)">&#160;9999&#160;&#160;</text><text class="terminal-3789936591-r5" x="109.8" y="44.4" textLength="12.2" clip-path="url(#terminal-3789936591-line-1)">F</text><text class="terminal-3789936591-r6" x="122" y="44.4" textLength="244" clip-path="url(#terminal-3789936591-line-1)">oo&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-3789936591-r2" x="378.2" y="44.4" textLength="12.2" clip-path="url(#terminal-3789936591-line-1)">▎</text><text class="terminal-3789936591-r3" x="390.4" y="44.4" textLength="12.2" clip-path="url(#terminal-3789936591-line-1)">
</text><text class="terminal-3789936591-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-3789936591-line-2)">▊</text><text class="terminal-3789936591-r7" x="24.4" y="68.8" textLength="85.4" clip-path="url(#terminal-3789936591-line-2)">10000&#160;&#160;</text><text class="terminal-3789936591-r8" x="109.8" y="68.8" textLength="256.2" clip-path="url(#terminal-3789936591-line-2)">Bar&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-3789936591-r2" x="378.2" y="68.8" textLength="12.2" clip-path="url(#terminal-3789936591-line-2)">▎</text><text class="terminal-3789936591-r3" x="390.4" y="68.8" textLength="12.2" clip-path="url(#terminal-3789936591-line-2)">
</text><text class="terminal-3789936591-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-3789936591-line-3)">▊</text><text class="terminal-3789936591-r7" x="24.4" y="93.2" textLength="85.4" clip-path="url(#terminal-3789936591-line-3)">10001&#160;&#160;</text><text class="terminal-3789936591-r8" x="109.8" y="93.2" textLength="256.2" clip-path="url(#terminal-3789936591-line-3)">Baz&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-3789936591-r2" x="378.2" y="93.2" textLength="12.2" clip-path="url(#terminal-3789936591-line-3)">▎</text><text class="terminal-3789936591-r3" x="390.4" y="93.2" textLength="12.2" clip-path="url(#terminal-3789936591-line-3)">
</text><text class="terminal-3789936591-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-3789936591-line-4)">▊</text><text class="terminal-3789936591-r7" x="24.4" y="117.6" textLength="85.4" clip-path="url(#terminal-3789936591-line-4)">10002&#160;&#160;</text><text class="terminal-3789936591-r2" x="378.2" y="117.6" textLength="12.2" clip-path="url(#terminal-3789936591-line-4)">▎</text><text class="terminal-3789936591-r3" x="390.4" y="117.6" textLength="12.2" clip-path="url(#terminal-3789936591-line-4)">
</text><text class="terminal-3789936591-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-3789936591-line-5)">▊</text><text class="terminal-3789936591-r2" x="378.2" y="142" textLength="12.2" clip-path="url(#terminal-3789936591-line-5)">▎</text><text class="terminal-3789936591-r3" x="390.4" y="142" textLength="12.2" clip-path="url(#terminal-3789936591-line-5)">
</text><text class="terminal-3789936591-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-3789936591-line-6)">▊</text><text class="terminal-3789936591-r2" x="378.2" y="166.4" textLength="12.2" clip-path="url(#terminal-3789936591-line-6)">▎</text><text class="terminal-3789936591-r3" x="390.4" y="166.4" textLength="12.2" clip-path="url(#terminal-3789936591-line-6)">
</text><text class="terminal-3789936591-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-3789936591-line-7)">▊</text><text class="terminal-3789936591-r2" x="12.2" y="190.8" textLength="378.2" clip-path="url(#terminal-3789936591-line-7)">▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎</text>
</g>
</g>
</svg>

'''
# ---
# name: test_text_area_read_only_cursor_rendering
'''
<svg class="rich-terminal" viewBox="0 0 384 172.0" xmlns="http://www.w3.org/2000/svg">
Expand Down
25 changes: 25 additions & 0 deletions tests/snapshot_tests/snapshot_apps/text_area_line_number_start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from textual.app import App, ComposeResult
from textual.widgets import TextArea

TEXT = """\
Foo
Bar
Baz
"""


class LineNumbersReactive(App[None]):
START_LINE_NUMBER = 9999

def compose(self) -> ComposeResult:
yield TextArea(
TEXT,
soft_wrap=True,
show_line_numbers=True,
line_number_start=self.START_LINE_NUMBER,
)


app = LineNumbersReactive()
if __name__ == "__main__":
app.run()
6 changes: 6 additions & 0 deletions tests/snapshot_tests/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,12 @@ def test_text_area_wrapping_and_folding(snap_compare):
)


def test_text_area_line_number_start(snap_compare):
assert snap_compare(
SNAPSHOT_APPS_DIR / "text_area_line_number_start.py", terminal_size=(32, 8)
)


def test_digits(snap_compare) -> None:
assert snap_compare(SNAPSHOT_APPS_DIR / "digits.py")

Expand Down

0 comments on commit b61028d

Please sign in to comment.