From b335080decbd421a38944007b2b1975acf82c334 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Jul 2025 09:16:13 +0100 Subject: [PATCH 1/3] add caching --- src/textual/content.py | 115 +++++++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 32 deletions(-) diff --git a/src/textual/content.py b/src/textual/content.py index 3abac71246..af74bd1504 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -27,6 +27,8 @@ from textual._cells import cell_len from textual._context import active_app from textual._loop import loop_last +from textual._profile import timer +from textual.cache import FIFOCache from textual.color import Color from textual.css.types import TextAlign, TextOverflow from textual.selection import Selection @@ -140,6 +142,13 @@ def __init__( self._optimal_width_cache: int | None = None self._minimal_width_cache: int | None = None self._height_cache: tuple[tuple[int, str, bool] | None, int] = (None, 0) + self._divide_cache: ( + FIFOCache[Sequence[int], list[tuple[Span, int, int]]] | None + ) = None + self._split_cache: FIFOCache[tuple[str, bool, bool], list[Content]] | None = ( + None + ) + self._line_length_cache: FIFOCache[Sequence[int], list[int]] | None = None def __str__(self) -> str: return self._text @@ -1194,38 +1203,18 @@ def render_segments( ] return segments - def divide(self, offsets: Sequence[int]) -> list[Content]: - """Divide the content at the given offsets. - - This will cut the content in to pieces, and return those pieces. Note that the number of pieces - return will be one greater than the number of cuts. - - Args: - offsets: Sequence of offsets (in characters) of where to apply the cuts. - - Returns: - List of Content instances which combined would be equal to the whole. - """ - if not offsets: - return [self] - - offsets = sorted(offsets) - - text = self.plain - text_length = len(text) - divide_offsets = [0, *offsets, text_length] - line_ranges = list(zip(divide_offsets, divide_offsets[1:])) - - new_lines = [Content(text[start:end]) for start, end in line_ranges] - - if not self._spans: - return new_lines + def _divide_spans(self, offsets: tuple[int, ...]) -> list[tuple[Span, int, int]]: + if self._divide_cache is None: + self._divide_cache = FIFOCache(4) + if (cached_result := self._divide_cache.get(offsets)) is not None: + return cached_result - _line_appends = [line._spans.append for line in new_lines] + line_ranges = list(zip(offsets, offsets[1:])) + text_length = len(self.plain) line_count = len(line_ranges) - _Span = Span - - for span_start, span_end, style in self._spans: + span_ranges: list[tuple[Span, int, int]] = [] + for span in self._spans: + span_start, span_end, _style = span if span_start >= text_length: continue span_end = min(text_length, span_end) @@ -1259,7 +1248,62 @@ def divide(self, offsets: Sequence[int]) -> list[Content]: break end_line_no = (lower_bound + upper_bound) // 2 - for line_no in range(start_line_no, end_line_no + 1): + span_ranges.append((span, start_line_no, end_line_no + 1)) + self._divide_cache[offsets] = span_ranges + return span_ranges + + def divide(self, offsets: Sequence[int]) -> list[Content]: + """Divide the content at the given offsets. + + This will cut the content in to pieces, and return those pieces. Note that the number of pieces + return will be one greater than the number of cuts. + + Args: + offsets: Sequence of offsets (in characters) of where to apply the cuts. + + Returns: + List of Content instances which combined would be equal to the whole. + """ + if not offsets: + return [self] + + offsets = sorted(offsets) + text = self.plain + divide_offsets = tuple([0, *offsets, len(text)]) + line_ranges = list(zip(divide_offsets, divide_offsets[1:])) + + if self._line_length_cache is None: + self._line_length_cache = FIFOCache(4) + + line_text = [text[start:end] for start, end in line_ranges] + + if ( + cache_line_lengths := self._line_length_cache.get(divide_offsets) + ) is not None: + line_lengths = cache_line_lengths + else: + + line_lengths = self._line_length_cache[divide_offsets] = [ + cell_len(line) for line in line_text + ] + + new_lines = [ + Content(line, None, line_length) + for line, line_length in zip(line_text, line_lengths) + ] + + if not self._spans: + return new_lines + + _line_appends = [line._spans.append for line in new_lines] + _Span = Span + + for ( + (span_start, span_end, style), + start_line, + end_line, + ) in self._divide_spans(divide_offsets): + for line_no in range(start_line, end_line): line_start, line_end = line_ranges[line_no] new_start = max(0, span_start - line_start) new_end = min(span_end - line_start, line_end - line_start) @@ -1268,6 +1312,7 @@ def divide(self, offsets: Sequence[int]) -> list[Content]: return new_lines + @timer("split") def split( self, separator: str = "\n", @@ -1286,11 +1331,16 @@ def split( List[Content]: A list of Content, one per line of the original. """ assert separator, "separator must not be empty" - text = self.plain if separator not in text: return [self] + cache_key = (separator, include_separator, allow_blank) + if self._split_cache is None: + self._split_cache = FIFOCache(4) + if (cached_result := self._split_cache.get(cache_key)) is not None: + return cached_result.copy() + if include_separator: lines = self.divide( [match.end() for match in re.finditer(re.escape(separator), text)], @@ -1310,6 +1360,7 @@ def flatten_spans() -> Iterable[int]: if not allow_blank and text.endswith(separator): lines.pop() + self._split_cache[cache_key] = lines return lines def rstrip(self, chars: str | None = None) -> Content: From 81b87971f6a0a596798e3fd92a53fb43da36439c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Jul 2025 09:17:05 +0100 Subject: [PATCH 2/3] remove timer --- src/textual/content.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/textual/content.py b/src/textual/content.py index af74bd1504..9684e5fb0f 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -27,7 +27,6 @@ from textual._cells import cell_len from textual._context import active_app from textual._loop import loop_last -from textual._profile import timer from textual.cache import FIFOCache from textual.color import Color from textual.css.types import TextAlign, TextOverflow @@ -1312,7 +1311,6 @@ def divide(self, offsets: Sequence[int]) -> list[Content]: return new_lines - @timer("split") def split( self, separator: str = "\n", From 7003d8b76388e15ba3044c0cbf3fcd9070d495cc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Jul 2025 09:25:25 +0100 Subject: [PATCH 3/3] line length cache --- src/textual/content.py | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/textual/content.py b/src/textual/content.py index 9684e5fb0f..63454b2335 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -147,7 +147,6 @@ def __init__( self._split_cache: FIFOCache[tuple[str, bool, bool], list[Content]] | None = ( None ) - self._line_length_cache: FIFOCache[Sequence[int], list[int]] | None = None def __str__(self) -> str: return self._text @@ -1203,6 +1202,14 @@ def render_segments( return segments def _divide_spans(self, offsets: tuple[int, ...]) -> list[tuple[Span, int, int]]: + """Divide content from a list of offset to cut. + + Args: + offsets: A tuple of indices in to the text. + + Returns: + A list of tuples containing Spans and their line offsets. + """ if self._divide_cache is None: self._divide_cache = FIFOCache(4) if (cached_result := self._divide_cache.get(offsets)) is not None: @@ -1270,26 +1277,8 @@ def divide(self, offsets: Sequence[int]) -> list[Content]: text = self.plain divide_offsets = tuple([0, *offsets, len(text)]) line_ranges = list(zip(divide_offsets, divide_offsets[1:])) - - if self._line_length_cache is None: - self._line_length_cache = FIFOCache(4) - line_text = [text[start:end] for start, end in line_ranges] - - if ( - cache_line_lengths := self._line_length_cache.get(divide_offsets) - ) is not None: - line_lengths = cache_line_lengths - else: - - line_lengths = self._line_length_cache[divide_offsets] = [ - cell_len(line) for line in line_text - ] - - new_lines = [ - Content(line, None, line_length) - for line, line_length in zip(line_text, line_lengths) - ] + new_lines = [Content(line, None) for line in line_text] if not self._spans: return new_lines