From b079299c63096c07b428b2a2bd6511c72c7a1cc8 Mon Sep 17 00:00:00 2001 From: Paul Ollis Date: Tue, 11 Mar 2025 17:01:56 +0000 Subject: [PATCH 1/2] Make syntax highlight generation scale better. The TextArea widget code queries the entires syntax tree for each edit, using the tree-sitter Query.captures method. This has potential scaling issues, but such issues are exacerbated by the fact the Query.captures method scales very poorly with the number of line it is asked to generate captures for. It appears to be quadratic or something similar I think - I strongly suspect a bug in tree-sitter or its python bindings. On my laptop, this makes editing a 25,000 line Python file painfully unresponsive. A 25,000 lines Python file is large, but not entirely unreasonable. I actually became aware of this behaviour developing a LaTeX editor, which showed symptoms after about 1,000 lines. This change replaces the plain TextArea._highlights dictionary with a dictonary-like class that lazily performs item access to build hilghlight information for small blocks of (50) lines at a time. As well as keeping the TextArea very much more responsive, it will reduce the average memory requirements for larger documents. During regression testing, I discovered that the per-line highlights are not necessarily in the correct (best) order for syntax highlighting. For example, highlighting within string expressions can lost. So I added suitable sorting. This required that the snapshots for some tests to be updated. --- CHANGELOG.md | 11 + .../document/_syntax_aware_document.py | 42 +- src/textual/widgets/_text_area.py | 136 +++++-- ...test_text_area_language_rendering[css].svg | 320 +++++++-------- ...xt_area_language_rendering[javascript].svg | 362 ++++++++--------- ...text_area_language_rendering[markdown].svg | 317 ++++++++------- ...t_text_area_language_rendering[python].svg | 366 +++++++++--------- ...test_text_area_language_rendering[xml].svg | 120 +++--- tests/text_area/test_languages.py | 4 +- 9 files changed, 894 insertions(+), 784 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2423a1258..888ce59308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added Widget.preflight_checks to perform some debug checks after a widget is instantiated, to catch common errors. https://github.com/Textualize/textual/pull/5588 +### Fixed + +- Fixed TextArea's syntax highlighting. Some highlighting details were not being + applied. For example, in CSS, the text 'padding: 10px 0;' was shown in a + single colour. Now the 'px' appears in a different colour to the rest of the + text. + +- Fixed a cause of slow editing for syntax highlighed TextArea widgets with + large documents. + + ## [2.1.2] - 2025-02-26 ### Fixed diff --git a/src/textual/document/_syntax_aware_document.py b/src/textual/document/_syntax_aware_document.py index 162d3fbd54..9288e63fa2 100644 --- a/src/textual/document/_syntax_aware_document.py +++ b/src/textual/document/_syntax_aware_document.py @@ -1,5 +1,8 @@ from __future__ import annotations +from contextlib import contextmanager +from typing import ContextManager + try: from tree_sitter import Language, Node, Parser, Query, Tree @@ -12,6 +15,35 @@ from textual.document._document import Document, EditResult, Location, _utf8_encode +@contextmanager +def temporary_query_point_range( + query: Query, + start_point: tuple[int, int] | None, + end_point: tuple[int, int] | None, +) -> ContextManager[None]: + """Temporarily change the start and/or end point for a tree-sitter Query. + + Args: + query: The tree-sitter Query. + start_point: The (row, column byte) to start the query at. + end_point: The (row, column byte) to end the query at. + """ + # Note: Although not documented for the tree-sitter Python API, an + # end-point of (0, 0) means 'end of document'. + default_point_range = [(0, 0), (0, 0)] + + point_range = list(default_point_range) + if start_point is not None: + point_range[0] = start_point + if end_point is not None: + point_range[1] = end_point + query.set_point_range(point_range) + try: + yield None + finally: + query.set_point_range(default_point_range) + + class SyntaxAwareDocumentError(Exception): """General error raised when SyntaxAwareDocument is used incorrectly.""" @@ -128,14 +160,8 @@ def query_syntax_tree( "tree-sitter is not available on this architecture." ) - captures_kwargs = {} - if start_point is not None: - captures_kwargs["start_point"] = start_point - if end_point is not None: - captures_kwargs["end_point"] = end_point - - captures = query.captures(self._syntax_tree.root_node, **captures_kwargs) - return captures + with temporary_query_point_range(query, start_point, end_point): + return query.captures(self._syntax_tree.root_node) def replace_range(self, start: Location, end: Location, text: str) -> EditResult: """Replace text at the given range. diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 687ef8107d..7f01093598 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -70,6 +70,105 @@ class LanguageDoesNotExist(Exception): """ +class HighlightMap: + """Lazy evaluated pseudo dictionary mapping lines to highlight information. + + This allows TextArea syntax highlighting to scale. + + Args: + text_area_widget: The associated `TextArea` widget. + """ + + BLOCK_SIZE = 50 + + def __init__(self, text_area_widget: widgets.TextArea): + self.text_area_widget: widgets.TextArea = text_area_widget + self.uncovered_lines: dict[int, range] = {} + + # A mapping from line index to a list of Highlight instances. + self._highlights: LineToHighlightsMap = defaultdict(list) + self.reset() + + def reset(self) -> None: + """Reset so that future lookups rebuild the highlight map.""" + self._highlights.clear() + line_count = self.document.line_count + uncovered_lines = self.uncovered_lines + uncovered_lines.clear() + i = end_range = 0 + for i in range(0, line_count, self.BLOCK_SIZE): + end_range = min(i + self.BLOCK_SIZE, line_count) + line_range = range(i, end_range) + uncovered_lines.update({j: line_range for j in line_range}) + if end_range < line_count: + line_range = range(i, line_count) + uncovered_lines.update({j: line_range for j in line_range}) + + @property + def document(self) -> DocumentBase: + """The text document being highlighted.""" + return self.text_area_widget.document + + def __getitem__(self, idx: int) -> list[text_area.Highlight]: + if idx in self.uncovered_lines: + self._build_part_of_highlight_map(self.uncovered_lines[idx]) + return self._highlights[idx] + + def _build_part_of_highlight_map(self, line_range: range) -> None: + """Build part of the highlight map.""" + highlights = self._highlights + for line_index in line_range: + self.uncovered_lines.pop(line_index) + start_point = (line_range[0], 0) + end_point = (line_range[-1] + 1, 0) + captures = self.document.query_syntax_tree( + self.text_area_widget._highlight_query, + start_point=start_point, + end_point=end_point, + ) + for highlight_name, nodes in captures.items(): + for node in nodes: + node_start_row, node_start_column = node.start_point + node_end_row, node_end_column = node.end_point + if node_start_row == node_end_row: + highlight = node_start_column, node_end_column, highlight_name + highlights[node_start_row].append(highlight) + else: + # Add the first line of the node range + highlights[node_start_row].append( + (node_start_column, None, highlight_name) + ) + + # Add the middle lines - entire row of this node is highlighted + for node_row in range(node_start_row + 1, node_end_row): + highlights[node_row].append((0, None, highlight_name)) + + # Add the last line of the node range + highlights[node_end_row].append( + (0, node_end_column, highlight_name) + ) + + # The highlights for each line need to be sorted. Each highlight is of + # the form: + # + # a, b, highlight-name + # + # Where a is a number and b is a number or ``None``. These highlights need + # to be sorted in ascending order of ``a``. When two highlights have the same + # value of ``a`` then the one with the larger a--b range comes first, with ``None`` + # being considered larger than any number. + def sort_key(hl) -> tuple[int, int, int]: + a, b, _ = hl + max_range_ind = 1 + if b is None: + max_range_ind = 0 + b = a + return a, max_range_ind, a - b + + for line_index in line_range: + line_highlights = highlights.get(line_index, []).sort(key=sort_key) + + @dataclass class TextAreaLanguage: """A container for a language which has been registered with the TextArea. @@ -456,15 +555,15 @@ def __init__( cursor is currently at. If the cursor is at a bracket, or there's no matching bracket, this will be `None`.""" - self._highlights: dict[int, list[Highlight]] = defaultdict(list) - """Mapping line numbers to the set of highlights for that line.""" - self._highlight_query: "Query | None" = None """The query that's currently being used for highlighting.""" self.document: DocumentBase = Document(text) """The document this widget is currently editing.""" + self._highlights: HighlightMap = HighlightMap(self) + """Mapping line numbers to the set of highlights for that line.""" + self.wrapped_document: WrappedDocument = WrappedDocument(self.document) """The wrapped view of the document.""" @@ -593,35 +692,10 @@ def check_consume_key(self, key: str, character: str | None = None) -> bool: return character is not None and character.isprintable() def _build_highlight_map(self) -> None: - """Query the tree for ranges to highlights, and update the internal highlights mapping.""" - highlights = self._highlights - highlights.clear() - if not self._highlight_query: - return - - captures = self.document.query_syntax_tree(self._highlight_query) - for highlight_name, nodes in captures.items(): - for node in nodes: - node_start_row, node_start_column = node.start_point - node_end_row, node_end_column = node.end_point - - if node_start_row == node_end_row: - highlight = (node_start_column, node_end_column, highlight_name) - highlights[node_start_row].append(highlight) - else: - # Add the first line of the node range - highlights[node_start_row].append( - (node_start_column, None, highlight_name) - ) + """Reset the lazily evaluated highlight map.""" - # Add the middle lines - entire row of this node is highlighted - for node_row in range(node_start_row + 1, node_end_row): - highlights[node_row].append((0, None, highlight_name)) - - # Add the last line of the node range - highlights[node_end_row].append( - (0, node_end_column, highlight_name) - ) + if self._highlight_query: + self._highlights.reset() def _watch_has_focus(self, focus: bool) -> None: self._cursor_visible = focus diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[css].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[css].svg index 44ac0c0411..a49c38524e 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[css].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[css].svg @@ -19,330 +19,330 @@ font-weight: 700; } - .terminal-2526263208-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2526263208-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2526263208-r1 { fill: #121212 } -.terminal-2526263208-r2 { fill: #0178d4 } -.terminal-2526263208-r3 { fill: #c5c8c6 } -.terminal-2526263208-r4 { fill: #c2c2bf } -.terminal-2526263208-r5 { fill: #272822 } -.terminal-2526263208-r6 { fill: #75715e } -.terminal-2526263208-r7 { fill: #f8f8f2 } -.terminal-2526263208-r8 { fill: #90908a } -.terminal-2526263208-r9 { fill: #a6e22e } -.terminal-2526263208-r10 { fill: #ae81ff } -.terminal-2526263208-r11 { fill: #e6db74 } -.terminal-2526263208-r12 { fill: #f92672 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #c2c2bf } +.terminal-r5 { fill: #272822 } +.terminal-r6 { fill: #75715e } +.terminal-r7 { fill: #f8f8f2 } +.terminal-r8 { fill: #90908a } +.terminal-r9 { fill: #a6e22e } +.terminal-r10 { fill: #ae81ff } +.terminal-r11 { fill: #e6db74 } +.terminal-r12 { fill: #f92672 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1  /* This is a comment in CSS */ - 2   - 3  /* Basic selectors and properties */ - 4  body {                                                                   - 5  font-familyArialsans-serif;                                      - 6  background-color#f4f4f4;                                           - 7  margin0;                                                           - 8  padding0;                                                          - 9  }                                                                        -10   -11  /* Class and ID selectors */ -12  .header {                                                                -13  background-color#333;                                              -14  color#fff;                                                         -15  padding10px0;                                                     -16  text-aligncenter;                                                  -17  }                                                                        -18   -19  #logo {                                                                  -20  font-size24px;                                                     -21  font-weightbold;                                                   -22  }                                                                        -23   -24  /* Descendant and child selectors */ -25  .navul {                                                                -26  list-style-typenone;                                               -27  padding0;                                                          -28  }                                                                        -29   -30  .nav > li {                                                              -31  displayinline-block;                                               -32  margin-right10px;                                                  -33  }                                                                        -34   -35  /* Pseudo-classes */ -36  a:hover {                                                                -37  text-decorationunderline;                                          -38  }                                                                        -39   -40  input:focus {                                                            -41  border-color#007BFF;                                               -42  }                                                                        -43   -44  /* Media query */ -45  @media (max-width768px) {                                              -46  body {                                                               -47  font-size16px;                                                 -48      }                                                                    -49   -50      .header {                                                            -51  padding5px0;                                                  -52      }                                                                    -53  }                                                                        -54   -55  /* Keyframes animation */ -56  @keyframes slideIn {                                                     -57  from {                                                               -58  transformtranslateX(-100%);                                    -59      }                                                                    -60  to {                                                                 -61  transformtranslateX(0);                                        -62      }                                                                    -63  }                                                                        -64   -65  .slide-in-element {                                                      -66  animationslideIn0.5sforwards;                                    -67  }                                                                        -68   - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  /* This is a comment in CSS */ + 2   + 3  /* Basic selectors and properties */ + 4  body {                                                                   + 5  font-familyArialsans-serif;                                      + 6  background-color: #f4f4f4;                                           + 7  margin0;                                                           + 8  padding0;                                                          + 9  }                                                                        +10   +11  /* Class and ID selectors */ +12  .header {                                                                +13  background-color: #333;                                              +14  color: #fff;                                                         +15  padding10px0;                                                     +16  text-aligncenter;                                                  +17  }                                                                        +18   +19  #logo {                                                                  +20  font-size24px;                                                     +21  font-weightbold;                                                   +22  }                                                                        +23   +24  /* Descendant and child selectors */ +25  .navul {                                                                +26  list-style-typenone;                                               +27  padding0;                                                          +28  }                                                                        +29   +30  .nav > li {                                                              +31  displayinline-block;                                               +32  margin-right10px;                                                  +33  }                                                                        +34   +35  /* Pseudo-classes */ +36  a:hover {                                                                +37  text-decorationunderline;                                          +38  }                                                                        +39   +40  input:focus {                                                            +41  border-color: #007BFF;                                               +42  }                                                                        +43   +44  /* Media query */ +45  @media (max-width768px) {                                              +46  body {                                                               +47  font-size16px;                                                 +48      }                                                                    +49   +50      .header {                                                            +51  padding5px0;                                                  +52      }                                                                    +53  }                                                                        +54   +55  /* Keyframes animation */ +56  @keyframes slideIn {                                                     +57  from {                                                               +58  transformtranslateX(-100%);                                    +59      }                                                                    +60  to {                                                                 +61  transformtranslateX(0);                                        +62      }                                                                    +63  }                                                                        +64   +65  .slide-in-element {                                                      +66  animationslideIn0.5sforwards;                                    +67  }                                                                        +68   + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[javascript].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[javascript].svg index 645ea326fa..77ae9609f9 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[javascript].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[javascript].svg @@ -19,371 +19,371 @@ font-weight: 700; } - .terminal-2506662657-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2506662657-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2506662657-r1 { fill: #121212 } -.terminal-2506662657-r2 { fill: #0178d4 } -.terminal-2506662657-r3 { fill: #c5c8c6 } -.terminal-2506662657-r4 { fill: #c2c2bf } -.terminal-2506662657-r5 { fill: #272822 } -.terminal-2506662657-r6 { fill: #75715e } -.terminal-2506662657-r7 { fill: #f8f8f2 } -.terminal-2506662657-r8 { fill: #90908a } -.terminal-2506662657-r9 { fill: #f92672 } -.terminal-2506662657-r10 { fill: #e6db74 } -.terminal-2506662657-r11 { fill: #ae81ff } -.terminal-2506662657-r12 { fill: #66d9ef;font-style: italic; } -.terminal-2506662657-r13 { fill: #a6e22e } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #c2c2bf } +.terminal-r5 { fill: #272822 } +.terminal-r6 { fill: #75715e } +.terminal-r7 { fill: #f8f8f2 } +.terminal-r8 { fill: #90908a } +.terminal-r9 { fill: #f92672 } +.terminal-r10 { fill: #e6db74 } +.terminal-r11 { fill: #ae81ff } +.terminal-r12 { fill: #66d9ef;font-style: italic; } +.terminal-r13 { fill: #a6e22e } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1  // Variable declarations - 2  const name = "John";                                                     - 3  let age = 30;                                                            - 4  var isStudent = true;                                                    - 5   - 6  // Template literals - 7  console.log(`Hello, ${name}! You are ${age} years old.`);                - 8   - 9  // Conditional statements -10  if (age >= 18 && isStudent) {                                            -11    console.log("You are an adult student.");                              -12  elseif (age >= 18) {                                                  -13    console.log("You are an adult.");                                      -14  else {                                                                 -15    console.log("You are a minor.");                                       -16  }                                                                        -17   -18  // Arrays and array methods -19  const numbers = [12345];                                         -20  const doubledNumbers = numbers.map((num) => num * 2);                    -21  console.log("Doubled numbers:", doubledNumbers);                         -22   -23  // Objects -24  const person = {                                                         -25    firstName: "John",                                                     -26    lastName: "Doe",                                                       -27    getFullName() {                                                        -28  return`${this.firstName}${this.lastName}`;                         -29    },                                                                     -30  };                                                                       -31  console.log("Full name:", person.getFullName());                         -32   -33  // Classes -34  class Rectangle {                                                        -35    constructor(width, height) {                                           -36      this.width = width;                                                  -37      this.height = height;                                                -38    }                                                                      -39   -40    getArea() {                                                            -41  return this.width * this.height;                                     -42    }                                                                      -43  }                                                                        -44  const rectangle = new Rectangle(53);                                   -45  console.log("Rectangle area:", rectangle.getArea());                     -46   -47  // Async/Await and Promises -48  asyncfunctionfetchData() {                                             -49  try {                                                                  -50  const response = awaitfetch("https://api.example.com/data");        -51  const data = await response.json();                                  -52      console.log("Fetched data:", data);                                  -53    } catch (error) {                                                      -54      console.error("Error:", error);                                      -55    }                                                                      -56  }                                                                        -57  fetchData();                                                             -58   -59  // Arrow functions -60  constgreet = (name) => {                                                -61    console.log(`Hello, ${name}!`);                                        -62  };                                                                       -63  greet("Alice");                                                          -64   -65  // Destructuring assignment -66  const [a, b, ...rest] = [12345];                                 -67  console.log(a, b, rest);                                                 -68   -69  // Spread operator -70  const arr1 = [123];                                                  -71  const arr2 = [456];                                                  -72  const combinedArr = [...arr1, ...arr2];                                  -73  console.log("Combined array:", combinedArr);                             -74   -75  // Ternary operator -76  const message = age >= 18 ? "You are an adult." : "You are a minor.";    -77  console.log(message);                                                    -78   - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  // Variable declarations + 2  const name = "John";                                                     + 3  let age = 30;                                                            + 4  var isStudent = true;                                                    + 5   + 6  // Template literals + 7  console.log(`Hello, ${name}! You are ${age} years old.`);                + 8   + 9  // Conditional statements +10  if (age >= 18 && isStudent) {                                            +11    console.log("You are an adult student.");                              +12  elseif (age >= 18) {                                                  +13    console.log("You are an adult.");                                      +14  else {                                                                 +15    console.log("You are a minor.");                                       +16  }                                                                        +17   +18  // Arrays and array methods +19  const numbers = [12345];                                         +20  const doubledNumbers = numbers.map((num) => num * 2);                    +21  console.log("Doubled numbers:", doubledNumbers);                         +22   +23  // Objects +24  const person = {                                                         +25    firstName: "John",                                                     +26    lastName: "Doe",                                                       +27    getFullName() {                                                        +28  return`${this.firstName}${this.lastName}`;                         +29    },                                                                     +30  };                                                                       +31  console.log("Full name:", person.getFullName());                         +32   +33  // Classes +34  class Rectangle {                                                        +35    constructor(width, height) {                                           +36      this.width = width;                                                  +37      this.height = height;                                                +38    }                                                                      +39   +40    getArea() {                                                            +41  return this.width * this.height;                                     +42    }                                                                      +43  }                                                                        +44  const rectangle = new Rectangle(53);                                   +45  console.log("Rectangle area:", rectangle.getArea());                     +46   +47  // Async/Await and Promises +48  asyncfunctionfetchData() {                                             +49  try {                                                                  +50  const response = awaitfetch("https://api.example.com/data");        +51  const data = await response.json();                                  +52      console.log("Fetched data:", data);                                  +53    } catch (error) {                                                      +54      console.error("Error:", error);                                      +55    }                                                                      +56  }                                                                        +57  fetchData();                                                             +58   +59  // Arrow functions +60  constgreet = (name) => {                                                +61    console.log(`Hello, ${name}!`);                                        +62  };                                                                       +63  greet("Alice");                                                          +64   +65  // Destructuring assignment +66  const [a, b, ...rest] = [12345];                                 +67  console.log(a, b, rest);                                                 +68   +69  // Spread operator +70  const arr1 = [123];                                                  +71  const arr2 = [456];                                                  +72  const combinedArr = [...arr1, ...arr2];                                  +73  console.log("Combined array:", combinedArr);                             +74   +75  // Ternary operator +76  const message = age >= 18 ? "You are an adult." : "You are a minor.";    +77  console.log(message);                                                    +78   + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[markdown].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[markdown].svg index 5cf7309fde..95053a32f0 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[markdown].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[markdown].svg @@ -19,329 +19,328 @@ font-weight: 700; } - .terminal-1784849415-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1784849415-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1784849415-r1 { fill: #121212 } -.terminal-1784849415-r2 { fill: #0178d4 } -.terminal-1784849415-r3 { fill: #c5c8c6 } -.terminal-1784849415-r4 { fill: #c2c2bf } -.terminal-1784849415-r5 { fill: #272822;font-weight: bold } -.terminal-1784849415-r6 { fill: #f92672;font-weight: bold } -.terminal-1784849415-r7 { fill: #f8f8f2 } -.terminal-1784849415-r8 { fill: #90908a } -.terminal-1784849415-r9 { fill: #90908a;font-weight: bold } -.terminal-1784849415-r10 { fill: #272822 } -.terminal-1784849415-r11 { fill: #003054 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #c2c2bf } +.terminal-r5 { fill: #272822;font-weight: bold } +.terminal-r6 { fill: #f92672;font-weight: bold } +.terminal-r7 { fill: #f8f8f2 } +.terminal-r8 { fill: #90908a } +.terminal-r9 { fill: #272822 } +.terminal-r10 { fill: #003054 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1  Heading  - 2  ======= - 3   - 4  Sub-heading  - 5  ----------- - 6   - 7  ###Heading - 8   - 9  ####H4 Heading -10   -11  #####H5 Heading -12   -13  ######H6 Heading -14   -15   -16  Paragraphs are separated                                                 -17  by a blank line.                                                         -18   -19  Two spaces at the end of a line                                          -20  produces a line break.                                                   -21   -22  Text attributes _italic_,                                                -23  **bold**, `monospace`.                                                   -24   -25  Horizontal rule:                                                         -26   -27  ---  -28    -29  Bullet list:                                                             -30   -31    * apples                                                               -32  oranges                                                              -33  pears                                                                -34   -35  Numbered list:                                                           -36   -37    1. lather                                                              -38  2. rinse                                                               -39  3. repeat                                                              -40   -41  An [example](http://example.com).                                        -42   -43  > Markdown uses email-style > characters for blockquoting.               -44  >                                                                        -45  > Lorem ipsum                                                            -46   -47  ![progress](https://github.com/textualize/rich/raw/master/imgs/progress. -48   -49   -50  ```                                                                      -51  a=1                                                                      -52  ```                                                                      -53   -54  ```python                                                                -55  import this                                                              -56  ```                                                                      -57   -58  ```somelang                                                              -59  foobar                                                                   -60  ```                                                                      -61   -62      import this                                                          -63   -64   -65  1. List item                                                             -66   -67         Code block                                                        -68   - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  Heading  + 2  =======  + 3   + 4  Sub-heading  + 5  -----------  + 6   + 7  ###Heading + 8   + 9  ####H4 Heading +10   +11  #####H5 Heading +12   +13  ######H6 Heading +14   +15   +16  Paragraphs are separated                                                 +17  by a blank line.                                                         +18   +19  Two spaces at the end of a line                                          +20  produces a line break.                                                   +21   +22  Text attributes _italic_,                                                +23  **bold**, `monospace`.                                                   +24   +25  Horizontal rule:                                                         +26   +27  ---  +28    +29  Bullet list:                                                             +30   +31    * apples                                                               +32  oranges                                                              +33  pears                                                                +34   +35  Numbered list:                                                           +36   +37    1. lather                                                              +38  2. rinse                                                               +39  3. repeat                                                              +40   +41  An [example](http://example.com).                                        +42   +43  > Markdown uses email-style > characters for blockquoting.               +44  >                                                                        +45  > Lorem ipsum                                                            +46   +47  ![progress](https://github.com/textualize/rich/raw/master/imgs/progress. +48   +49   +50  ```                                                                      +51  a=1                                                                      +52  ```                                                                      +53   +54  ```python                                                                +55  import this                                                              +56  ```                                                                      +57   +58  ```somelang                                                              +59  foobar                                                                   +60  ```                                                                      +61   +62      import this                                                          +63   +64   +65  1. List item                                                             +66   +67         Code block                                                        +68   + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[python].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[python].svg index 92ffdc54f9..dcc7124808 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[python].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[python].svg @@ -19,375 +19,375 @@ font-weight: 700; } - .terminal-202856356-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-202856356-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-202856356-r1 { fill: #121212 } -.terminal-202856356-r2 { fill: #0178d4 } -.terminal-202856356-r3 { fill: #c5c8c6 } -.terminal-202856356-r4 { fill: #c2c2bf } -.terminal-202856356-r5 { fill: #272822 } -.terminal-202856356-r6 { fill: #f92672 } -.terminal-202856356-r7 { fill: #f8f8f2 } -.terminal-202856356-r8 { fill: #90908a } -.terminal-202856356-r9 { fill: #75715e } -.terminal-202856356-r10 { fill: #e6db74 } -.terminal-202856356-r11 { fill: #ae81ff } -.terminal-202856356-r12 { fill: #a6e22e } -.terminal-202856356-r13 { fill: #003054 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #c2c2bf } +.terminal-r5 { fill: #272822 } +.terminal-r6 { fill: #f92672 } +.terminal-r7 { fill: #f8f8f2 } +.terminal-r8 { fill: #90908a } +.terminal-r9 { fill: #75715e } +.terminal-r10 { fill: #e6db74 } +.terminal-r11 { fill: #ae81ff } +.terminal-r12 { fill: #a6e22e } +.terminal-r13 { fill: #003054 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1  import math                                                              - 2  from os import path                                                      - 3   - 4  # I'm a comment :) - 5   - 6  string_var = "Hello, world!" - 7  int_var = 42 - 8  float_var = 3.14 - 9  complex_var = 1 + 2j -10   -11  list_var = [12345]                                               -12  tuple_var = (12345)                                              -13  set_var = {12345}                                                -14  dict_var = {"a"1"b"2"c"3}                                      -15   -16  deffunction_no_args():                                                  -17  return"No arguments" -18   -19  deffunction_with_args(a, b):                                            -20  return a + b                                                         -21   -22  deffunction_with_default_args(a=0, b=0):                                -23  return a * b                                                         -24   -25  lambda_func = lambda x: x**2 -26   -27  if int_var == 42:                                                        -28  print("It's the answer!")                                            -29  elif int_var < 42:                                                       -30  print("Less than the answer.")                                       -31  else:                                                                    -32  print("Greater than the answer.")                                    -33   -34  for index, value inenumerate(list_var):                                 -35  print(f"Index: {index}, Value: {value}")                             -36   -37  counter = 0 -38  while counter < 5:                                                       -39  print(f"Counter value: {counter}")                                   -40      counter += 1 -41   -42  squared_numbers = [x**2for x inrange(10if x % 2 == 0]                -43   -44  try:                                                                     -45      result = 10 / 0 -46  except ZeroDivisionError:                                                -47  print("Cannot divide by zero!")                                      -48  finally:                                                                 -49  print("End of try-except block.")                                    -50   -51  classAnimal:                                                            -52  def__init__(self, name):                                            -53          self.name = name                                                 -54   -55  defspeak(self):                                                     -56  raiseNotImplementedError("Subclasses must implement this method -57   -58  classDog(Animal):                                                       -59  defspeak(self):                                                     -60  returnf"{self.name} says Woof!" -61   -62  deffibonacci(n):                                                        -63      a, b = 01 -64  for _ inrange(n):                                                   -65  yield a                                                          -66          a, b = b, a + b                                                  -67   -68  for num infibonacci(5):                                                 -69  print(num)                                                           -70   -71  withopen('test.txt''w'as f:                                         -72      f.write("Testing with statement.")                                   -73   -74  @my_decorator                                                            -75  defsay_hello():                                                         -76  print("Hello!")                                                      -77   -78  say_hello()                                                              -79   - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  import math                                                              + 2  from os import path                                                      + 3   + 4  # I'm a comment :) + 5   + 6  string_var = "Hello, world!" + 7  int_var = 42 + 8  float_var = 3.14 + 9  complex_var = 1 + 2j +10   +11  list_var = [12345]                                               +12  tuple_var = (12345)                                              +13  set_var = {12345}                                                +14  dict_var = {"a"1"b"2"c"3}                                      +15   +16  deffunction_no_args():                                                  +17  return"No arguments" +18   +19  deffunction_with_args(a, b):                                            +20  return a + b                                                         +21   +22  deffunction_with_default_args(a=0, b=0):                                +23  return a * b                                                         +24   +25  lambda_func = lambda x: x**2 +26   +27  if int_var == 42:                                                        +28  print("It's the answer!")                                            +29  elif int_var < 42:                                                       +30  print("Less than the answer.")                                       +31  else:                                                                    +32  print("Greater than the answer.")                                    +33   +34  for index, value inenumerate(list_var):                                 +35  print(f"Index: {index}, Value: {value}")                             +36   +37  counter = 0 +38  while counter < 5:                                                       +39  print(f"Counter value: {counter}")                                   +40      counter += 1 +41   +42  squared_numbers = [x**2for x inrange(10if x % 2 == 0]                +43   +44  try:                                                                     +45      result = 10 / 0 +46  except ZeroDivisionError:                                                +47  print("Cannot divide by zero!")                                      +48  finally:                                                                 +49  print("End of try-except block.")                                    +50   +51  classAnimal:                                                            +52  def__init__(self, name):                                            +53          self.name = name                                                 +54   +55  defspeak(self):                                                     +56  raiseNotImplementedError("Subclasses must implement this method +57   +58  classDog(Animal):                                                       +59  defspeak(self):                                                     +60  returnf"{self.name} says Woof!" +61   +62  deffibonacci(n):                                                        +63      a, b = 01 +64  for _ inrange(n):                                                   +65  yield a                                                          +66          a, b = b, a + b                                                  +67   +68  for num infibonacci(5):                                                 +69  print(num)                                                           +70   +71  withopen('test.txt''w'as f:                                         +72      f.write("Testing with statement.")                                   +73   +74  @my_decorator                                                            +75  defsay_hello():                                                         +76  print("Hello!")                                                      +77   +78  say_hello()                                                              +79   + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[xml].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[xml].svg index 7d9ce1aeb3..31f74b433e 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[xml].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_language_rendering[xml].svg @@ -19,130 +19,130 @@ font-weight: 700; } - .terminal-1843935949-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1843935949-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1843935949-r1 { fill: #121212 } -.terminal-1843935949-r2 { fill: #0178d4 } -.terminal-1843935949-r3 { fill: #c5c8c6 } -.terminal-1843935949-r4 { fill: #c2c2bf } -.terminal-1843935949-r5 { fill: #272822 } -.terminal-1843935949-r6 { fill: #f8f8f2 } -.terminal-1843935949-r7 { fill: #f92672 } -.terminal-1843935949-r8 { fill: #ae81ff } -.terminal-1843935949-r9 { fill: #90908a } -.terminal-1843935949-r10 { fill: #75715e } -.terminal-1843935949-r11 { fill: #e6db74 } -.terminal-1843935949-r12 { fill: #003054 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #c2c2bf } +.terminal-r5 { fill: #272822 } +.terminal-r6 { fill: #f8f8f2 } +.terminal-r7 { fill: #f92672 } +.terminal-r8 { fill: #ae81ff } +.terminal-r9 { fill: #90908a } +.terminal-r10 { fill: #75715e } +.terminal-r11 { fill: #e6db74 } +.terminal-r12 { fill: #003054 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1  <?xml version="1.0" encoding="UTF-8"?>                                   - 2  <!-- This is an example XML document --> - 3  <library>                                                                - 4      <book id="1" genre="fiction">                                        - 5          <title>The Great Gatsby</title>                                  - 6          <author>F. Scott Fitzgerald</author>                             - 7          <published>1925</published>                                      - 8          <description><![CDATA[This classic novel explores themes of weal - 9      </book>                                                              -10      <book id="2" genre="non-fiction">                                    -11          <title>Sapiens: A Brief History of Humankind</title>             -12          <author>Yuval Noah Harari</author>                               -13          <published>2011</published>                                      -14          <description><![CDATA[Explores the history and impact of Homo sa -15      </book>                                                              -16  <!-- Another book can be added here --> -17  </library>                                                               -18   - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  <?xml version="1.0" encoding="UTF-8"?>                                   + 2  <!-- This is an example XML document --> + 3  <library>                                                                + 4      <book id="1" genre="fiction">                                        + 5          <title>The Great Gatsby</title>                                  + 6          <author>F. Scott Fitzgerald</author>                             + 7          <published>1925</published>                                      + 8          <description><![CDATA[This classic novel explores themes of weal + 9      </book>                                                              +10      <book id="2" genre="non-fiction">                                    +11          <title>Sapiens: A Brief History of Humankind</title>             +12          <author>Yuval Noah Harari</author>                               +13          <published>2011</published>                                      +14          <description><![CDATA[Explores the history and impact of Homo sa +15      </book>                                                              +16  <!-- Another book can be added here --> +17  </library>                                                               +18   + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/text_area/test_languages.py b/tests/text_area/test_languages.py index f5f381354f..d1bdc54de6 100644 --- a/tests/text_area/test_languages.py +++ b/tests/text_area/test_languages.py @@ -86,10 +86,10 @@ async def test_update_highlight_query(): text_area = app.query_one(TextArea) # Before registering the language, we have highlights as expected. - assert len(text_area._highlights) > 0 + assert len(text_area._highlights[0]) > 0 # Overwriting the highlight query for Python... text_area.update_highlight_query("python", "") # We've overridden the highlight query with a blank one, so there are no highlights. - assert text_area._highlights == {} + assert len(text_area._highlights[0]) == 0 From 799246557dd18ba9a78fd75765cede614a7a61e6 Mon Sep 17 00:00:00 2001 From: Paul Ollis Date: Wed, 12 Mar 2025 21:32:37 +0000 Subject: [PATCH 2/2] Update with suggestions by Darren Burns. --- src/textual/widgets/_text_area.py | 84 +++++++++++++++---------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 7f01093598..9f58388a14 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -81,48 +81,47 @@ class HighlightMap: BLOCK_SIZE = 50 - def __init__(self, text_area_widget: widgets.TextArea): - self.text_area_widget: widgets.TextArea = text_area_widget - self.uncovered_lines: dict[int, range] = {} + def __init__(self, text_area: TextArea): + self.text_area: TextArea = text_area + """The text area associated with this highlight map.""" - # A mapping from line index to a list of Highlight instances. - self._highlights: LineToHighlightsMap = defaultdict(list) - self.reset() + self._highlighted_blocks: set[int] = set() + """The set of blocks that have been highlighted. Each block covers BLOCK_SIZE + lines. + """ + + self._highlights: dict[int, list[Highlight]] = defaultdict(list) + """A mapping from line index to a list of Highlight instances.""" def reset(self) -> None: """Reset so that future lookups rebuild the highlight map.""" self._highlights.clear() - line_count = self.document.line_count - uncovered_lines = self.uncovered_lines - uncovered_lines.clear() - i = end_range = 0 - for i in range(0, line_count, self.BLOCK_SIZE): - end_range = min(i + self.BLOCK_SIZE, line_count) - line_range = range(i, end_range) - uncovered_lines.update({j: line_range for j in line_range}) - if end_range < line_count: - line_range = range(i, line_count) - uncovered_lines.update({j: line_range for j in line_range}) + self._highlighted_blocks.clear() @property def document(self) -> DocumentBase: """The text document being highlighted.""" - return self.text_area_widget.document + return self.text_area.document + + def __getitem__(self, index: int) -> list[Highlight]: + block_index = index // self.BLOCK_SIZE + if block_index not in self._highlighted_blocks: + self._highlighted_blocks.add(block_index) + self._build_part_of_highlight_map(block_index * self.BLOCK_SIZE) + return self._highlights[index] - def __getitem__(self, idx: int) -> list[text_area.Highlight]: - if idx in self.uncovered_lines: - self._build_part_of_highlight_map(self.uncovered_lines[idx]) - return self._highlights[idx] + def _build_part_of_highlight_map(self, start_index: int) -> None: + """Build part of the highlight map. - def _build_part_of_highlight_map(self, line_range: range) -> None: - """Build part of the highlight map.""" + Args: + start_index: The start of the block of line for which to build the map. + """ highlights = self._highlights - for line_index in line_range: - self.uncovered_lines.pop(line_index) - start_point = (line_range[0], 0) - end_point = (line_range[-1] + 1, 0) + start_point = (start_index, 0) + end_index = min(self.document.line_count, start_index + self.BLOCK_SIZE) + end_point = (end_index, 0) captures = self.document.query_syntax_tree( - self.text_area_widget._highlight_query, + self.text_area._highlight_query, start_point=start_point, end_point=end_point, ) @@ -140,8 +139,9 @@ def _build_part_of_highlight_map(self, line_range: range) -> None: ) # Add the middle lines - entire row of this node is highlighted + middle_highlight = (0, None, highlight_name) for node_row in range(node_start_row + 1, node_end_row): - highlights[node_row].append((0, None, highlight_name)) + highlights[node_row].append(middle_highlight) # Add the last line of the node range highlights[node_end_row].append( @@ -157,16 +157,16 @@ def _build_part_of_highlight_map(self, line_range: range) -> None: # to be sorted in ascending order of ``a``. When two highlights have the same # value of ``a`` then the one with the larger a--b range comes first, with ``None`` # being considered larger than any number. - def sort_key(hl) -> tuple[int, int, int]: - a, b, _ = hl - max_range_ind = 1 + def sort_key(highlight: Highlight) -> tuple[int, int, int]: + a, b, _ = highlight + max_range_index = 1 if b is None: - max_range_ind = 0 + max_range_index = 0 b = a - return a, max_range_ind, a - b + return a, max_range_index, a - b - for line_index in line_range: - line_highlights = highlights.get(line_index, []).sort(key=sort_key) + for line_index in range(start_index, end_index): + highlights.get(line_index, []).sort(key=sort_key) @dataclass @@ -691,7 +691,7 @@ def check_consume_key(self, key: str, character: str | None = None) -> bool: # Otherwise we capture all printable keys return character is not None and character.isprintable() - def _build_highlight_map(self) -> None: + def _reset_highlights(self) -> None: """Reset the lazily evaluated highlight map.""" if self._highlight_query: @@ -1009,7 +1009,7 @@ def _set_document(self, text: str, language: str | None) -> None: self.document = document self.wrapped_document = WrappedDocument(document, tab_width=self.indent_width) self.navigator = DocumentNavigator(self.wrapped_document) - self._build_highlight_map() + self._reset_highlights() self.move_cursor((0, 0)) self._rewrap_and_refresh_virtual_size() @@ -1422,7 +1422,7 @@ def edit(self, edit: Edit) -> EditResult: self._refresh_size() edit.after(self) - self._build_highlight_map() + self._reset_highlights() self.post_message(self.Changed(self)) return result @@ -1485,7 +1485,7 @@ def _undo_batch(self, edits: Sequence[Edit]) -> None: self._refresh_size() for edit in reversed(edits): edit.after(self) - self._build_highlight_map() + self._reset_highlights() self.post_message(self.Changed(self)) def _redo_batch(self, edits: Sequence[Edit]) -> None: @@ -1533,7 +1533,7 @@ def _redo_batch(self, edits: Sequence[Edit]) -> None: self._refresh_size() for edit in edits: edit.after(self) - self._build_highlight_map() + self._reset_highlights() self.post_message(self.Changed(self)) async def _on_key(self, event: events.Key) -> None: