Skip to content

Commit 2d9f2bb

Browse files
committed
optimizations for Content and Style
1 parent 422852a commit 2d9f2bb

File tree

4 files changed

+104
-17
lines changed

4 files changed

+104
-17
lines changed

src/textual/_compositor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -695,9 +695,9 @@ def add_widget(
695695
widget.container_size.height
696696
- widget.scrollbar_size_horizontal
697697
)
698-
widget.set_reactive(Widget.scroll_y, new_scroll_y)
699-
widget.set_reactive(Widget.scroll_target_y, new_scroll_y)
700-
widget.vertical_scrollbar._reactive_position = new_scroll_y
698+
widget.scroll_y = new_scroll_y
699+
widget.scroll_target_y = new_scroll_y
700+
widget.vertical_scrollbar.position = new_scroll_y
701701

702702
if visible:
703703
# Add any scrollbars

src/textual/content.py

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,27 @@ def extend(self, cells: int) -> "Span":
106106
return Span(start, end + cells, style)
107107
return self
108108

109+
def shift(self, distance: int) -> "Span":
110+
"""Shift a span a given distance.
111+
112+
Note that the start offset is clamped to 0.
113+
The end offset is not clamped, as it is assumed this has already been checked by the caller.
114+
115+
Args:
116+
distance: Number of characters to move.
117+
118+
Returns:
119+
New Span.
120+
"""
121+
if distance < 0:
122+
start, end, style = self
123+
return Span(
124+
offset if (offset := start + distance) > 0 else 0, end + distance, style
125+
)
126+
else:
127+
start, end, style = self
128+
return Span(start + distance, end + distance, style)
129+
109130

110131
@rich.repr.auto
111132
@total_ordering
@@ -126,6 +147,7 @@ def __init__(
126147
text: str = "",
127148
spans: list[Span] | None = None,
128149
cell_length: int | None = None,
150+
strip_control_codes: bool = True,
129151
) -> None:
130152
"""
131153
Initialize a Content object.
@@ -134,8 +156,12 @@ def __init__(
134156
text: text content.
135157
spans: Optional list of spans.
136158
cell_length: Cell length of text if known, otherwise `None`.
159+
strip_control_codes: Strip control codes that may break output?
137160
"""
138-
self._text: str = _strip_control_codes(text)
161+
if strip_control_codes and text:
162+
self._text: str = _strip_control_codes(text)
163+
else:
164+
self._text = text
139165
self._spans: list[Span] = [] if spans is None else spans
140166
self._cell_length = cell_length
141167
self._optimal_width_cache: int | None = None
@@ -147,6 +173,8 @@ def __init__(
147173
self._split_cache: FIFOCache[tuple[str, bool, bool], list[Content]] | None = (
148174
None
149175
)
176+
# If there are 1 or 0 spans, it can't be simplified further
177+
self._simplified = len(self._spans) <= 1
150178

151179
def __str__(self) -> str:
152180
return self._text
@@ -321,25 +349,35 @@ def styled(
321349
text: str,
322350
style: Style | str = "",
323351
cell_length: int | None = None,
352+
strip_control_codes: bool = True,
324353
) -> Content:
325354
"""Create a Content instance from text and an optional style.
326355
327356
Args:
328357
text: String content.
329358
style: Desired style.
330359
cell_length: Cell length of text if known, otherwise `None`.
360+
strip_control_codes: Strip control codes that may break output.
331361
332362
Returns:
333363
New Content instance.
334364
"""
335365
if not text:
336366
return Content("")
337-
new_content = cls(text, [Span(0, len(text), style)] if style else None)
367+
new_content = cls(
368+
text,
369+
[Span(0, len(text), style)] if style else None,
370+
cell_length,
371+
strip_control_codes=strip_control_codes,
372+
)
338373
return new_content
339374

340375
@classmethod
341376
def assemble(
342-
cls, *parts: str | Content | tuple[str, str | Style], end: str = ""
377+
cls,
378+
*parts: str | Content | tuple[str, str | Style],
379+
end: str = "",
380+
strip_control_codes: bool = True,
343381
) -> Content:
344382
"""Construct new content from string, content, or tuples of (TEXT, STYLE).
345383
@@ -359,6 +397,7 @@ def assemble(
359397
*parts: Parts to join to gether. A *part* may be a simple string, another Content
360398
instance, or tuple containing text and a style.
361399
end: Optional end to the Content.
400+
strip_control_codes: Strip control codes that may break output.
362401
"""
363402
text: list[str] = []
364403
spans: list[Span] = []
@@ -390,7 +429,7 @@ def assemble(
390429
position += len(part.plain)
391430
if end:
392431
text_append(end)
393-
return cls("".join(text), spans)
432+
return cls("".join(text), spans, strip_control_codes=strip_control_codes)
394433

395434
def simplify(self) -> Content:
396435
"""Simplify spans by joining contiguous spans together.
@@ -405,7 +444,7 @@ def simplify(self) -> Content:
405444
Returns:
406445
Self.
407446
"""
408-
if not (spans := self._spans):
447+
if not (spans := self._spans) or self._simplified:
409448
return self
410449
last_span = Span(-1, -1, "")
411450
new_spans: list[Span] = []
@@ -419,6 +458,7 @@ def simplify(self) -> Content:
419458
last_span = span
420459
if changed:
421460
self._spans[:] = new_spans
461+
self._simplified = True
422462
return self
423463

424464
def add_spans(self, spans: Sequence[Span]) -> Content:
@@ -431,7 +471,12 @@ def add_spans(self, spans: Sequence[Span]) -> Content:
431471
A Content instance.
432472
"""
433473
if spans:
434-
return Content(self.plain, [*self._spans, *spans], self._cell_length)
474+
return Content(
475+
self.plain,
476+
[*self._spans, *spans],
477+
self._cell_length,
478+
strip_control_codes=False,
479+
)
435480
return self
436481

437482
def __eq__(self, other: object) -> bool:
@@ -706,7 +751,7 @@ def plain(self) -> str:
706751
def without_spans(self) -> Content:
707752
"""The content with no spans"""
708753
if self._spans:
709-
return Content(self.plain, [], self._cell_length)
754+
return Content(self.plain, [], self._cell_length, strip_control_codes=False)
710755
return self
711756

712757
@property
@@ -726,6 +771,7 @@ def get_text_at(offset: int) -> "Content":
726771
for start, end, style in self._spans
727772
if end > offset >= start
728773
],
774+
strip_control_codes=False,
729775
)
730776
return content
731777

@@ -734,16 +780,32 @@ def get_text_at(offset: int) -> "Content":
734780
else:
735781
start, stop, step = slice.indices(len(self.plain))
736782
if step == 1:
737-
lines = self.divide([start, stop])
738-
return lines[1]
783+
if start == 0:
784+
if stop >= len(self.plain):
785+
return self
786+
text = self.plain[:stop]
787+
return Content(
788+
text,
789+
self._trim_spans(text, self._spans),
790+
strip_control_codes=False,
791+
)
792+
else:
793+
text = self.plain[start:stop]
794+
spans = [
795+
span.shift(-start) for span in self._spans if span.end > start
796+
]
797+
return Content(
798+
text, self._trim_spans(text, spans), strip_control_codes=False
799+
)
800+
739801
else:
740802
# This would be a bit of work to implement efficiently
741803
# For now, its not required
742804
raise TypeError("slices with step!=1 are not supported")
743805

744806
def __add__(self, other: Content | str) -> Content:
745807
if isinstance(other, str):
746-
return Content(self._text + other, self._spans)
808+
return Content(self._text + other, self._spans, strip_control_codes=False)
747809
if isinstance(other, Content):
748810
offset = len(self.plain)
749811
content = Content(
@@ -801,6 +863,7 @@ def append(self, content: Content | str) -> Content:
801863
if self._cell_length is None
802864
else self._cell_length + cell_len(content)
803865
),
866+
strip_control_codes=False,
804867
)
805868
return Content("").join([self, content])
806869

@@ -836,12 +899,20 @@ def iter_content() -> Iterable[Content]:
836899
"""Iterate the lines, optionally inserting the separator."""
837900
if self.plain:
838901
for last, line in loop_last(lines):
839-
yield line if isinstance(line, Content) else Content(line)
902+
yield (
903+
line
904+
if isinstance(line, Content)
905+
else Content(line, strip_control_codes=False)
906+
)
840907
if not last:
841908
yield self
842909
else:
843910
for line in lines:
844-
yield line if isinstance(line, Content) else Content(line)
911+
yield (
912+
line
913+
if isinstance(line, Content)
914+
else Content(line, strip_control_codes=False)
915+
)
845916

846917
extend_text = text.extend
847918
extend_spans = spans.extend
@@ -935,13 +1006,16 @@ def truncate(
9351006
if pad and length < max_width:
9361007
spaces = max_width - length
9371008
text = f"{self.plain}{' ' * spaces}"
1009+
return Content(text, spans, max_width, strip_control_codes=False)
9381010
elif length > max_width:
9391011
if ellipsis and max_width:
9401012
text = set_cell_size(self.plain, max_width - 1) + "…"
9411013
else:
9421014
text = set_cell_size(self.plain, max_width)
9431015
spans = self._trim_spans(text, self._spans)
944-
return Content(text, spans)
1016+
return Content(text, spans, max_width, strip_control_codes=False)
1017+
else:
1018+
return self
9451019

9461020
def pad_left(self, count: int, character: str = " ") -> Content:
9471021
"""Pad the left with a given character.
@@ -962,6 +1036,7 @@ def pad_left(self, count: int, character: str = " ") -> Content:
9621036
text,
9631037
spans,
9641038
None if self._cell_length is None else self._cell_length + count,
1039+
strip_control_codes=False,
9651040
)
9661041
return content
9671042

@@ -987,6 +1062,7 @@ def extend_right(self, count: int, character: str = " ") -> Content:
9871062
for span in self._spans
9881063
],
9891064
None if self._cell_length is None else self._cell_length + count,
1065+
strip_control_codes=False,
9901066
)
9911067
return self
9921068

@@ -1003,6 +1079,7 @@ def pad_right(self, count: int, character: str = " ") -> Content:
10031079
f"{self.plain}{character * count}",
10041080
self._spans,
10051081
None if self._cell_length is None else self._cell_length + count,
1082+
strip_control_codes=False,
10061083
)
10071084
return self
10081085

@@ -1029,6 +1106,7 @@ def pad(self, left: int, right: int, character: str = " ") -> Content:
10291106
text,
10301107
spans,
10311108
None if self._cell_length is None else self._cell_length + left + right,
1109+
strip_control_codes=False,
10321110
)
10331111
return content
10341112

@@ -1114,6 +1192,8 @@ def stylize(
11141192
return Content(
11151193
self.plain,
11161194
self._spans + [Span(start, length if length < end else end, style)],
1195+
self._cell_length,
1196+
strip_control_codes=False,
11171197
)
11181198

11191199
def stylize_before(
@@ -1146,6 +1226,8 @@ def stylize_before(
11461226
return Content(
11471227
self.plain,
11481228
[Span(start, length if length < end else end, style), *self._spans],
1229+
self._cell_length,
1230+
strip_control_codes=False,
11491231
)
11501232

11511233
def render(

src/textual/style.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@ def markup_tag(self) -> str:
239239
@lru_cache(maxsize=1024 * 4)
240240
def __add__(self, other: object | None) -> Style:
241241
if isinstance(other, Style):
242+
if self._is_null:
243+
return other
244+
if other._is_null:
245+
return self
242246
(
243247
background,
244248
foreground,

src/textual/widget.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1896,7 +1896,7 @@ def watch_hover_style(
18961896
) -> None:
18971897
# TODO: This will cause the widget to refresh, even when there are no links
18981898
# Can we avoid this?
1899-
if self.auto_links:
1899+
if self.auto_links and not self.app.mouse_captured:
19001900
self.highlight_link_id = hover_style.link_id
19011901

19021902
def watch_scroll_x(self, old_value: float, new_value: float) -> None:
@@ -4270,6 +4270,7 @@ def refresh(
42704270
Returns:
42714271
The `Widget` instance.
42724272
"""
4273+
42734274
if layout and not self._layout_required:
42744275
self._layout_required = True
42754276
self._layout_updates += 1

0 commit comments

Comments
 (0)