diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 01a5639af..7eca7607d 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -55,4 +55,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: snapshot-report-textual - path: tests/snapshot_tests/output/snapshot_report.html + path: snapshot_report.html diff --git a/docs/blog/images/text-area-learnings/cursor_position_updating_via_api.png b/docs/blog/images/text-area-learnings/cursor_position_updating_via_api.png new file mode 100644 index 000000000..c10f78dc8 Binary files /dev/null and b/docs/blog/images/text-area-learnings/cursor_position_updating_via_api.png differ diff --git a/docs/blog/images/text-area-learnings/maintain_offset.gif b/docs/blog/images/text-area-learnings/maintain_offset.gif new file mode 100644 index 000000000..d39bca5e0 Binary files /dev/null and b/docs/blog/images/text-area-learnings/maintain_offset.gif differ diff --git a/docs/blog/images/text-area-learnings/text-area-api-insert.gif b/docs/blog/images/text-area-learnings/text-area-api-insert.gif new file mode 100644 index 000000000..529eb01e3 Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-api-insert.gif differ diff --git a/docs/blog/images/text-area-learnings/text-area-pyinstrument.png b/docs/blog/images/text-area-learnings/text-area-pyinstrument.png new file mode 100644 index 000000000..2a8cc3609 Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-pyinstrument.png differ diff --git a/docs/blog/images/text-area-learnings/text-area-syntax-error.gif b/docs/blog/images/text-area-learnings/text-area-syntax-error.gif new file mode 100644 index 000000000..0a74cb649 Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-syntax-error.gif differ diff --git a/docs/blog/images/text-area-learnings/text-area-theme-cycle.gif b/docs/blog/images/text-area-learnings/text-area-theme-cycle.gif new file mode 100644 index 000000000..c73e9dd9e Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-theme-cycle.gif differ diff --git a/docs/blog/images/text-area-learnings/text-area-welcome.gif b/docs/blog/images/text-area-learnings/text-area-welcome.gif new file mode 100644 index 000000000..baaf821ed Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-welcome.gif differ diff --git a/docs/blog/posts/text-area-learnings.md b/docs/blog/posts/text-area-learnings.md new file mode 100644 index 000000000..d55a6b96e --- /dev/null +++ b/docs/blog/posts/text-area-learnings.md @@ -0,0 +1,210 @@ +--- +draft: false +date: 2023-09-18 +categories: + - DevLog +authors: + - darrenburns +--- + +# Things I learned while building Textual's TextArea + +`TextArea` is the latest widget to be added to Textual's [growing collection](https://textual.textualize.io/widget_gallery/). +It provides a multi-line space to edit text, and features optional syntax highlighting for a selection of languages. + +![text-area-welcome.gif](../images/text-area-learnings/text-area-welcome.gif) + +Adding a `TextArea` to your Textual app is as simple as adding this to your `compose` method: + +```python +yield TextArea() +``` + +Enabling syntax highlighting for a language is as simple as: + +```python +yield TextArea(language="python") +``` + +Working on the `TextArea` widget for Textual taught me a lot about Python and my general +approach to software engineering. It gave me an appreciation for the subtle functionality behind +the editors we use on a daily basis — features we may not even notice, despite +some engineer spending hours perfecting it to provide a small boost to our development experience. + +This post is a tour of some of these learnings. + + + +## Vertical cursor movement is more than just `cursor_row++` + +When you move the cursor vertically, you can't simply keep the same column index and clamp it within the line. +Editors should maintain the visual column offset where possible, +meaning they must account for double-width emoji (sigh 😔) and East-Asian characters. + +![maintain_offset.gif](../images/text-area-learnings/maintain_offset.gif){ loading=lazy } + +Notice that although the cursor is on column 11 while on line 1, it lands on column 6 when it +arrives at line 3. +This is because the 6th character of line 3 _visually_ aligns with the 11th character of line 1. + + +## Edits from other sources may move my cursor + +There are two ways to interact with the `TextArea`: + +1. You can type into it. +2. You can make API calls to edit the content in it. + +In the example below, `Hello, world!\n` is repeatedly inserted at the start of the document via the +API. +Notice that this updates the location of my cursor, ensuring that I don't lose my place. + +![text-area-api-insert.gif](../images/text-area-learnings/text-area-api-insert.gif){ loading=lazy } + +This subtle feature should aid those implementing collaborative and multi-cursor editing. + +This turned out to be one of the more complex features of the whole project, and went through several iterations before I was happy with the result. + +Thankfully it resulted in some wonderful Tetris-esque whiteboards along the way! + +
+ ![cursor_position_updating_via_api.png](../images/text-area-learnings/cursor_position_updating_via_api.png){ loading=lazy } +
A TetrisArea white-boarding session.
+
+ +Sometimes stepping away from the screen and scribbling on a whiteboard with your colleagues (thanks [Dave](https://fosstodon.org/@davep)!) is what's needed to finally crack a tough problem. + +Many thanks to [David Brochart](https://mastodon.top/@davidbrochart) for sending me down this rabbit hole! + +## Spending a few minutes running a profiler can be really beneficial + +While building the `TextArea` widget I avoided heavy optimisation work that may have affected +readability or maintainability. + +However, I did run a profiler in an attempt to detect flawed assumptions or mistakes which were +affecting the performance of my code. + +I spent around 30 minutes profiling `TextArea` +using [pyinstrument](https://pyinstrument.readthedocs.io/en/latest/home.html), and the result was a +**~97%** reduction in the time taken to handle a key press. +What an amazing return on investment for such a minimal time commitment! + + +
+ ![text-area-pyinstrument.png](../images/text-area-learnings/text-area-pyinstrument.png){ loading=lazy } +
"pyinstrument -r html" produces this beautiful output.
+
+ +pyinstrument unveiled two issues that were massively impacting performance. + +### 1. Reparsing highlighting queries on each key press + +I was constructing a tree-sitter `Query` object on each key press, incorrectly assuming it was a +low-overhead call. +This query was completely static, so I moved it into the constructor ensuring the object was created +only once. +This reduced key processing time by around 94% - a substantial and very much noticeable improvement. + +This seems obvious in hindsight, but the code in question was written earlier in the project and had +been relegated in my mind to "code that works correctly and will receive less attention from here on +out". +pyinstrument quickly brought this code back to my attention and highlighted it as a glaring +performance bug. + +### 2. NamedTuples are slower than I expected + +In Python, `NamedTuple`s are slow to create relative to `tuple`s, and this cost was adding up inside +an extremely hot loop which was instantiating a large number of them. +pyinstrument revealed that a large portion of the time during syntax highlighting was spent inside `NamedTuple.__new__`. + +Here's a quick benchmark which constructs 10,000 `NamedTuple`s: + +```toml +❯ hyperfine -w 2 'python sandbox/darren/make_namedtuples.py' +Benchmark 1: python sandbox/darren/make_namedtuples.py + Time (mean ± σ): 15.9 ms ± 0.5 ms [User: 12.8 ms, System: 2.5 ms] + Range (min … max): 15.2 ms … 18.4 ms 165 runs +``` + +Here's the same benchmark using `tuple` instead: + +```toml +❯ hyperfine -w 2 'python sandbox/darren/make_tuples.py' +Benchmark 1: python sandbox/darren/make_tuples.py + Time (mean ± σ): 9.3 ms ± 0.5 ms [User: 6.8 ms, System: 2.0 ms] + Range (min … max): 8.7 ms … 12.3 ms 256 runs +``` + +Switching to `tuple` resulted in another noticeable increase in responsiveness. +Key-press handling time dropped by almost 50%! +Unfortunately, this change _does_ impact readability. +However, the scope in which these tuples were used was very small, and so I felt it was a worthy trade-off. + + +## Syntax highlighting is very different from what I expected + +In order to support syntax highlighting, we make use of +the [tree-sitter](https://tree-sitter.github.io/tree-sitter/) library, which maintains a syntax tree +representing the structure of our document. + +To perform highlighting, we follow these steps: + +1. The user edits the document. +2. We inform tree-sitter of the location of this edit. +3. tree-sitter intelligently parses only the subset of the document impacted by the change, updating the tree. +4. We run a query against the tree to retrieve ranges of text we wish to highlight. +5. These ranges are mapped to styles (defined by the chosen "theme"). +6. These styles to the appropriate text ranges when rendering the widget. + +
+ ![text-area-theme-cycle.gif](../images/text-area-learnings/text-area-theme-cycle.gif){ loading=lazy } +
Cycling through a few of the builtin themes.
+
+ +Another benefit that I didn't consider before working on this project is that tree-sitter +parsers can also be used to highlight syntax errors in a document. +This can be useful in some situations - for example, highlighting mismatched HTML closing tags: + +
+ ![text-area-syntax-error.gif](../images/text-area-learnings/text-area-syntax-error.gif){ loading=lazy } +
Highlighting mismatched closing HTML tags in red.
+
+ +Before building this widget, I was oblivious as to how we might approach syntax highlighting. +Without tree-sitter's incremental parsing approach, I'm not sure reasonable performance would have +been feasible. + +## Edits are replacements + +All single-cursor edits can be distilled into a single behaviour: `replace_range`. +This replaces a range of characters with some text. +We can use this one method to easily implement deletion, insertion, and replacement of text. + +- Inserting text is replacing a zero-width range with the text to insert. +- Pressing backspace (delete left) is just replacing the character behind the cursor with an empty + string. +- Selecting text and pressing delete is just replacing the selected text with an empty string. +- Selecting text and pasting is replacing the selected text with some other text. + +This greatly simplified my initial approach, which involved unique implementations for inserting and +deleting. + + +## The line between "text area" and "VSCode in the terminal" + +A project like this has no clear finish line. +There are always new features, optimisations, and refactors waiting to be made. + +So where do we draw the line? + +We want to provide a widget which can act as both a basic multiline text area that +anyone can drop into their app, yet powerful and extensible enough to act as the foundation +for a Textual-powered text editor. + +Yet, the more features we add, the more opinionated the widget becomes, and the less that users +will feel like they can build it into their _own_ thing. +Finding the sweet spot between feature-rich and flexible is no easy task. + +I don't think the answer is clear, and I don't believe it's possible to please everyone. + +Regardless, I'm happy with where we've landed, and I'm really excited to see what people build using `TextArea` in the future! diff --git a/docs/examples/widgets/horizontal_rules.py b/docs/examples/widgets/horizontal_rules.py index 2327e474e..643f129bb 100644 --- a/docs/examples/widgets/horizontal_rules.py +++ b/docs/examples/widgets/horizontal_rules.py @@ -1,6 +1,6 @@ from textual.app import App, ComposeResult -from textual.widgets import Rule, Label from textual.containers import Vertical +from textual.widgets import Label, Rule class HorizontalRulesApp(App): diff --git a/docs/examples/widgets/java_highlights.scm b/docs/examples/widgets/java_highlights.scm new file mode 100644 index 000000000..b6259be12 --- /dev/null +++ b/docs/examples/widgets/java_highlights.scm @@ -0,0 +1,140 @@ +; Methods + +(method_declaration + name: (identifier) @function.method) +(method_invocation + name: (identifier) @function.method) +(super) @function.builtin + +; Annotations + +(annotation + name: (identifier) @attribute) +(marker_annotation + name: (identifier) @attribute) + +"@" @operator + +; Types + +(type_identifier) @type + +(interface_declaration + name: (identifier) @type) +(class_declaration + name: (identifier) @type) +(enum_declaration + name: (identifier) @type) + +((field_access + object: (identifier) @type) + (#match? @type "^[A-Z]")) +((scoped_identifier + scope: (identifier) @type) + (#match? @type "^[A-Z]")) +((method_invocation + object: (identifier) @type) + (#match? @type "^[A-Z]")) +((method_reference + . (identifier) @type) + (#match? @type "^[A-Z]")) + +(constructor_declaration + name: (identifier) @type) + +[ + (boolean_type) + (integral_type) + (floating_point_type) + (floating_point_type) + (void_type) +] @type.builtin + +; Variables + +((identifier) @constant + (#match? @constant "^_*[A-Z][A-Z\\d_]+$")) + +(identifier) @variable + +(this) @variable.builtin + +; Literals + +[ + (hex_integer_literal) + (decimal_integer_literal) + (octal_integer_literal) + (decimal_floating_point_literal) + (hex_floating_point_literal) +] @number + +[ + (character_literal) + (string_literal) +] @string + +[ + (true) + (false) + (null_literal) +] @constant.builtin + +[ + (line_comment) + (block_comment) +] @comment + +; Keywords + +[ + "abstract" + "assert" + "break" + "case" + "catch" + "class" + "continue" + "default" + "do" + "else" + "enum" + "exports" + "extends" + "final" + "finally" + "for" + "if" + "implements" + "import" + "instanceof" + "interface" + "module" + "native" + "new" + "non-sealed" + "open" + "opens" + "package" + "private" + "protected" + "provides" + "public" + "requires" + "return" + "sealed" + "static" + "strictfp" + "switch" + "synchronized" + "throw" + "throws" + "to" + "transient" + "transitive" + "try" + "uses" + "volatile" + "while" + "with" +] @keyword diff --git a/docs/examples/widgets/text_area_custom_language.py b/docs/examples/widgets/text_area_custom_language.py new file mode 100644 index 000000000..70ee7e16b --- /dev/null +++ b/docs/examples/widgets/text_area_custom_language.py @@ -0,0 +1,34 @@ +from pathlib import Path + +from tree_sitter_languages import get_language + +from textual.app import App, ComposeResult +from textual.widgets import TextArea + +java_language = get_language("java") +java_highlight_query = (Path(__file__).parent / "java_highlights.scm").read_text() +java_code = """\ +class HelloWorld { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} +""" + + +class TextAreaCustomLanguage(App): + def compose(self) -> ComposeResult: + text_area = TextArea(text=java_code) + text_area.cursor_blink = False + + # Register the Java language and highlight query + text_area.register_language(java_language, java_highlight_query) + + # Switch to Java + text_area.language = "java" + yield text_area + + +app = TextAreaCustomLanguage() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/text_area_custom_theme.py b/docs/examples/widgets/text_area_custom_theme.py new file mode 100644 index 000000000..c2c81a115 --- /dev/null +++ b/docs/examples/widgets/text_area_custom_theme.py @@ -0,0 +1,42 @@ +from rich.style import Style + +from textual._text_area_theme import TextAreaTheme +from textual.app import App, ComposeResult +from textual.widgets import TextArea + +TEXT = """\ +# says hello +def hello(name): + print("hello" + name) + +# says goodbye +def goodbye(name): + print("goodbye" + name) +""" + +MY_THEME = TextAreaTheme( + # This name will be used to refer to the theme... + name="my_cool_theme", + # Basic styles such as background, cursor, selection, gutter, etc... + cursor_style=Style(color="white", bgcolor="blue"), + cursor_line_style=Style(bgcolor="yellow"), + # `syntax_styles` maps tokens parsed from the document to Rich styles. + syntax_styles={ + "string": Style(color="red"), + "comment": Style(color="magenta"), + }, +) + + +class TextAreaCustomThemes(App): + def compose(self) -> ComposeResult: + text_area = TextArea(TEXT, language="python") + text_area.cursor_blink = False + text_area.register_theme(MY_THEME) + text_area.theme = "my_cool_theme" + yield text_area + + +app = TextAreaCustomThemes() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/text_area_example.py b/docs/examples/widgets/text_area_example.py new file mode 100644 index 000000000..2e0e31c06 --- /dev/null +++ b/docs/examples/widgets/text_area_example.py @@ -0,0 +1,20 @@ +from textual.app import App, ComposeResult +from textual.widgets import TextArea + +TEXT = """\ +def hello(name): + print("hello" + name) + +def goodbye(name): + print("goodbye" + name) +""" + + +class TextAreaExample(App): + def compose(self) -> ComposeResult: + yield TextArea(TEXT, language="python") + + +app = TextAreaExample() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/text_area_extended.py b/docs/examples/widgets/text_area_extended.py new file mode 100644 index 000000000..8ac237db8 --- /dev/null +++ b/docs/examples/widgets/text_area_extended.py @@ -0,0 +1,23 @@ +from textual import events +from textual.app import App, ComposeResult +from textual.widgets import TextArea + + +class ExtendedTextArea(TextArea): + """A subclass of TextArea with parenthesis-closing functionality.""" + + def _on_key(self, event: events.Key) -> None: + if event.character == "(": + self.insert("()") + self.move_cursor_relative(columns=-1) + event.prevent_default() + + +class TextAreaKeyPressHook(App): + def compose(self) -> ComposeResult: + yield ExtendedTextArea(language="python") + + +app = TextAreaKeyPressHook() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/text_area_selection.py b/docs/examples/widgets/text_area_selection.py new file mode 100644 index 000000000..4165eb2d2 --- /dev/null +++ b/docs/examples/widgets/text_area_selection.py @@ -0,0 +1,23 @@ +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import Selection + +TEXT = """\ +def hello(name): + print("hello" + name) + +def goodbye(name): + print("goodbye" + name) +""" + + +class TextAreaSelection(App): + def compose(self) -> ComposeResult: + text_area = TextArea(TEXT, language="python") + text_area.selection = Selection(start=(0, 0), end=(2, 0)) # (1)! + yield text_area + + +app = TextAreaSelection() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/vertical_rules.py b/docs/examples/widgets/vertical_rules.py index 27592bef8..500104530 100644 --- a/docs/examples/widgets/vertical_rules.py +++ b/docs/examples/widgets/vertical_rules.py @@ -1,6 +1,6 @@ from textual.app import App, ComposeResult -from textual.widgets import Rule, Label from textual.containers import Horizontal +from textual.widgets import Label, Rule class VerticalRulesApp(App): diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index 559fc6292..f0384f5a7 100644 --- a/docs/widget_gallery.md +++ b/docs/widget_gallery.md @@ -281,7 +281,7 @@ Displays simple static content. Typically used as a base class. ## Switch -A on / off control, inspired by toggle buttons. +An on / off control, inspired by toggle buttons. [Switch reference](./widgets/switch.md){ .md-button .md-button--primary } @@ -307,6 +307,14 @@ A Combination of Tabs and ContentSwitcher to navigate static content. ```{.textual path="docs/examples/widgets/tabbed_content.py" press="j"} ``` +## TextArea + +A multi-line text area which supports syntax highlighting various languages. + +[TextArea reference](./widgets/text_area.md){ .md-button .md-button--primary } + +```{.textual path="docs/examples/widgets/text_area.py" columns="42" lines="8"} +``` ## Tree diff --git a/docs/widgets/_template.md b/docs/widgets/_template.md index ecedff151..70c6a4338 100644 --- a/docs/widgets/_template.md +++ b/docs/widgets/_template.md @@ -32,6 +32,7 @@ Example app showing the widget: ## Reactive Attributes +## Messages ## Bindings diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md new file mode 100644 index 000000000..2fddae64e --- /dev/null +++ b/docs/widgets/text_area.md @@ -0,0 +1,467 @@ + +# TextArea + +!!! tip "Added in version 0.38.0" + +A widget for editing text which may span multiple lines. +Supports syntax highlighting for a selection of languages. + +- [x] Focusable +- [ ] Container + + +## Guide + +### Loading text + +In this example we load some initial text into the `TextArea`, and set the language to `"python"` to enable syntax highlighting. + +=== "Output" + + ```{.textual path="docs/examples/widgets/text_area_example.py" columns="42" lines="8"} + ``` + +=== "text_area_example.py" + + ```python + --8<-- "docs/examples/widgets/text_area_example.py" + ``` + +To load content into the `TextArea` after it has already been created, +use the [`load_text`][textual.widgets._text_area.TextArea.load_text] method. + +To update the parser used for syntax highlighting, set the [`language`][textual.widgets._text_area.TextArea.language] reactive attribute: + +```python +# Set the language to Markdown +text_area.language = "markdown" +``` + +!!! note + Syntax highlighting is unavailable on Python 3.7. + +!!! note + More built-in languages will be added in the future. For now, you can [add your own](#adding-support-for-custom-languages). + + +### Reading content from `TextArea` + +There are a number of ways to retrieve content from the `TextArea`: + +- The [`TextArea.text`][textual.widgets._text_area.TextArea.text] property returns all content in the text area as a string. +- The [`TextArea.selected_text`][textual.widgets._text_area.TextArea.selected_text] property returns the text corresponding to the current selection. +- The [`TextArea.get_text_range`][textual.widgets._text_area.TextArea.get_text_range] method returns the text between two locations. + +In all cases, when multiple lines of text are retrieved, the [document line separator](#line-separators) will be used. + +### Editing content inside `TextArea` + +The content of the `TextArea` can be updated using the [`replace`][textual.widgets._text_area.TextArea.replace] method. +This method is the programmatic equivalent of selecting some text and then pasting. + +Some other convenient methods are available, such as [`insert`][textual.widgets._text_area.TextArea.insert], [`delete`][textual.widgets._text_area.TextArea.delete], and [`clear`][textual.widgets._text_area.TextArea.clear]. + +### Working with the cursor + +#### Moving the cursor + +The cursor location is available via the [`cursor_location`][textual.widgets._text_area.TextArea.cursor_location] property, which represents +the location of the cursor as a tuple `(row_index, column_index)`. These indices are zero-based. +Writing a new value to `cursor_location` will immediately update the location of the cursor. + +```python +>>> text_area = TextArea() +>>> text_area.cursor_location +(0, 0) +>>> text_area.cursor_location = (0, 4) +>>> text_area.cursor_location +(0, 4) +``` + +`cursor_location` is a simple way to move the cursor programmatically, but it doesn't let us select text. + +#### Selecting text + +To select text, we can use the `selection` reactive attribute. +Let's select the first two lines of text in a document by adding `text_area.selection = Selection(start=(0, 0), end=(2, 0))` to our code: + +=== "Output" + + ```{.textual path="docs/examples/widgets/text_area_selection.py" columns="42" lines="8"} + ``` + +=== "text_area_selection.py" + + ```python hl_lines="17" + --8<-- "docs/examples/widgets/text_area_selection.py" + ``` + + 1. Selects the first two lines of text. + +Note that selections can happen in both directions, so `Selection((2, 0), (0, 0))` is also valid. + +!!! tip + + The `end` attribute of the `selection` is always equal to `TextArea.cursor_location`. In other words, + the `cursor_location` attribute is simply a convenience for accessing `text_area.selection.end`. + +#### More cursor utilities + +There are a number of additional utility methods available for interacting with the cursor. + +##### Location information + +A number of properties exist on `TextArea` which give information about the current cursor location. +These properties begin with `cursor_at_`, and return booleans. +For example, [`cursor_at_start_of_line`][textual.widgets._text_area.TextArea.cursor_at_start_of_line] tells us if the cursor is at a start of line. + +We can also check the location the cursor _would_ arrive at if we were to move it. +For example, [`get_cursor_right_location`][textual.widgets._text_area.TextArea.get_cursor_right_location] returns the location +the cursor would move to if it were to move right. +A number of similar methods exist, with names like `get_cursor_*_location`. + +##### Cursor movement methods + +The [`move_cursor`][textual.widgets._text_area.TextArea.move_cursor] method allows you to move the cursor to a new location while selecting +text, or move the cursor and scroll to keep it centered. + +```python +# Move the cursor from its current location to row index 4, +# column index 8, while selecting all the text between. +text_area.move_cursor((4, 8), select=True) +``` + +The [`move_cursor_relative`][textual.widgets._text_area.TextArea.move_cursor_relative] method offers a very similar interface, but moves the cursor relative +to its current location. + +##### Common selections + +There are some methods available which make common selections easier: + +- [`select_line`][textual.widgets._text_area.TextArea.select_line] selects a line by index. Bound to ++f6++ by default. +- [`select_all`][textual.widgets._text_area.TextArea.select_all] selects all text. Bound to ++f7++ by default. + +### Themes + +`TextArea` ships with some builtin themes, and you can easily add your own. + +Themes give you control over the look and feel, including syntax highlighting, +the cursor, selection, gutter, and more. + +#### Using builtin themes + +The initial theme of the `TextArea` is determined by the `theme` parameter. + +```python +# Create a TextArea with the 'dracula' theme. +yield TextArea("print(123)", language="python", theme="dracula") +``` + +You can check which themes are available using the [`available_themes`][textual.widgets._text_area.TextArea.available_themes] property. + +```python +>>> text_area = TextArea() +>>> print(text_area.available_themes) +{'dracula', 'github_light', 'monokai', 'vscode_dark'} +``` + +After creating a `TextArea`, you can change the theme by setting the [`theme`][textual.widgets._text_area.TextArea.theme] +attribute to one of the available themes. + +```python +text_area.theme = "vscode_dark" +``` + +On setting this attribute the `TextArea` will immediately refresh to display the updated theme. + +#### Custom themes + +Using custom (non-builtin) themes is two-step process: + +1. Create an instance of [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme]. +2. Register it using [`TextArea.register_theme`][textual.widgets._text_area.TextArea.register_theme]. + +##### 1. Creating a theme + +Let's create a simple theme, `"my_cool_theme"`, which colors the cursor blue, and the cursor line yellow. +Our theme will also syntax highlight strings as red, and comments as magenta. + +```python +from rich.style import Style +from textual.widgets.text_area import TextAreaTheme +# ... +my_theme = TextAreaTheme( + # This name will be used to refer to the theme... + name="my_cool_theme", + # Basic styles such as background, cursor, selection, gutter, etc... + cursor_style=Style(color="white", bgcolor="blue"), + cursor_line_style=Style(bgcolor="yellow"), + # `syntax_styles` is for syntax highlighting. + # It maps tokens parsed from the document to Rich styles. + syntax_styles={ + "string": Style(color="red"), + "comment": Style(color="magenta"), + } +) +``` + +Attributes like `cursor_style` and `cursor_line_style` apply general language-agnostic +styling to the widget. + +The `syntax_styles` attribute of `TextAreaTheme` is used for syntax highlighting and +depends on the `language` currently in use. +For more details, see [syntax highlighting](#syntax-highlighting). + +If you wish to build on an existing theme, you can obtain a reference to it using the [`TextAreaTheme.get_builtin_theme`][textual.widgets.text_area.TextAreaTheme.get_builtin_theme] classmethod: + +```python +from textual.widgets.text_area import TextAreaTheme + +monokai = TextAreaTheme.get_builtin_theme("monokai") +``` + +##### 2. Registering a theme + +Our theme can now be registered with the `TextArea` instance. + +```python +text_area.register_theme(my_theme) +``` + +After registering a theme, it'll appear in the `available_themes`: + +```python +>>> print(text_area.available_themes) +{'dracula', 'github_light', 'monokai', 'vscode_dark', 'my_cool_theme'} +``` + +We can now switch to it: + +```python +text_area.theme = "my_cool_theme" +``` + +This immediately updates the appearance of the `TextArea`: + +```{.textual path="docs/examples/widgets/text_area_custom_theme.py" columns="42" lines="8"} +``` + +### Indentation + +The character(s) inserted when you press tab is controlled by setting the `indent_type` attribute to either `tabs` or `spaces`. + +If `indent_type == "spaces"`, pressing ++tab++ will insert up to `indent_width` spaces in order to align with the next tab stop. + +### Line separators + +When content is loaded into `TextArea`, the content is scanned from beginning to end +and the first occurrence of a line separator is recorded. + +This separator will then be used when content is later read from the `TextArea` via +the `text` property. The `TextArea` widget does not support exporting text which +contains mixed line endings. + +Similarly, newline characters pasted into the `TextArea` will be converted. + +You can check the line separator of the current document by inspecting `TextArea.document.newline`: + +```python +>>> text_area = TextArea() +>>> text_area.document.newline +'\n' +``` + +### Line numbers + +The gutter (column on the left containing line numbers) can be toggled by setting +the `show_line_numbers` attribute to `True` or `False`. + +Setting this attribute will immediately repaint the `TextArea` to reflect the new value. + +### Extending `TextArea` + +Sometimes, you may wish to subclass `TextArea` to add some extra functionality. +In this section, we'll briefly explore how we can extend the widget to achieve common goals. + +#### Hooking into key presses + +You may wish to hook into certain key presses to inject some functionality. +This can be done by over-riding `_on_key` and adding the required functionality. + +##### Example - closing parentheses automatically + +Let's extend `TextArea` to add a feature which automatically closes parentheses and moves the cursor to a sensible location. + +```python +--8<-- "docs/examples/widgets/text_area_extended.py" +``` + +This intercepts the key handler when `"("` is pressed, and inserts `"()"` instead. +It then moves the cursor so that it lands between the open and closing parentheses. + +Typing `def hello(` into the `TextArea` results in the bracket automatically being closed: + +```{.textual path="docs/examples/widgets/text_area_extended.py" columns="36" lines="4" press="d,e,f,space,h,e,l,l,o,left_parenthesis"} +``` + +### Advanced concepts + +#### Syntax highlighting + +Syntax highlighting inside the `TextArea` is powered by a library called [`tree-sitter`](https://tree-sitter.github.io/tree-sitter/). + +Each time you update the document in a `TextArea`, an internal syntax tree is updated. +This tree is frequently _queried_ to find location ranges relevant to syntax highlighting. +We give these ranges _names_, and ultimately map them to Rich styles inside `TextAreaTheme.syntax_styles`. + +To illustrate how this works, lets look at how the "Monokai" `TextAreaTheme` highlights Markdown files. + +When the `language` attribute is set to `"markdown"`, a highlight query similar to the one below is used (trimmed for brevity). + +```scheme +(heading_content) @heading +(link) @link +``` + +This highlight query maps `heading_content` nodes returned by the Markdown parser to the name `@heading`, +and `link` nodes to the name `@link`. + +Inside our `TextAreaTheme.syntax_styles` dict, we can map the name `@heading` to a Rich style. +Here's a snippet from the "Monokai" theme which does just that: + +```python +TextAreaTheme( + name="monokai", + base_style=Style(color="#f8f8f2", bgcolor="#272822"), + gutter_style=Style(color="#90908a", bgcolor="#272822"), + # ... + syntax_styles={ + # Colorise @heading and make them bold + "heading": Style(color="#F92672", bold=True), + # Colorise and underline @link + "link": Style(color="#66D9EF", underline=True), + # ... + }, +) +``` + +To understand which names can be mapped inside `syntax_styles`, we recommend looking at the existing +themes and highlighting queries (`.scm` files) in the Textual repository. + +!!! tip + + You may also wish to take a look at the contents of `TextArea._highlights` on an + active `TextArea` instance to see which highlights have been generated for the + open document. + +#### Adding support for custom languages + +To add support for a language to a `TextArea`, use the [`register_language`][textual.widgets._text_area.TextArea.register_language] method. + +To register a language, we require two things: + +1. A tree-sitter `Language` object which contains the grammar for the language. +2. A highlight query which is used for [syntax highlighting](#syntax-highlighting). + +##### Example - adding Java support + +The easiest way to obtain a `Language` object is using the [`py-tree-sitter-languages`](https://github.com/grantjenks/py-tree-sitter-languages) package. Here's how we can use this package to obtain a reference to a `Language` object representing Java: + +```python +from tree_sitter_languages import get_language +java_language = get_language("java") +``` + +!!! note + + `py-tree-sitter-languages` may not be available on some architectures (e.g. Macbooks with Apple Silicon running Python 3.7). + +The exact version of the parser used when you call `get_language` can be checked via +the [`repos.txt` file](https://github.com/grantjenks/py-tree-sitter-languages/blob/a6d4f7c903bf647be1bdcfa504df967d13e40427/repos.txt) in +the version of `py-tree-sitter-languages` you're using. This file contains links to the GitHub +repos and commit hashes of the tree-sitter parsers. In these repos you can often find pre-made highlight queries at `queries/highlights.scm`, +and a file showing all the available node types which can be used in highlight queries at `src/node-types.json`. + +Since we're adding support for Java, lets grab the Java highlight query from the repo by following these steps: + +1. Open [`repos.txt` file](https://github.com/grantjenks/py-tree-sitter-languages/blob/a6d4f7c903bf647be1bdcfa504df967d13e40427/repos.txt) from the `py-tree-sitter-languages` repo. +2. Find the link corresponding to `tree-sitter-java` and go to the repo on GitHub (you may also need to go to the specific commit referenced in `repos.txt`). +3. Go to [`queries/highlights.scm`](https://github.com/tree-sitter/tree-sitter-java/blob/ac14b4b1884102839455d32543ab6d53ae089ab7/queries/highlights.scm) to see the example highlight query for Java. + +Be sure to check the license in the repo to ensure it can be freely copied. + +!!! warning + + It's important to use a highlight query which is compatible with the parser in use, so + pay attention to the commit hash when visiting the repo via `repos.txt`. + +We now have our `Language` and our highlight query, so we can register Java as a language. + +```python +--8<-- "docs/examples/widgets/text_area_custom_language.py" +``` + +Running our app, we can see that the Java code is highlighted. +We can freely edit the text, and the syntax highlighting will update immediately. + +```{.textual path="docs/examples/widgets/text_area_custom_language.py" columns="52" lines="8"} +``` + +Recall that we map names (like `@heading`) from the tree-sitter highlight query to Rich style objects inside the `TextAreaTheme.syntax_styles` dictionary. +If you notice some highlights are missing after registering a language, the issue may be: + +1. The current `TextAreaTheme` doesn't contain a mapping for the name in the highlight query. Adding a new to `syntax_styles` should resolve the issue. +2. The highlight query doesn't assign a name to the pattern you expect to be highlighted. In this case you'll need to update the highlight query to assign to the name. + +!!! tip + + The names assigned in tree-sitter highlight queries are often reused across multiple languages. + For example, `@string` is used in many languages to highlight strings. + +## Reactive attributes + +| Name | Type | Default | Description | +|------------------------|--------------------------|--------------------|--------------------------------------------------| +| `language` | `str | None` | `None` | The language to use for syntax highlighting. | +| `theme` | `str | None` | `TextAreaTheme.default()` | The theme to use for syntax highlighting. | +| `selection` | `Selection` | `Selection()` | The current selection. | +| `show_line_numbers` | `bool` | `True` | Show or hide line numbers. | +| `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. | + +## Bindings + +The `TextArea` widget defines the following bindings: + +::: textual.widgets._text_area.TextArea.BINDINGS + options: + show_root_heading: false + show_root_toc_entry: false + + +## Component classes + +The `TextArea` widget defines no component classes. + +Styling should be done exclusively via [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme]. + +## See also + +- [`Input`][textual.widgets.Input] - for single-line text input. +- [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme] - for theming the `TextArea`. +- The tree-sitter documentation [website](https://tree-sitter.github.io/tree-sitter/). +- The tree-sitter Python bindings [repository](https://github.com/tree-sitter/py-tree-sitter). +- `py-tree-sitter-languages` [repository](https://github.com/grantjenks/py-tree-sitter-languages) (provides binary wheels for a large variety of tree-sitter languages). + +--- + +::: textual.widgets._text_area.TextArea + options: + heading_level: 2 + +--- + +::: textual.widgets.text_area + options: + heading_level: 2 diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 66e3f2480..2d6106311 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -164,6 +164,7 @@ nav: - "widgets/switch.md" - "widgets/tabbed_content.md" - "widgets/tabs.md" + - "widgets/text_area.md" - "widgets/tree.md" - API: - "api/index.md" diff --git a/poetry.lock b/poetry.lock index 1d1ce00cb..fcd778a16 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,3 @@ -# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. - [[package]] name = "aiohttp" version = "3.8.5" @@ -7,7 +5,1196 @@ description = "Async http client/server framework (asyncio)" category = "dev" optional = false python-versions = ">=3.6" -files = [ + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = ">=4.0.0a3,<5.0" +asynctest = {version = "0.13.0", markers = "python_version < \"3.8\""} +attrs = ">=17.3.0" +charset-normalizer = ">=2.0,<4.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "cchardet"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "anyio" +version = "3.7.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} + +[[package]] +name = "asynctest" +version = "0.13.0" +description = "Enhance the standard unittest package with features for testing asyncio libraries" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "attrs" +version = "23.1.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] + +[[package]] +name = "babel" +version = "2.12.1" +description = "Internationalization utilities" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} + +[[package]] +name = "black" +version = "23.3.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cached-property" +version = "1.5.2" +description = "A decorator for caching properties in classes." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "certifi" +version = "2023.7.22" +description = "Python package for providing Mozilla's CA Bundle." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "cfgv" +version = "3.3.1" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[[package]] +name = "charset-normalizer" +version = "3.2.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.7.0" + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "colored" +version = "1.4.4" +description = "Simple library for color and formatting to terminal" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "coverage" +version = "7.2.7" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "distlib" +version = "0.3.7" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "exceptiongroup" +version = "1.1.3" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.12.2" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] + +[[package]] +name = "frozenlist" +version = "1.3.3" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "gitdb" +version = "4.0.10" +description = "Git Object Database" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.36" +description = "GitPython is a Python library used to interact with Git repositories" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +gitdb = ">=4.0.1,<5" +typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} + +[package.extras] +test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-sugar", "virtualenv"] + +[[package]] +name = "griffe" +version = "0.30.1" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +cached-property = {version = "*", markers = "python_version < \"3.8\""} +colorama = ">=0.4" + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "httpcore" +version = "0.16.3" +description = "A minimal low-level HTTP client." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +anyio = ">=3.0,<5.0" +certifi = "*" +h11 = ">=0.13,<0.15" +sniffio = ">=1.0.0,<2.0.0" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "httpx" +version = "0.23.3" +description = "The next generation HTTP client." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +certifi = "*" +httpcore = ">=0.15.0,<0.17.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "identify" +version = "2.5.24" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "importlib-metadata" +version = "6.7.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "linkify-it-py" +version = "2.0.2" +description = "Links recognition library with FULL unicode support." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +uc-micro-py = "*" + +[package.extras] +benchmark = ["pytest", "pytest-benchmark"] +dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] +doc = ["myst-parser", "sphinx", "sphinx-book-theme"] +test = ["coverage", "pytest", "pytest-cov"] + +[[package]] +name = "markdown" +version = "3.4.4" +description = "Python implementation of John Gruber's Markdown." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.0)", "mkdocs-nature (>=0.4)"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markdown-it-py" +version = "2.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} +mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} +mdurl = ">=0.1,<1.0" +typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mdit-py-plugins" +version = "0.3.5" +description = "Collection of plugins for markdown-it-py" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +markdown-it-py = ">=1.0.0,<3.0.0" + +[package.extras] +code-style = ["pre-commit"] +rtd = ["attrs", "myst-parser (>=0.16.1,<0.17.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mkdocs" +version = "1.5.3" +description = "Project documentation with Markdown." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} +jinja2 = ">=2.11.1" +markdown = ">=3.2.1" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +packaging = ">=20.5" +pathspec = ">=0.11.1" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""} +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pathspec (==0.11.1)", "platformdirs (==2.2.0)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-autorefs" +version = "0.4.1" +description = "Automatically link across pages in MkDocs." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +Markdown = ">=3.3" +mkdocs = ">=1.1" + +[[package]] +name = "mkdocs-exclude" +version = "1.0.2" +description = "A mkdocs plugin that lets you exclude files or trees." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +mkdocs = "*" + +[[package]] +name = "mkdocs-material" +version = "9.2.7" +description = "Documentation that simply works" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +babel = ">=2.10,<3.0" +colorama = ">=0.4,<1.0" +jinja2 = ">=3.0,<4.0" +markdown = ">=3.2,<4.0" +mkdocs = ">=1.5,<2.0" +mkdocs-material-extensions = ">=1.1,<2.0" +paginate = ">=0.5,<1.0" +pygments = ">=2.16,<3.0" +pymdown-extensions = ">=10.2,<11.0" +regex = ">=2022.4,<2023.0" +requests = ">=2.26,<3.0" + +[[package]] +name = "mkdocs-material-extensions" +version = "1.1.1" +description = "Extension pack for Python Markdown and MkDocs Material." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mkdocs-rss-plugin" +version = "1.5.0" +description = "MkDocs plugin which generates a static RSS feed using git log and page.meta." +category = "dev" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +GitPython = ">=3.1,<3.2" +mkdocs = ">=1.1,<2" +pytz = {version = ">=2022.0.0,<2023.0.0", markers = "python_version < \"3.9\""} +tzdata = {version = ">=2022.0.0,<2023.0.0", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} + +[package.extras] +dev = ["black", "feedparser (>=6.0,<6.1)", "flake8 (>=4,<5.1)", "pre-commit (>=2.10,<2.21)", "pytest-cov (>=4.0.0,<4.1.0)", "validator-collection (>=1.5,<1.6)"] +doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (>=0.5.0,<0.6.0)", "pygments (>=2.5,<3)", "pymdown-extensions (>=7,<10)"] + +[[package]] +name = "mkdocstrings" +version = "0.20.0" +description = "Automatic documentation from sources, for MkDocs." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +Jinja2 = ">=2.11.1" +Markdown = ">=3.3" +MarkupSafe = ">=1.1" +mkdocs = ">=1.2" +mkdocs-autorefs = ">=0.3.1" +mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} +pymdown-extensions = ">=6.3" + +[package.extras] +crystal = ["mkdocstrings-crystal (>=0.3.4)"] +python = ["mkdocstrings-python (>=0.5.2)"] +python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] + +[[package]] +name = "mkdocstrings-python" +version = "0.10.1" +description = "A Python handler for mkdocstrings." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +griffe = ">=0.24" +mkdocstrings = ">=0.20" + +[[package]] +name = "msgpack" +version = "1.0.5" +description = "MessagePack serializer" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "multidict" +version = "6.0.4" +description = "multidict implementation" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mypy" +version = "1.4.1" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "packaging" +version = "23.1" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "paginate" +version = "0.5.6" +description = "Divides large result sets into pages for easier browsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pathspec" +version = "0.11.2" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "platformdirs" +version = "3.10.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "pluggy" +version = "1.2.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "2.21.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pymdown-extensions" +version = "10.2.1" +description = "Extension pack for Python Markdown." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +markdown = ">=3.2" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.12)"] + +[[package]] +name = "pytest" +version = "7.4.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-aiohttp" +version = "1.0.5" +description = "Pytest plugin for aiohttp support" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +aiohttp = ">=3.8.1" +pytest = ">=6.1.0" +pytest-asyncio = ">=0.17.2" + +[package.extras] +testing = ["coverage (==6.2)", "mypy (==0.931)"] + +[[package]] +name = "pytest-asyncio" +version = "0.21.1" +description = "Pytest support for asyncio" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=7.0.0" +typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + +[[package]] +name = "pytest-cov" +version = "2.12.1" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +coverage = ">=5.2.1" +pytest = ">=4.6" +toml = "*" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-textual-snapshot" +version = "0.4.0" +description = "Snapshot testing for Textual apps" +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +jinja2 = ">=3.0.0" +pytest = ">=7.0.0" +rich = ">=12.0.0" +syrupy = ">=3.0.0" +textual = ">=0.28.0" + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2022.7.1" +description = "World timezone definitions, modern and historical" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "regex" +version = "2022.10.31" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} + +[package.extras] +idna2008 = ["idna"] + +[[package]] +name = "rich" +version = "13.5.3" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" +optional = false +python-versions = ">=3.7.0" + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "setuptools" +version = "68.0.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "smmap" +version = "5.0.1" +description = "A pure Python implementation of a sliding window memory map manager" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "syrupy" +version = "3.0.6" +description = "Pytest Snapshot Test Utility" +category = "dev" +optional = false +python-versions = ">=3.7,<4" + +[package.dependencies] +colored = ">=1.3.92,<2.0.0" +pytest = ">=5.1.0,<8.0.0" + +[[package]] +name = "textual-dev" +version = "1.1.0" +description = "Development tools for working with Textual" +category = "dev" +optional = false +python-versions = ">=3.7,<4.0" + +[package.dependencies] +aiohttp = ">=3.8.1" +click = ">=8.1.2" +msgpack = ">=1.0.3" +textual = ">=0.32.0" +typing-extensions = ">=4.4.0,<5.0.0" + +[[package]] +name = "time-machine" +version = "2.10.0" +description = "Travel through time in your tests." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +python-dateutil = "*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "tree-sitter" +version = "0.20.2" +description = "Python bindings for the Tree-Sitter parsing library" +category = "main" +optional = false +python-versions = ">=3.3" + +[[package]] +name = "tree-sitter-languages" +version = "1.7.0" +description = "Binary Python wheels for all tree sitter languages." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +tree-sitter = "*" + +[[package]] +name = "typed-ast" +version = "1.5.5" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "types-setuptools" +version = "67.8.0.0" +description = "Typing stubs for setuptools" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "types-tree-sitter" +version = "0.20.1.5" +description = "Typing stubs for tree-sitter" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "types-tree-sitter-languages" +version = "1.7.0.1" +description = "Typing stubs for tree-sitter-languages" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +types-tree-sitter = "*" + +[[package]] +name = "typing-extensions" +version = "4.7.1" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "tzdata" +version = "2022.7" +description = "Provider of IANA time zone data" +category = "dev" +optional = false +python-versions = ">=2" + +[[package]] +name = "uc-micro-py" +version = "1.0.2" +description = "Micro subset of unicode data files for linkify-it-py projects." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["coverage", "pytest", "pytest-cov"] + +[[package]] +name = "urllib3" +version = "2.0.5" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "virtualenv" +version = "20.24.5" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +importlib-metadata = {version = ">=6.6", markers = "python_version < \"3.8\""} +platformdirs = ">=3.9.1,<4" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "watchdog" +version = "3.0.0" +description = "Filesystem events monitoring" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "yarl" +version = "1.9.2" +description = "Yet another URL library" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + +[[package]] +name = "zipp" +version = "3.15.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.7" +content-hash = "c53cf8b109a11121625f7fb1037b22ff677dea740b70a4318edbd2829ea6080b" + +[metadata.files] +aiohttp = [ {file = "aiohttp-3.8.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a94159871304770da4dd371f4291b20cac04e8c94f11bdea1c3478e557fbe0d8"}, {file = "aiohttp-3.8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:13bf85afc99ce6f9ee3567b04501f18f9f8dbbb2ea11ed1a2e079670403a7c84"}, {file = "aiohttp-3.8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ce2ac5708501afc4847221a521f7e4b245abf5178cf5ddae9d5b3856ddb2f3a"}, @@ -96,131 +1283,31 @@ files = [ {file = "aiohttp-3.8.5-cp39-cp39-win_amd64.whl", hash = "sha256:c0a9034379a37ae42dea7ac1e048352d96286626251862e448933c0f59cbd79c"}, {file = "aiohttp-3.8.5.tar.gz", hash = "sha256:b9552ec52cc147dbf1944ac7ac98af7602e51ea2dcd076ed194ca3c0d1c7d0bc"}, ] - -[package.dependencies] -aiosignal = ">=1.1.2" -async-timeout = ">=4.0.0a3,<5.0" -asynctest = {version = "0.13.0", markers = "python_version < \"3.8\""} -attrs = ">=17.3.0" -charset-normalizer = ">=2.0,<4.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} -yarl = ">=1.0,<2.0" - -[package.extras] -speedups = ["Brotli", "aiodns", "cchardet"] - -[[package]] -name = "aiosignal" -version = "1.3.1" -description = "aiosignal: a list of registered asynchronous callbacks" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +aiosignal = [ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, ] - -[package.dependencies] -frozenlist = ">=1.1.0" - -[[package]] -name = "anyio" -version = "3.7.1" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +anyio = [ {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, ] - -[package.dependencies] -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} - -[package.extras] -doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] -test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (<0.22)"] - -[[package]] -name = "async-timeout" -version = "4.0.3" -description = "Timeout context manager for asyncio programs" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +async-timeout = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, ] - -[package.dependencies] -typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} - -[[package]] -name = "asynctest" -version = "0.13.0" -description = "Enhance the standard unittest package with features for testing asyncio libraries" -category = "dev" -optional = false -python-versions = ">=3.5" -files = [ +asynctest = [ {file = "asynctest-0.13.0-py3-none-any.whl", hash = "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676"}, {file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"}, ] - -[[package]] -name = "attrs" -version = "23.1.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +attrs = [ {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, ] - -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - -[package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] - -[[package]] -name = "babel" -version = "2.12.1" -description = "Internationalization utilities" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +babel = [ {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, ] - -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - -[[package]] -name = "black" -version = "23.3.0" -description = "The uncompromising code formatter." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +black = [ {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, @@ -247,67 +1334,19 @@ files = [ {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, ] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} -typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "cached-property" -version = "1.5.2" -description = "A decorator for caching properties in classes." -category = "dev" -optional = false -python-versions = "*" -files = [ +cached-property = [ {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, ] - -[[package]] -name = "certifi" -version = "2023.7.22" -description = "Python package for providing Mozilla's CA Bundle." -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ +certifi = [ {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] - -[[package]] -name = "cfgv" -version = "3.3.1" -description = "Validate configuration and produce human readable error messages." -category = "dev" -optional = false -python-versions = ">=3.6.1" -files = [ +cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] - -[[package]] -name = "charset-normalizer" -version = "3.2.0" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "dev" -optional = false -python-versions = ">=3.7.0" -files = [ +charset-normalizer = [ {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, @@ -384,54 +1423,18 @@ files = [ {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, ] - -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +click = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, ] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ +colorama = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] - -[[package]] -name = "colored" -version = "1.4.4" -description = "Simple library for color and formatting to terminal" -category = "dev" -optional = false -python-versions = "*" -files = [ +colored = [ {file = "colored-1.4.4.tar.gz", hash = "sha256:04ff4d4dd514274fe3b99a21bb52fb96f2688c01e93fba7bef37221e7cb56ce0"}, ] - -[[package]] -name = "coverage" -version = "7.2.7" -description = "Code coverage measurement for Python" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +coverage = [ {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, @@ -493,61 +1496,19 @@ files = [ {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, ] - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "distlib" -version = "0.3.7" -description = "Distribution utilities" -category = "dev" -optional = false -python-versions = "*" -files = [ +distlib = [ {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, ] - -[[package]] -name = "exceptiongroup" -version = "1.1.3" -description = "Backport of PEP 654 (exception groups)" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +exceptiongroup = [ {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, ] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "filelock" -version = "3.12.2" -description = "A platform independent file lock." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +filelock = [ {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, ] - -[package.extras] -docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] - -[[package]] -name = "frozenlist" -version = "1.3.3" -description = "A list-like structure which implements collections.abc.MutableSequence" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +frozenlist = [ {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4"}, {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0"}, {file = "frozenlist-1.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1c63e8d377d039ac769cd0926558bb7068a1f7abb0f003e3717ee003ad85530"}, @@ -623,290 +1584,67 @@ files = [ {file = "frozenlist-1.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:cfe33efc9cb900a4c46f91a5ceba26d6df370ffddd9ca386eb1d4f0ad97b9ea9"}, {file = "frozenlist-1.3.3.tar.gz", hash = "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a"}, ] - -[[package]] -name = "ghp-import" -version = "2.1.0" -description = "Copy your docs directly to the gh-pages branch." -category = "dev" -optional = false -python-versions = "*" -files = [ +ghp-import = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, ] - -[package.dependencies] -python-dateutil = ">=2.8.1" - -[package.extras] -dev = ["flake8", "markdown", "twine", "wheel"] - -[[package]] -name = "gitdb" -version = "4.0.10" -description = "Git Object Database" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +gitdb = [ {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, ] - -[package.dependencies] -smmap = ">=3.0.1,<6" - -[[package]] -name = "gitpython" -version = "3.1.36" -description = "GitPython is a Python library used to interact with Git repositories" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +gitpython = [ {file = "GitPython-3.1.36-py3-none-any.whl", hash = "sha256:8d22b5cfefd17c79914226982bb7851d6ade47545b1735a9d010a2a4c26d8388"}, {file = "GitPython-3.1.36.tar.gz", hash = "sha256:4bb0c2a6995e85064140d31a33289aa5dce80133a23d36fcd372d716c54d3ebf"}, ] - -[package.dependencies] -gitdb = ">=4.0.1,<5" -typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} - -[package.extras] -test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-sugar", "virtualenv"] - -[[package]] -name = "griffe" -version = "0.30.1" -description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +griffe = [ {file = "griffe-0.30.1-py3-none-any.whl", hash = "sha256:b2f3df6952995a6bebe19f797189d67aba7c860755d3d21cc80f64d076d0154c"}, {file = "griffe-0.30.1.tar.gz", hash = "sha256:007cc11acd20becf1bb8f826419a52b9d403bbad9d8c8535699f5440ddc0a109"}, ] - -[package.dependencies] -cached-property = {version = "*", markers = "python_version < \"3.8\""} -colorama = ">=0.4" - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +h11 = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] - -[package.dependencies] -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} - -[[package]] -name = "httpcore" -version = "0.16.3" -description = "A minimal low-level HTTP client." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +httpcore = [ {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, ] - -[package.dependencies] -anyio = ">=3.0,<5.0" -certifi = "*" -h11 = ">=0.13,<0.15" -sniffio = ">=1.0.0,<2.0.0" - -[package.extras] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] - -[[package]] -name = "httpx" -version = "0.23.3" -description = "The next generation HTTP client." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +httpx = [ {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, ] - -[package.dependencies] -certifi = "*" -httpcore = ">=0.15.0,<0.17.0" -rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} -sniffio = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] - -[[package]] -name = "identify" -version = "2.5.24" -description = "File identification library for Python" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +identify = [ {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, ] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.4" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "dev" -optional = false -python-versions = ">=3.5" -files = [ +idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] - -[[package]] -name = "importlib-metadata" -version = "6.7.0" -description = "Read metadata from Python packages" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ +importlib-metadata = [ {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, ] - -[package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +iniconfig = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] - -[[package]] -name = "jinja2" -version = "3.1.2" -description = "A very fast and expressive template engine." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +jinja2 = [ {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, ] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "linkify-it-py" -version = "2.0.2" -description = "Links recognition library with FULL unicode support." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ +linkify-it-py = [ {file = "linkify-it-py-2.0.2.tar.gz", hash = "sha256:19f3060727842c254c808e99d465c80c49d2c7306788140987a1a7a29b0d6ad2"}, {file = "linkify_it_py-2.0.2-py3-none-any.whl", hash = "sha256:a3a24428f6c96f27370d7fe61d2ac0be09017be5190d68d8658233171f1b6541"}, ] - -[package.dependencies] -uc-micro-py = "*" - -[package.extras] -benchmark = ["pytest", "pytest-benchmark"] -dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] -doc = ["myst-parser", "sphinx", "sphinx-book-theme"] -test = ["coverage", "pytest", "pytest-cov"] - -[[package]] -name = "markdown" -version = "3.4.4" -description = "Python implementation of John Gruber's Markdown." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +markdown = [ {file = "Markdown-3.4.4-py3-none-any.whl", hash = "sha256:a4c1b65c0957b4bd9e7d86ddc7b3c9868fb9670660f6f99f6d1bca8954d5a941"}, {file = "Markdown-3.4.4.tar.gz", hash = "sha256:225c6123522495d4119a90b3a3ba31a1e87a70369e03f14799ea9c0d7183a3d6"}, ] - -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.0)", "mkdocs-nature (>=0.4)"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "markdown-it-py" -version = "2.2.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ +markdown-it-py = [ {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, ] - -[package.dependencies] -linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} -mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} -mdurl = ">=0.1,<1.0" -typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "2.1.3" -description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +markupsafe = [ {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, @@ -958,223 +1696,50 @@ files = [ {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] - -[[package]] -name = "mdit-py-plugins" -version = "0.3.5" -description = "Collection of plugins for markdown-it-py" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ +mdit-py-plugins = [ {file = "mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a"}, {file = "mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e"}, ] - -[package.dependencies] -markdown-it-py = ">=1.0.0,<3.0.0" - -[package.extras] -code-style = ["pre-commit"] -rtd = ["attrs", "myst-parser (>=0.16.1,<0.17.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ +mdurl = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] - -[[package]] -name = "mergedeep" -version = "1.3.4" -description = "A deep merge function for 🐍." -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ +mergedeep = [ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, ] - -[[package]] -name = "mkdocs" -version = "1.5.2" -description = "Project documentation with Markdown." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mkdocs-1.5.2-py3-none-any.whl", hash = "sha256:60a62538519c2e96fe8426654a67ee177350451616118a41596ae7c876bb7eac"}, - {file = "mkdocs-1.5.2.tar.gz", hash = "sha256:70d0da09c26cff288852471be03c23f0f521fc15cf16ac89c7a3bfb9ae8d24f9"}, +mkdocs = [ + {file = "mkdocs-1.5.3-py3-none-any.whl", hash = "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1"}, + {file = "mkdocs-1.5.3.tar.gz", hash = "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2"}, ] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} -ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} -jinja2 = ">=2.11.1" -markdown = ">=3.2.1" -markupsafe = ">=2.0.1" -mergedeep = ">=1.3.4" -packaging = ">=20.5" -pathspec = ">=0.11.1" -platformdirs = ">=2.2.0" -pyyaml = ">=5.1" -pyyaml-env-tag = ">=0.1" -typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""} -watchdog = ">=2.0" - -[package.extras] -i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pathspec (==0.11.1)", "platformdirs (==2.2.0)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] - -[[package]] -name = "mkdocs-autorefs" -version = "0.4.1" -description = "Automatically link across pages in MkDocs." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +mkdocs-autorefs = [ {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] - -[package.dependencies] -Markdown = ">=3.3" -mkdocs = ">=1.1" - -[[package]] -name = "mkdocs-exclude" -version = "1.0.2" -description = "A mkdocs plugin that lets you exclude files or trees." -category = "dev" -optional = false -python-versions = "*" -files = [ +mkdocs-exclude = [ {file = "mkdocs-exclude-1.0.2.tar.gz", hash = "sha256:ba6fab3c80ddbe3fd31d3e579861fd3124513708271180a5f81846da8c7e2a51"}, ] - -[package.dependencies] -mkdocs = "*" - -[[package]] -name = "mkdocs-material" -version = "9.2.7" -description = "Documentation that simply works" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +mkdocs-material = [ {file = "mkdocs_material-9.2.7-py3-none-any.whl", hash = "sha256:92e4160d191cc76121fed14ab9f14638e43a6da0f2e9d7a9194d377f0a4e7f18"}, {file = "mkdocs_material-9.2.7.tar.gz", hash = "sha256:b44da35b0d98cd762d09ef74f1ddce5b6d6e35c13f13beb0c9d82a629e5f229e"}, ] - -[package.dependencies] -babel = ">=2.10,<3.0" -colorama = ">=0.4,<1.0" -jinja2 = ">=3.0,<4.0" -markdown = ">=3.2,<4.0" -mkdocs = ">=1.5,<2.0" -mkdocs-material-extensions = ">=1.1,<2.0" -paginate = ">=0.5,<1.0" -pygments = ">=2.16,<3.0" -pymdown-extensions = ">=10.2,<11.0" -regex = ">=2022.4,<2023.0" -requests = ">=2.26,<3.0" - -[[package]] -name = "mkdocs-material-extensions" -version = "1.1.1" -description = "Extension pack for Python Markdown and MkDocs Material." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +mkdocs-material-extensions = [ {file = "mkdocs_material_extensions-1.1.1-py3-none-any.whl", hash = "sha256:e41d9f38e4798b6617ad98ca8f7f1157b1e4385ac1459ca1e4ea219b556df945"}, {file = "mkdocs_material_extensions-1.1.1.tar.gz", hash = "sha256:9c003da71e2cc2493d910237448c672e00cefc800d3d6ae93d2fc69979e3bd93"}, ] - -[[package]] -name = "mkdocs-rss-plugin" -version = "1.5.0" -description = "MkDocs plugin which generates a static RSS feed using git log and page.meta." -category = "dev" -optional = false -python-versions = ">=3.7, <4" -files = [ +mkdocs-rss-plugin = [ {file = "mkdocs-rss-plugin-1.5.0.tar.gz", hash = "sha256:4178b3830dcbad9b53b12459e315b1aad6b37d1e7e5c56c686866a10f99878a4"}, {file = "mkdocs_rss_plugin-1.5.0-py2.py3-none-any.whl", hash = "sha256:2ab14c20bf6b7983acbe50181e7e4a0778731d9c2d5c38107ca7047a7abd2165"}, ] - -[package.dependencies] -GitPython = ">=3.1,<3.2" -mkdocs = ">=1.1,<2" -pytz = {version = ">=2022.0.0,<2023.0.0", markers = "python_version < \"3.9\""} -tzdata = {version = ">=2022.0.0,<2023.0.0", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} - -[package.extras] -dev = ["black", "feedparser (>=6.0,<6.1)", "flake8 (>=4,<5.1)", "pre-commit (>=2.10,<2.21)", "pytest-cov (>=4.0.0,<4.1.0)", "validator-collection (>=1.5,<1.6)"] -doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (>=0.5.0,<0.6.0)", "pygments (>=2.5,<3)", "pymdown-extensions (>=7,<10)"] - -[[package]] -name = "mkdocstrings" -version = "0.20.0" -description = "Automatic documentation from sources, for MkDocs." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +mkdocstrings = [ {file = "mkdocstrings-0.20.0-py3-none-any.whl", hash = "sha256:f17fc2c4f760ec302b069075ef9e31045aa6372ca91d2f35ded3adba8e25a472"}, {file = "mkdocstrings-0.20.0.tar.gz", hash = "sha256:c757f4f646d4f939491d6bc9256bfe33e36c5f8026392f49eaa351d241c838e5"}, ] - -[package.dependencies] -Jinja2 = ">=2.11.1" -Markdown = ">=3.3" -MarkupSafe = ">=1.1" -mkdocs = ">=1.2" -mkdocs-autorefs = ">=0.3.1" -mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} -pymdown-extensions = ">=6.3" - -[package.extras] -crystal = ["mkdocstrings-crystal (>=0.3.4)"] -python = ["mkdocstrings-python (>=0.5.2)"] -python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] - -[[package]] -name = "mkdocstrings-python" -version = "0.10.1" -description = "A Python handler for mkdocstrings." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +mkdocstrings-python = [ {file = "mkdocstrings_python-0.10.1-py3-none-any.whl", hash = "sha256:ef239cee2c688e2b949a0a47e42a141d744dd12b7007311b3309dc70e3bafc5c"}, {file = "mkdocstrings_python-0.10.1.tar.gz", hash = "sha256:b72301fff739070ec517b5b36bf2f7c49d1360a275896a64efb97fc17d3f3968"}, ] - -[package.dependencies] -griffe = ">=0.24" -mkdocstrings = ">=0.20" - -[[package]] -name = "msgpack" -version = "1.0.5" -description = "MessagePack serializer" -category = "dev" -optional = false -python-versions = "*" -files = [ +msgpack = [ {file = "msgpack-1.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:525228efd79bb831cf6830a732e2e80bc1b05436b086d4264814b4b2955b2fa9"}, {file = "msgpack-1.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f8d8b3bf1ff2672567d6b5c725a1b347fe838b912772aa8ae2bf70338d5a198"}, {file = "msgpack-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdc793c50be3f01106245a61b739328f7dccc2c648b501e237f0699fe1395b81"}, @@ -1239,15 +1804,7 @@ files = [ {file = "msgpack-1.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:06f5174b5f8ed0ed919da0e62cbd4ffde676a374aba4020034da05fab67b9164"}, {file = "msgpack-1.0.5.tar.gz", hash = "sha256:c075544284eadc5cddc70f4757331d99dcbc16b2bbd4849d15f8aae4cf36d31c"}, ] - -[[package]] -name = "multidict" -version = "6.0.4" -description = "multidict implementation" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +multidict = [ {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, {file = "multidict-6.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7"}, @@ -1323,15 +1880,7 @@ files = [ {file = "multidict-6.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2"}, {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, ] - -[[package]] -name = "mypy" -version = "1.4.1" -description = "Optional static typing for Python" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +mypy = [ {file = "mypy-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:566e72b0cd6598503e48ea610e0052d1b8168e60a46e0bfd34b3acf2d57f96a8"}, {file = "mypy-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca637024ca67ab24a7fd6f65d280572c3794665eaf5edcc7e90a866544076878"}, {file = "mypy-1.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dde1d180cd84f0624c5dcaaa89c89775550a675aff96b5848de78fb11adabcd"}, @@ -1359,311 +1908,74 @@ files = [ {file = "mypy-1.4.1-py3-none-any.whl", hash = "sha256:45d32cec14e7b97af848bddd97d85ea4f0db4d5a149ed9676caa4eb2f7402bb4"}, {file = "mypy-1.4.1.tar.gz", hash = "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b"}, ] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} -typing-extensions = ">=4.1.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -install-types = ["pip"] -python2 = ["typed-ast (>=1.4.0,<2)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" -optional = false -python-versions = ">=3.5" -files = [ +mypy-extensions = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] - -[[package]] -name = "nodeenv" -version = "1.8.0" -description = "Node.js virtual environment builder" -category = "dev" -optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" -files = [ +nodeenv = [ {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, ] - -[package.dependencies] -setuptools = "*" - -[[package]] -name = "packaging" -version = "23.1" -description = "Core utilities for Python packages" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +packaging = [ {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] - -[[package]] -name = "paginate" -version = "0.5.6" -description = "Divides large result sets into pages for easier browsing" -category = "dev" -optional = false -python-versions = "*" -files = [ +paginate = [ {file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"}, ] - -[[package]] -name = "pathspec" -version = "0.11.2" -description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +pathspec = [ {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] - -[[package]] -name = "platformdirs" -version = "3.10.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +platformdirs = [ {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, ] - -[package.dependencies] -typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.8\""} - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] - -[[package]] -name = "pluggy" -version = "1.2.0" -description = "plugin and hook calling mechanisms for python" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +pluggy = [ {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, ] - -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pre-commit" -version = "2.21.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +pre-commit = [ {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, ] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "pygments" -version = "2.16.1" -description = "Pygments is a syntax highlighting package written in Python." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ +pygments = [ {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, ] - -[package.extras] -plugins = ["importlib-metadata"] - -[[package]] -name = "pymdown-extensions" -version = "10.2.1" -description = "Extension pack for Python Markdown." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +pymdown-extensions = [ {file = "pymdown_extensions-10.2.1-py3-none-any.whl", hash = "sha256:bded105eb8d93f88f2f821f00108cb70cef1269db6a40128c09c5f48bfc60ea4"}, {file = "pymdown_extensions-10.2.1.tar.gz", hash = "sha256:d0c534b4a5725a4be7ccef25d65a4c97dba58b54ad7c813babf0eb5ba9c81591"}, ] - -[package.dependencies] -markdown = ">=3.2" -pyyaml = "*" - -[package.extras] -extra = ["pygments (>=2.12)"] - -[[package]] -name = "pytest" -version = "7.4.2" -description = "pytest: simple powerful testing with Python" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +pytest = [ {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, ] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-aiohttp" -version = "1.0.5" -description = "Pytest plugin for aiohttp support" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +pytest-aiohttp = [ {file = "pytest-aiohttp-1.0.5.tar.gz", hash = "sha256:880262bc5951e934463b15e3af8bb298f11f7d4d3ebac970aab425aff10a780a"}, {file = "pytest_aiohttp-1.0.5-py3-none-any.whl", hash = "sha256:63a5360fd2f34dda4ab8e6baee4c5f5be4cd186a403cabd498fced82ac9c561e"}, ] - -[package.dependencies] -aiohttp = ">=3.8.1" -pytest = ">=6.1.0" -pytest-asyncio = ">=0.17.2" - -[package.extras] -testing = ["coverage (==6.2)", "mypy (==0.931)"] - -[[package]] -name = "pytest-asyncio" -version = "0.21.1" -description = "Pytest support for asyncio" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +pytest-asyncio = [ {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, ] - -[package.dependencies] -pytest = ">=7.0.0" -typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] - -[[package]] -name = "pytest-cov" -version = "2.12.1" -description = "Pytest plugin for measuring coverage." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ +pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] - -[package.dependencies] -coverage = ">=5.2.1" -pytest = ">=4.6" -toml = "*" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] - -[[package]] -name = "pytest-textual-snapshot" -version = "0.4.0" -description = "Snapshot testing for Textual apps" -category = "dev" -optional = false -python-versions = ">=3.6,<4.0" -files = [ +pytest-textual-snapshot = [ {file = "pytest_textual_snapshot-0.4.0-py3-none-any.whl", hash = "sha256:879cc5de29cdd31cfe1b6daeb1dc5e42682abebcf4f88e7e3375bd5200683fc0"}, {file = "pytest_textual_snapshot-0.4.0.tar.gz", hash = "sha256:63782e053928a925d88ff7359dd640f2900e23bc708b3007f8b388e65f2527cb"}, ] - -[package.dependencies] -jinja2 = ">=3.0.0" -pytest = ">=7.0.0" -rich = ">=12.0.0" -syrupy = ">=3.0.0" -textual = ">=0.28.0" - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ +python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pytz" -version = "2022.7.1" -description = "World timezone definitions, modern and historical" -category = "dev" -optional = false -python-versions = "*" -files = [ +pytz = [ {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, ] - -[[package]] -name = "pyyaml" -version = "6.0.1" -description = "YAML parser and emitter for Python" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ +pyyaml = [ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, @@ -1705,30 +2017,11 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] - -[[package]] -name = "pyyaml-env-tag" -version = "0.1" -description = "A custom YAML tag for referencing environment variables in YAML files. " -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ +pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] - -[package.dependencies] -pyyaml = "*" - -[[package]] -name = "regex" -version = "2022.10.31" -description = "Alternative regular expression module, to replace re." -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ +regex = [ {file = "regex-2022.10.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f"}, {file = "regex-2022.10.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9"}, {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b"}, @@ -1818,163 +2111,43 @@ files = [ {file = "regex-2022.10.31-cp39-cp39-win_amd64.whl", hash = "sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692"}, {file = "regex-2022.10.31.tar.gz", hash = "sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83"}, ] - -[[package]] -name = "requests" -version = "2.31.0" -description = "Python HTTP for Humans." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +requests = [ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, ] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "rfc3986" -version = "1.5.0" -description = "Validating URI References per RFC 3986" -category = "dev" -optional = false -python-versions = "*" -files = [ +rfc3986 = [ {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, ] - -[package.dependencies] -idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} - -[package.extras] -idna2008 = ["idna"] - -[[package]] -name = "rich" -version = "13.5.3" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -category = "main" -optional = false -python-versions = ">=3.7.0" -files = [ +rich = [ {file = "rich-13.5.3-py3-none-any.whl", hash = "sha256:9257b468badc3d347e146a4faa268ff229039d4c2d176ab0cffb4c4fbc73d5d9"}, {file = "rich-13.5.3.tar.gz", hash = "sha256:87b43e0543149efa1253f485cd845bb7ee54df16c9617b8a893650ab84b4acb6"}, ] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "setuptools" -version = "68.0.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +setuptools = [ {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, ] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ +six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] - -[[package]] -name = "smmap" -version = "5.0.1" -description = "A pure Python implementation of a sliding window memory map manager" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +smmap = [ {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, ] - -[[package]] -name = "sniffio" -version = "1.3.0" -description = "Sniff out which async library your code is running under" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +sniffio = [ {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] - -[[package]] -name = "syrupy" -version = "3.0.6" -description = "Pytest Snapshot Test Utility" -category = "dev" -optional = false -python-versions = ">=3.7,<4" -files = [ +syrupy = [ {file = "syrupy-3.0.6-py3-none-any.whl", hash = "sha256:9c18e22264026b34239bcc87ab7cc8d893eb17236ea7dae634217ea4f22a848d"}, {file = "syrupy-3.0.6.tar.gz", hash = "sha256:583aa5ca691305c27902c3e29a1ce9da50ff9ab5f184c54b1dc124a16e4a6cf4"}, ] - -[package.dependencies] -colored = ">=1.3.92,<2.0.0" -pytest = ">=5.1.0,<8.0.0" - -[[package]] -name = "textual-dev" -version = "1.1.0" -description = "Development tools for working with Textual" -category = "dev" -optional = false -python-versions = ">=3.7,<4.0" -files = [ +textual-dev = [ {file = "textual_dev-1.1.0-py3-none-any.whl", hash = "sha256:c57320636098e31fa5d5c29fc3bc60829bb420da3c76bfed24db6eacf178dbc6"}, {file = "textual_dev-1.1.0.tar.gz", hash = "sha256:e2f8ce4e1c18a16b80282f3257cd2feb49a7ede289a78908c9063ce071bb77ce"}, ] - -[package.dependencies] -aiohttp = ">=3.8.1" -click = ">=8.1.2" -msgpack = ">=1.0.3" -textual = ">=0.32.0" -typing-extensions = ">=4.4.0,<5.0.0" - -[[package]] -name = "time-machine" -version = "2.10.0" -description = "Travel through time in your tests." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +time-machine = [ {file = "time_machine-2.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2d5e93c14b935d802a310c1d4694a9fe894b48a733ebd641c9a570d6f9e1f667"}, {file = "time_machine-2.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c0dda6b132c0180941944ede357109016d161d840384c2fb1096a3a2ef619f4"}, {file = "time_machine-2.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:900517e4a4121bf88527343d6aea2b5c99df134815bb8271ef589ec792502a71"}, @@ -2030,42 +2203,124 @@ files = [ {file = "time_machine-2.10.0-cp39-cp39-win_arm64.whl", hash = "sha256:c1775a949dd830579d1af5a271ec53d920dc01657035ad305f55c5a1ac9b9f1e"}, {file = "time_machine-2.10.0.tar.gz", hash = "sha256:64fd89678cf589fc5554c311417128b2782222dd65f703bf248ef41541761da0"}, ] - -[package.dependencies] -python-dateutil = "*" - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ +toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] - -[[package]] -name = "typed-ast" -version = "1.5.5" -description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ +tree-sitter = [ + {file = "tree_sitter-0.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1a151ccf9233b0b84850422654247f68a4d78f548425c76520402ea6fb6cdb24"}, + {file = "tree_sitter-0.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52ca2738c3c4c660c83054ac3e44a49cbecb9f89dc26bb8e154d6ca288aa06b0"}, + {file = "tree_sitter-0.20.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8d51478ea078da7cc6f626e9e36f131bbc5fac036cf38ea4b5b81632cbac37d"}, + {file = "tree_sitter-0.20.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0b2b59e1633efbf19cd2ed1ceb8d51b2c44a278153b1113998c70bc1570b750"}, + {file = "tree_sitter-0.20.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7f691c57d2a65d6e53e2f3574153c9cd0c157ff938b8d6f252edd5e619811403"}, + {file = "tree_sitter-0.20.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba72a363387eebaff9a0b788f864fe47da425136cbd4cac6cd125051f043c296"}, + {file = "tree_sitter-0.20.2-cp310-cp310-win32.whl", hash = "sha256:55e33eb206446d5046d3b5fe36ab300840f5a8a844246adb0ccc68c55c30b722"}, + {file = "tree_sitter-0.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:24ce9d14daba0a71a778417d9d61dd4038ca96981ddec19e1e8990881469321c"}, + {file = "tree_sitter-0.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:942dbfb8bc380f09b0e323d3884de07d19022930516f33b7503a6eb5f6e18979"}, + {file = "tree_sitter-0.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ee5651c11924d426f8d6858a40fd5090ae31574f81ef180bef2055282f43bf62"}, + {file = "tree_sitter-0.20.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fb6982b480031628dad7f229c4c8d90b17d4c281ba97848d3b100666d7fa45f"}, + {file = "tree_sitter-0.20.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:067609c6c7cb6e5a6c4be50076a380fe52b6e8f0641ee9d0da33b24a5b972e82"}, + {file = "tree_sitter-0.20.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:849d7e6b66fe7ded08a633943b30e0ed807eee76104288e6c6841433f4a9651b"}, + {file = "tree_sitter-0.20.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e85689573797e49f86e2d7cf48b9dd23bc044c477df074a78546e666d6990a29"}, + {file = "tree_sitter-0.20.2-cp311-cp311-win32.whl", hash = "sha256:098906148e44ea391a91b019d584dd8d0ea1437af62a9744e280e93163fd35ca"}, + {file = "tree_sitter-0.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:2753a87094b72fe7f02276b3948155618f53aa14e1ca20588f0eeed510f68512"}, + {file = "tree_sitter-0.20.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:5de192cb9e7b1c882d45418decb7899f1547f7056df756bcae186bbf4966d96e"}, + {file = "tree_sitter-0.20.2-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3a77e663293a73a97edbf2a2e05001de08933eb5d311a16bdc25b9b2fac54f3"}, + {file = "tree_sitter-0.20.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:415da4a70c56a003758537517fe9e60b8b0c5f70becde54cc8b8f3ba810adc70"}, + {file = "tree_sitter-0.20.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:707fb4d7a6123b8f9f2b005d61194077c3168c0372556e7418802280eddd4892"}, + {file = "tree_sitter-0.20.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:75fcbfb0a61ad64e7f787eb3f8fbf29b8e2b858dc011897ad039d838a06cee02"}, + {file = "tree_sitter-0.20.2-cp36-cp36m-win32.whl", hash = "sha256:622926530895d939fa6e1e2487e71a311c71d3b09f4c4f19301695ea866304a4"}, + {file = "tree_sitter-0.20.2-cp36-cp36m-win_amd64.whl", hash = "sha256:5c0712f031271d9bc462f1db7623d23703ed9fbcbaa6dc19ba535f58d6110774"}, + {file = "tree_sitter-0.20.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2dfdf680ecf5619447243c4c20e4040a7b5e7afca4e1569f03c814e86bfda248"}, + {file = "tree_sitter-0.20.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79650ee23a15559b69542c71ed9eb3297dce21932a7c5c148be384dd0f2cd49d"}, + {file = "tree_sitter-0.20.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d63059746b4b2f2f87dd19c208141c69452694aae32459b7a4ebca8539d13bf4"}, + {file = "tree_sitter-0.20.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9398d1e214d4915032cf68a678de7eb803f64d25ef04724d70b88db7bb7746e9"}, + {file = "tree_sitter-0.20.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b506fb2e2bd7a5a1603c644bbb90401fe488f86bbca39706addaa8d2bfc80815"}, + {file = "tree_sitter-0.20.2-cp37-cp37m-win32.whl", hash = "sha256:405e83804ba60ca1c3dbd258adbe0d7b0f1bdce948e5eec5587a2ebedcf930ba"}, + {file = "tree_sitter-0.20.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a1e66d211c04144484e223922ac094a2367476e6f57000f986c5560dc5a83c6e"}, + {file = "tree_sitter-0.20.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f8adc325c74c042204ed47d095e0ec86f83de3c7ec4979645f86b58514f60297"}, + {file = "tree_sitter-0.20.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb49c861e1d111e0df119ecbfaa409e6413b8d91e8f56bcdb15f07fbc35594e"}, + {file = "tree_sitter-0.20.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e17ee83409b01fdd09021997b0c747be2f773bb2bb140ba6fb48b7e12fdd039a"}, + {file = "tree_sitter-0.20.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:475ab841647a0d1bc1266c8978279f8e4f7b9520b9a7336d532e5dfc8910214d"}, + {file = "tree_sitter-0.20.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:222350189675d9814966a5c88c6c1378a2ee2f3041c439a6f1d1ff2006f403aa"}, + {file = "tree_sitter-0.20.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:31ea52f0deee70f2cb00aff01e40aae325a34ebe1661de274c9107322fb95f54"}, + {file = "tree_sitter-0.20.2-cp38-cp38-win32.whl", hash = "sha256:cceaf7287137cbca707006624a4a8d4b5ccbfec025793fde84d90524c2bb0946"}, + {file = "tree_sitter-0.20.2-cp38-cp38-win_amd64.whl", hash = "sha256:25b9669911f21ec2b3727bb2f4dfeff6ddb6f81898c3e968d378a660e0d7f90e"}, + {file = "tree_sitter-0.20.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ce30a17f46a6b39a04a599dea88c127a19e3e1f43a2ad0ced71b5c032d585077"}, + {file = "tree_sitter-0.20.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e9576e8b2e663639527e01ab251b87f0bd370bfdd40515588689ebc424aec786"}, + {file = "tree_sitter-0.20.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d03731a498f624ce3536c821ef23b03d1ad569b3845b326a5b7149ef189d732c"}, + {file = "tree_sitter-0.20.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0116ecb163573ebaa0fc04cc99c90bd94c0be5cc4d0a1ebeb102de9cc9a054"}, + {file = "tree_sitter-0.20.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0943b00d3700f253c3ee6a53a71b9a6ca46defd9c0a33edb07a9388e70dc3a9e"}, + {file = "tree_sitter-0.20.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cb566b6f0b5457148cb8310a1ca3d764edf28e47fcccfe0b167861ecaa50c12"}, + {file = "tree_sitter-0.20.2-cp39-cp39-win32.whl", hash = "sha256:4544204a24c2b4d25d1731b0df83f7c819ce87c4f2538a19724b8753815ef388"}, + {file = "tree_sitter-0.20.2-cp39-cp39-win_amd64.whl", hash = "sha256:9517b204e471d6aa59ee2232f6220f315ed5336079034d5c861a24660d6511d6"}, + {file = "tree_sitter-0.20.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:84343678f58cb354d22ed14b627056ffb33c540cf16c35a83db4eeee8827b935"}, + {file = "tree_sitter-0.20.2-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:611a80171d8fa6833dd0c8b022714d2ea789de15a955ec42ec4fd5fcc1032edb"}, + {file = "tree_sitter-0.20.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bacecfb61694c95ccee462742b3fcea50ba1baf115c42e60adf52b549ef642ce"}, + {file = "tree_sitter-0.20.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f344ae94a268479456f19712736cc7398de5822dc74cca7d39538c28085721d0"}, + {file = "tree_sitter-0.20.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:221784d7f326fe81ce7174ac5972800f58b9a7c5c48a03719cad9830c22e5a76"}, + {file = "tree_sitter-0.20.2-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64210ed8d2a1b7e2951f6576aa0cb7be31ad06d87da26c52961318fc54c7fe77"}, + {file = "tree_sitter-0.20.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2634ac73b39ceacfa431d6d95692eae7465977fa0b9e9f7ae6cb445991e829a5"}, + {file = "tree_sitter-0.20.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:71663a0e8230dae99d9c55e6895bd2c9e42534ec861b255775f704ae2db70c1d"}, + {file = "tree_sitter-0.20.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:32c3e0f30b45a58d36bf6a0ec982ca3eaa23c7f924628da499b7ad22a8abad71"}, + {file = "tree_sitter-0.20.2-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9b02e4ab2158c25f6f520c93318d562da58fa4ba53e1dbd434be008f48104980"}, + {file = "tree_sitter-0.20.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10e567eb6961a1e86aebbe26a9ca07d324f8529bca90937a924f8aa0ea4dc127"}, + {file = "tree_sitter-0.20.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63f8e8e69f5f25c2b565449e1b8a2aa7b6338b4f37c8658c5fbdec04858c30be"}, + {file = "tree_sitter-0.20.2.tar.gz", hash = "sha256:0a6c06abaa55de174241a476b536173bba28241d2ea85d198d33aa8bf009f028"}, +] +tree-sitter-languages = [ + {file = "tree_sitter_languages-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fd8b856c224a74c395ed9495761c3ef8ba86014dbf6037d73634436ae683c808"}, + {file = "tree_sitter_languages-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:277d1bec6e101a26a4445cd7cb1eb8f8cf5a9bbad1ca80692bfae1af63568272"}, + {file = "tree_sitter_languages-1.7.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0473bd896799ccc87f428766813ddedd3506cad8430dbe863b663c81d7387680"}, + {file = "tree_sitter_languages-1.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6799419bc7e3029112f2a3f8b77b6c299f94f03bb70e5c31a437b3180486be"}, + {file = "tree_sitter_languages-1.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e5b705c8ce6ef47fc461484878956ecd42a67cbeb0a17e323b86a4439a8fdc3d"}, + {file = "tree_sitter_languages-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:28a732be6fced2f70184c1b34f64961e3b6259fe6d5f7540c91028c2a43a7109"}, + {file = "tree_sitter_languages-1.7.0-cp310-cp310-win32.whl", hash = "sha256:f5cdb1ec88f0b8c617330c953555a20cc7e96ca6b1f5c68ab6db347e869cfeeb"}, + {file = "tree_sitter_languages-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:26cb344a75798fce1a73b690504d8e7789f6ba25a178efcd203444d7868caf38"}, + {file = "tree_sitter_languages-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:433b56cb3dca02b30f21c596f431a2cff90905326be1f8913c3515acb984b21e"}, + {file = "tree_sitter_languages-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96686390e1a01af44aedef7b33d6be82de3cf674a98a5c7b417e540e6afa62cc"}, + {file = "tree_sitter_languages-1.7.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25a4b6d559fbd76c6ec1b73cf03d09f53aaa5a1b61078a3f518b162866d9d97e"}, + {file = "tree_sitter_languages-1.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e504f199c7a4c8b1b1efb05a063450aa23234feea6fa6c06f4077f7248ea9c98"}, + {file = "tree_sitter_languages-1.7.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6b29856e9314b5f68f05dfa45e6674f47535229dda32294ba6d129077a97759c"}, + {file = "tree_sitter_languages-1.7.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:786fdaf3d2120eef9384b0f22d7e2e42a561073ba753c7b438e90a1e7b351650"}, + {file = "tree_sitter_languages-1.7.0-cp311-cp311-win32.whl", hash = "sha256:a55a7007056d0927b78481b437d79ea0487cc991c7f9c19d67adcceac3d47f53"}, + {file = "tree_sitter_languages-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:4b01d3bdf7ce2aeee4d0df62071a0ca91e618a29845686a5bd714d93c5ef3b36"}, + {file = "tree_sitter_languages-1.7.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b603f1ad01bfb9d178f965125e2528cb7da9666d180f4a9a1acfaedbf5862ea"}, + {file = "tree_sitter_languages-1.7.0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70610aa26dd985d2fb9eb07ea8eacc3ceb0cc9c2e91416f51305120cfd919e28"}, + {file = "tree_sitter_languages-1.7.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0444ebc8bdb7dc0d66a816050cfd52376c4e62a94a9c54fde90b29acf3e4bab1"}, + {file = "tree_sitter_languages-1.7.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:7eeb5a3307ff1c0994ffff5ea37ec656a716a728b8c9359374104da521a76ded"}, + {file = "tree_sitter_languages-1.7.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:6c319cef16f2df667f1c165fe4eee160f2b51a0c4b61db1e70de2ab86420ca9a"}, + {file = "tree_sitter_languages-1.7.0-cp36-cp36m-win32.whl", hash = "sha256:b216650126d95d494f927393903e836a7ef5f0c4db0834f3a0b576f97c13abaf"}, + {file = "tree_sitter_languages-1.7.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6c96e5785d164a205962a10256808b3d12dccee9827ec88a46899063a2a2d28"}, + {file = "tree_sitter_languages-1.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:adafeabbd8d47b80122fad18bb61c25ed3da04f5347b7d774b53826accb27b7a"}, + {file = "tree_sitter_languages-1.7.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50e2bc5d2da770ecd5af94f9d716faa4764f890fd61bc0a488e9269653d9fb71"}, + {file = "tree_sitter_languages-1.7.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac773097cff7de6cf265c5be9990b4c6690161452da1d9fc41021d4bf7e8c73a"}, + {file = "tree_sitter_languages-1.7.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b233bfc48cf0f16436200afc7d7643cd87101c321de25b919b61f21f1693aa52"}, + {file = "tree_sitter_languages-1.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:eab3caedf50467045ed5cab776a57b494332616376d387c6600fd7ea4f5483cf"}, + {file = "tree_sitter_languages-1.7.0-cp37-cp37m-win32.whl", hash = "sha256:d533f743a22f5696494d3a5a60adb4cfbef63d58b8b5622993d93d6d0a602444"}, + {file = "tree_sitter_languages-1.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:aab96f64be30c9f73d6dc958ec22bb1a9fe70e90b2d2a3d233d537b347cea729"}, + {file = "tree_sitter_languages-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1bf89d771621e28847036b377f865f947e555a6654356d21beab738bb2531a69"}, + {file = "tree_sitter_languages-1.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b2f171089ec3c4f1de275edc8f0722e1e3dc7a54e83107098315ea2f0952cfcd"}, + {file = "tree_sitter_languages-1.7.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a091577d3a8454c40f813ee2834314c73cc504522f70f9e33d7c2268d33973f9"}, + {file = "tree_sitter_languages-1.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8287efa87d080b340b583a6e81266cc3d8266deb61b8f3312649a9d1562e665a"}, + {file = "tree_sitter_languages-1.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9c5080c06a2df7a59c69d2422a6ae83a5e37e92d57c4bd5e572d0eb5226ab3b0"}, + {file = "tree_sitter_languages-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ca8f629cfb406a2f9b9f8a3a5c804d4d1ba4cdca41cccba63f51fc1bab13e5de"}, + {file = "tree_sitter_languages-1.7.0-cp38-cp38-win32.whl", hash = "sha256:fd3561b37a99c9d501719819a8736529ae3a6d597128c15be432d1855f3cb0d9"}, + {file = "tree_sitter_languages-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:377ad60f7a7bf27315676c4fa84cc766aa0019c1e556083763136ed951e934c0"}, + {file = "tree_sitter_languages-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1dc71b68e48f58cd5b6a9ab7a541714201815629a6554a969cfc579a6ee6e53"}, + {file = "tree_sitter_languages-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fb1521367b14c275bef70997ea90526e7049f840ba1bbd3ef56c72f5b15596e9"}, + {file = "tree_sitter_languages-1.7.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f73651f7e78371dc3d455e8aba510cc6fb9e1ac1d648c3334157950781eb295"}, + {file = "tree_sitter_languages-1.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:049b0dd63be721fe3f9642a2b5a044bea2852de2b35818467996242ae4b7f01f"}, + {file = "tree_sitter_languages-1.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c428a8e1f5ecc4eb5c79abff3eb2881123446cde16fd1d8866d527470a6fdd2f"}, + {file = "tree_sitter_languages-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:40fb3fc11ff90caf65b4713feeb6c4852e5d2a04ef8ae6a2ac734a702a6a6c7e"}, + {file = "tree_sitter_languages-1.7.0-cp39-cp39-win32.whl", hash = "sha256:f28e9904833b7a909f8227c4560401049bd3310cebe3e0a884d9461f783b9af2"}, + {file = "tree_sitter_languages-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ea47ee390ec2e1c9bf96d7b418775263766021a834910c9f2d578f95a3e27d0f"}, +] +typed-ast = [ {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, @@ -2108,106 +2363,39 @@ files = [ {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, ] - -[[package]] -name = "types-setuptools" -version = "67.8.0.0" -description = "Typing stubs for setuptools" -category = "dev" -optional = false -python-versions = "*" -files = [ +types-setuptools = [ {file = "types-setuptools-67.8.0.0.tar.gz", hash = "sha256:95c9ed61871d6c0e258433373a4e1753c0a7c3627a46f4d4058c7b5a08ab844f"}, {file = "types_setuptools-67.8.0.0-py3-none-any.whl", hash = "sha256:6df73340d96b238a4188b7b7668814b37e8018168aef1eef94a3b1872e3f60ff"}, ] - -[[package]] -name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ +types-tree-sitter = [ + {file = "types-tree-sitter-0.20.1.5.tar.gz", hash = "sha256:94f971599548b90b9bbb6af651d235ad795a094a07651bc565a4b8856caebab1"}, + {file = "types_tree_sitter-0.20.1.5-py3-none-any.whl", hash = "sha256:8d7f9961febbad29789ce5c65f79b95b0702f3d34a7c12fabcd69c36c2bbe184"}, +] +types-tree-sitter-languages = [ + {file = "types-tree-sitter-languages-1.7.0.1.tar.gz", hash = "sha256:eadbbfa13f3fcad0711ac8f866cf87692f3c0cfeee72e979a5202b797588d57d"}, + {file = "types_tree_sitter_languages-1.7.0.1-py3-none-any.whl", hash = "sha256:818ec7824ed1bb5bcdbe21022340e0df3930199eb969ea1e08eb03a92440bce2"}, +] +typing-extensions = [ {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] - -[[package]] -name = "tzdata" -version = "2022.7" -description = "Provider of IANA time zone data" -category = "dev" -optional = false -python-versions = ">=2" -files = [ +tzdata = [ {file = "tzdata-2022.7-py2.py3-none-any.whl", hash = "sha256:2b88858b0e3120792a3c0635c23daf36a7d7eeeca657c323da299d2094402a0d"}, {file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"}, ] - -[[package]] -name = "uc-micro-py" -version = "1.0.2" -description = "Micro subset of unicode data files for linkify-it-py projects." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ +uc-micro-py = [ {file = "uc-micro-py-1.0.2.tar.gz", hash = "sha256:30ae2ac9c49f39ac6dce743bd187fcd2b574b16ca095fa74cd9396795c954c54"}, {file = "uc_micro_py-1.0.2-py3-none-any.whl", hash = "sha256:8c9110c309db9d9e87302e2f4ad2c3152770930d88ab385cd544e7a7e75f3de0"}, ] - -[package.extras] -test = ["coverage", "pytest", "pytest-cov"] - -[[package]] -name = "urllib3" -version = "2.0.4" -description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, - {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, +urllib3 = [ + {file = "urllib3-2.0.5-py3-none-any.whl", hash = "sha256:ef16afa8ba34a1f989db38e1dbbe0c302e4289a47856990d0682e374563ce35e"}, + {file = "urllib3-2.0.5.tar.gz", hash = "sha256:13abf37382ea2ce6fb744d4dad67838eec857c9f4f57009891805e0b5e123594"}, ] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "virtualenv" -version = "20.24.5" -description = "Virtual Python Environment builder" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +virtualenv = [ {file = "virtualenv-20.24.5-py3-none-any.whl", hash = "sha256:b80039f280f4919c77b30f1c23294ae357c4c8701042086e3fc005963e4e537b"}, {file = "virtualenv-20.24.5.tar.gz", hash = "sha256:e8361967f6da6fbdf1426483bfe9fca8287c242ac0bc30429905721cefbff752"}, ] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -importlib-metadata = {version = ">=6.6", markers = "python_version < \"3.8\""} -platformdirs = ">=3.9.1,<4" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] - -[[package]] -name = "watchdog" -version = "3.0.0" -description = "Filesystem events monitoring" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +watchdog = [ {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, @@ -2236,18 +2424,7 @@ files = [ {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, ] - -[package.extras] -watchmedo = ["PyYAML (>=3.10)"] - -[[package]] -name = "yarl" -version = "1.9.2" -description = "Yet another URL library" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ +yarl = [ {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82"}, {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8"}, {file = "yarl-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9"}, @@ -2323,29 +2500,7 @@ files = [ {file = "yarl-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18"}, {file = "yarl-1.9.2.tar.gz", hash = "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571"}, ] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} - -[[package]] -name = "zipp" -version = "3.15.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ +zipp = [ {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, ] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.7" -content-hash = "3817b3d8b678845abb17cddd49d5a6ea5fb9d0083faa356ef232184a94312ba6" diff --git a/pyproject.toml b/pyproject.toml index f343aea29..a3914503b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ markdown-it-py = { extras = ["plugins", "linkify"], version = ">=2.1.0" } #rich = {path="../rich", develop=true} importlib-metadata = ">=4.11.3" typing-extensions = "^4.4.0" +tree-sitter = "^0.20.1" +tree_sitter_languages = {version = ">=1.7.0", python = "^3.8"} [tool.poetry.group.dev.dependencies] pytest = "^7.1.3" @@ -65,7 +67,9 @@ httpx = "^0.23.1" types-setuptools = "^67.2.0.1" textual-dev = "^1.1.0" pytest-asyncio = "*" -pytest-textual-snapshot = "*" +pytest-textual-snapshot = ">=0.4.0" +types-tree-sitter = "^0.20.1.4" +types-tree-sitter-languages = "^1.7.0.1" [tool.black] includes = "src" diff --git a/src/textual/_ansi_sequences.py b/src/textual/_ansi_sequences.py index fc3b8de62..9d0c4a601 100644 --- a/src/textual/_ansi_sequences.py +++ b/src/textual/_ansi_sequences.py @@ -221,6 +221,8 @@ ANSI_SEQUENCES_KEYS: Mapping[str, Tuple[Keys, ...]] = { "\x1b[1;5B": (Keys.ControlDown,), # Cursor Mode "\x1b[1;5C": (Keys.ControlRight,), # Cursor Mode "\x1b[1;5D": (Keys.ControlLeft,), # Cursor Mode + "\x1bf": (Keys.ControlRight,), # iTerm natural editing keys + "\x1bb": (Keys.ControlLeft,), # iTerm natural editing keys "\x1b[1;5F": (Keys.ControlEnd,), "\x1b[1;5H": (Keys.ControlHome,), # Tmux sends following keystrokes when control+arrow is pressed, but for diff --git a/src/textual/_text_area_theme.py b/src/textual/_text_area_theme.py new file mode 100644 index 000000000..93bad81c8 --- /dev/null +++ b/src/textual/_text_area_theme.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from rich.style import Style + +from textual.app import DEFAULT_COLORS +from textual.color import Color +from textual.design import DEFAULT_DARK_SURFACE + + +@dataclass +class TextAreaTheme: + """A theme for the `TextArea` widget. + + Allows theming the general widget (gutter, selections, cursor, and so on) and + mapping of tree-sitter tokens to Rich styles. + + For example, consider the following snippet from the `markdown.scm` highlight + query file. We've assigned the `heading_content` token type to the name `heading`. + + ``` + (heading_content) @heading + ``` + + Now, we can map this `heading` name to a Rich style, and it will be styled as + such in the `TextArea`, assuming a parser which returns a `heading_content` + node is used (as will be the case when language="markdown"). + + ``` + TextAreaTheme('my_theme', syntax_styles={'heading': Style(color='cyan', bold=True)}) + ``` + + We can register this theme with our `TextArea` using the [`TextArea.register_theme`][textual.widgets._text_area.TextArea.register_theme] method, + and headings in our markdown files will be styled bold cyan. + """ + + name: str + """The name of the theme.""" + + base_style: Style | None = None + """The background style of the text area. If `None` the parent style will be used.""" + + gutter_style: Style | None = None + """The style of the gutter. If `None`, a legible Style will be generated.""" + + cursor_style: Style | None = None + """The style of the cursor. If `None`, a legible Style will be generated.""" + + cursor_line_style: Style | None = None + """The style to apply to the line the cursor is on.""" + + cursor_line_gutter_style: Style | None = None + """The style to apply to the gutter of the line the cursor is on. If `None`, a legible Style will be + generated.""" + + bracket_matching_style: Style | None = None + """The style to apply to matching brackets. If `None`, a legible Style will be generated.""" + + selection_style: Style | None = None + """The style of the selection. If `None` a default selection Style will be generated.""" + + syntax_styles: dict[str, Style] = field(default_factory=dict) + """The mapping of tree-sitter names from the `highlight_query` to Rich styles.""" + + def __post_init__(self) -> None: + """Generate some styles if they haven't been supplied.""" + if self.base_style is None: + self.base_style = Style() + + if self.base_style.color is None: + self.base_style = Style(color="#f3f3f3", bgcolor=self.base_style.bgcolor) + + if self.base_style.bgcolor is None: + self.base_style = Style( + color=self.base_style.color, bgcolor=DEFAULT_DARK_SURFACE + ) + + assert self.base_style is not None + assert self.base_style.color is not None + assert self.base_style.bgcolor is not None + + if self.gutter_style is None: + self.gutter_style = self.base_style.copy() + + background_color = Color.from_rich_color(self.base_style.bgcolor) + if self.cursor_style is None: + self.cursor_style = Style( + color=background_color.rich_color, + bgcolor=background_color.inverse.rich_color, + ) + + if self.cursor_line_gutter_style is None and self.cursor_line_style is not None: + self.cursor_line_gutter_style = self.cursor_line_style.copy() + + if self.bracket_matching_style is None: + bracket_matching_background = background_color.blend( + background_color.inverse, factor=0.05 + ) + self.bracket_matching_style = Style( + bgcolor=bracket_matching_background.rich_color + ) + + if self.selection_style is None: + selection_background_color = background_color.blend( + DEFAULT_COLORS["dark"].primary, factor=0.75 + ) + self.selection_style = Style.from_color( + bgcolor=selection_background_color.rich_color + ) + + @classmethod + def get_builtin_theme(cls, theme_name: str) -> "TextAreaTheme" | None: + """Get a `TextAreaTheme` by name. + + Given a `theme_name`, return the corresponding `TextAreaTheme` object. + + Args: + theme_name: The name of the theme. + + Returns: + The `TextAreaTheme` corresponding to the name or `None` if the theme isn't + found. + """ + return _BUILTIN_THEMES.get(theme_name) + + def get_highlight(self, name: str) -> Style | None: + """Return the Rich style corresponding to the name defined in the tree-sitter + highlight query for the current theme. + + Args: + name: The name of the highlight. + + Returns: + The `Style` to use for this highlight, or `None` if no style. + """ + return self.syntax_styles.get(name) + + @classmethod + def builtin_themes(cls) -> list[TextAreaTheme]: + """Get a list of all builtin TextAreaThemes. + + Returns: + A list of all builtin TextAreaThemes. + """ + return list(_BUILTIN_THEMES.values()) + + @classmethod + def default(cls) -> TextAreaTheme: + """Get the default syntax theme. + + Returns: + The default TextAreaTheme (probably "monokai"). + """ + return _MONOKAI + + +_MONOKAI = TextAreaTheme( + name="monokai", + base_style=Style(color="#f8f8f2", bgcolor="#272822"), + gutter_style=Style(color="#90908a", bgcolor="#272822"), + cursor_style=Style(color="#272822", bgcolor="#f8f8f0"), + cursor_line_style=Style(bgcolor="#3e3d32"), + cursor_line_gutter_style=Style(color="#c2c2bf", bgcolor="#3e3d32"), + bracket_matching_style=Style(bgcolor="#838889", bold=True), + selection_style=Style(bgcolor="#65686a"), + syntax_styles={ + "string": Style(color="#E6DB74"), + "string.documentation": Style(color="#E6DB74"), + "comment": Style(color="#75715E"), + "keyword": Style(color="#F92672"), + "operator": Style(color="#F92672"), + "repeat": Style(color="#F92672"), + "exception": Style(color="#F92672"), + "include": Style(color="#F92672"), + "keyword.function": Style(color="#F92672"), + "keyword.return": Style(color="#F92672"), + "keyword.operator": Style(color="#F92672"), + "conditional": Style(color="#F92672"), + "number": Style(color="#AE81FF"), + "float": Style(color="#AE81FF"), + "class": Style(color="#A6E22E"), + "type.class": Style(color="#A6E22E"), + "function": Style(color="#A6E22E"), + "function.call": Style(color="#A6E22E"), + "method": Style(color="#A6E22E"), + "method.call": Style(color="#A6E22E"), + "boolean": Style(color="#66D9EF", italic=True), + "json.null": Style(color="#66D9EF", italic=True), + "regex.punctuation.bracket": Style(color="#F92672"), + "regex.operator": Style(color="#F92672"), + "html.end_tag_error": Style(color="red", underline=True), + "tag": Style(color="#F92672"), + "yaml.field": Style(color="#F92672", bold=True), + "json.label": Style(color="#F92672", bold=True), + "toml.type": Style(color="#F92672"), + "toml.datetime": Style(color="#AE81FF"), + "heading": Style(color="#F92672", bold=True), + "bold": Style(bold=True), + "italic": Style(italic=True), + "strikethrough": Style(strike=True), + "link": Style(color="#66D9EF", underline=True), + "inline_code": Style(color="#E6DB74"), + }, +) + +_DRACULA = TextAreaTheme( + name="dracula", + base_style=Style(color="#f8f8f2", bgcolor="#1E1F35"), + gutter_style=Style(color="#6272a4"), + cursor_style=Style(color="#282a36", bgcolor="#f8f8f0"), + cursor_line_style=Style(bgcolor="#282b45"), + cursor_line_gutter_style=Style(color="#c2c2bf", bgcolor="#282b45", bold=True), + bracket_matching_style=Style(bgcolor="#99999d", bold=True, underline=True), + selection_style=Style(bgcolor="#44475A"), + syntax_styles={ + "string": Style(color="#f1fa8c"), + "string.documentation": Style(color="#f1fa8c"), + "comment": Style(color="#6272a4"), + "keyword": Style(color="#ff79c6"), + "operator": Style(color="#ff79c6"), + "repeat": Style(color="#ff79c6"), + "exception": Style(color="#ff79c6"), + "include": Style(color="#ff79c6"), + "keyword.function": Style(color="#ff79c6"), + "keyword.return": Style(color="#ff79c6"), + "keyword.operator": Style(color="#ff79c6"), + "conditional": Style(color="#ff79c6"), + "number": Style(color="#bd93f9"), + "float": Style(color="#bd93f9"), + "class": Style(color="#50fa7b"), + "type.class": Style(color="#50fa7b"), + "function": Style(color="#50fa7b"), + "function.call": Style(color="#50fa7b"), + "method": Style(color="#50fa7b"), + "method.call": Style(color="#50fa7b"), + "boolean": Style(color="#bd93f9"), + "json.null": Style(color="#bd93f9"), + "regex.punctuation.bracket": Style(color="#ff79c6"), + "regex.operator": Style(color="#ff79c6"), + "html.end_tag_error": Style(color="#F83333", underline=True), + "tag": Style(color="#ff79c6"), + "yaml.field": Style(color="#ff79c6", bold=True), + "json.label": Style(color="#ff79c6", bold=True), + "toml.type": Style(color="#ff79c6"), + "toml.datetime": Style(color="#bd93f9"), + "heading": Style(color="#ff79c6", bold=True), + "bold": Style(bold=True), + "italic": Style(italic=True), + "strikethrough": Style(strike=True), + "link": Style(color="#bd93f9", underline=True), + "inline_code": Style(color="#f1fa8c"), + }, +) + +_DARK_VS = TextAreaTheme( + name="vscode_dark", + base_style=Style(color="#CCCCCC", bgcolor="#1F1F1F"), + gutter_style=Style(color="#6E7681", bgcolor="#1F1F1F"), + cursor_style=Style(color="#1e1e1e", bgcolor="#f0f0f0"), + cursor_line_style=Style(bgcolor="#2b2b2b"), + bracket_matching_style=Style(bgcolor="#3a3a3a", bold=True), + cursor_line_gutter_style=Style(color="#CCCCCC", bgcolor="#2b2b2b"), + selection_style=Style(bgcolor="#264F78"), + syntax_styles={ + "string": Style(color="#ce9178"), + "string.documentation": Style(color="#ce9178"), + "comment": Style(color="#6A9955"), + "keyword": Style(color="#569cd6"), + "operator": Style(color="#569cd6"), + "conditional": Style(color="#569cd6"), + "keyword.function": Style(color="#569cd6"), + "keyword.return": Style(color="#569cd6"), + "keyword.operator": Style(color="#569cd6"), + "repeat": Style(color="#569cd6"), + "exception": Style(color="#569cd6"), + "include": Style(color="#569cd6"), + "number": Style(color="#b5cea8"), + "float": Style(color="#b5cea8"), + "class": Style(color="#4EC9B0"), + "type.class": Style(color="#4EC9B0"), + "function": Style(color="#4EC9B0"), + "function.call": Style(color="#4EC9B0"), + "method": Style(color="#4EC9B0"), + "method.call": Style(color="#4EC9B0"), + "boolean": Style(color="#7DAF9C"), + "json.null": Style(color="#7DAF9C"), + "tag": Style(color="#EFCB43"), + "yaml.field": Style(color="#569cd6", bold=True), + "json.label": Style(color="#569cd6", bold=True), + "toml.type": Style(color="#569cd6"), + "heading": Style(color="#569cd6", bold=True), + "bold": Style(bold=True), + "italic": Style(italic=True), + "strikethrough": Style(strike=True), + "link": Style(color="#40A6FF", underline=True), + "inline_code": Style(color="#ce9178"), + "info_string": Style(color="#ce9178", bold=True, italic=True), + }, +) + +_GITHUB_LIGHT = TextAreaTheme( + name="github_light", + base_style=Style(color="#24292e", bgcolor="#f0f0f0"), + gutter_style=Style(color="#BBBBBB", bgcolor="#f0f0f0"), + cursor_style=Style(color="#fafbfc", bgcolor="#24292e"), + cursor_line_style=Style(bgcolor="#ebebeb"), + bracket_matching_style=Style(color="#24292e", underline=True), + cursor_line_gutter_style=Style(color="#A4A4A4", bgcolor="#ebebeb"), + selection_style=Style(bgcolor="#c8c8fa"), + syntax_styles={ + "string": Style(color="#093069"), + "string.documentation": Style(color="#093069"), + "comment": Style(color="#6a737d"), + "keyword": Style(color="#d73a49"), + "operator": Style(color="#0450AE"), + "conditional": Style(color="#CF222E"), + "keyword.function": Style(color="#CF222E"), + "keyword.return": Style(color="#CF222E"), + "keyword.operator": Style(color="#CF222E"), + "repeat": Style(color="#CF222E"), + "exception": Style(color="#CF222E"), + "include": Style(color="#CF222E"), + "number": Style(color="#d73a49"), + "float": Style(color="#d73a49"), + "parameter": Style(color="#24292e"), + "class": Style(color="#963800"), + "variable": Style(color="#e36209"), + "function": Style(color="#6639BB"), + "method": Style(color="#6639BB"), + "boolean": Style(color="#7DAF9C"), + "tag": Style(color="#6639BB"), + "yaml.field": Style(color="#6639BB"), + "json.label": Style(color="#6639BB"), + "toml.type": Style(color="#6639BB"), + "heading": Style(color="#24292e", bold=True), + "bold": Style(bold=True), + "italic": Style(italic=True), + "strikethrough": Style(strike=True), + "link": Style(color="#40A6FF", underline=True), + "inline_code": Style(color="#093069"), + }, +) + +_BUILTIN_THEMES = { + "monokai": _MONOKAI, + "dracula": _DRACULA, + "vscode_dark": _DARK_VS, + "github_light": _GITHUB_LIGHT, +} + +DEFAULT_THEME = TextAreaTheme.get_builtin_theme("monokai") +"""The default TextAreaTheme used by Textual.""" diff --git a/src/textual/_tree_sitter.py b/src/textual/_tree_sitter.py new file mode 100644 index 000000000..01e300115 --- /dev/null +++ b/src/textual/_tree_sitter.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +try: + from tree_sitter import Language, Parser, Tree + from tree_sitter.binding import Query + from tree_sitter_languages import get_language, get_parser + + TREE_SITTER = True +except ImportError: + TREE_SITTER = False diff --git a/src/textual/_types.py b/src/textual/_types.py index b1ad7972f..669950c5a 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -1,6 +1,12 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Union -from typing_extensions import Protocol +from typing_extensions import ( + Literal, + Protocol, + SupportsIndex, + get_args, + runtime_checkable, +) if TYPE_CHECKING: from rich.segment import Segment diff --git a/src/textual/document/__init__.py b/src/textual/document/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py new file mode 100644 index 000000000..5e8e37d8d --- /dev/null +++ b/src/textual/document/_document.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from functools import lru_cache +from typing import TYPE_CHECKING, NamedTuple, Tuple, overload + +if TYPE_CHECKING: + from tree_sitter import Node + from tree_sitter.binding import Query + +from textual._cells import cell_len +from textual._types import Literal, get_args +from textual.geometry import Size + +Newline = Literal["\r\n", "\n", "\r"] +"""The type representing valid line separators.""" +VALID_NEWLINES = set(get_args(Newline)) +"""The set of valid line separator strings.""" + + +@dataclass +class EditResult: + """Contains information about an edit that has occurred.""" + + end_location: Location + """The new end Location after the edit is complete.""" + replaced_text: str + """The text that was replaced.""" + + +@lru_cache(maxsize=1024) +def _utf8_encode(text: str) -> bytes: + """Encode the input text as utf-8 bytes. + + The returned encoded bytes may be retrieved from a cache. + + Args: + text: The text to encode. + + Returns: + The utf-8 bytes representing the input string. + """ + return text.encode("utf-8") + + +def _detect_newline_style(text: str) -> Newline: + """Return the newline type used in this document. + + Args: + text: The text to inspect. + + Returns: + The NewlineStyle used in the file. + """ + if "\r\n" in text: # Windows newline + return "\r\n" + elif "\n" in text: # Unix/Linux/MacOS newline + return "\n" + elif "\r" in text: # Old MacOS newline + return "\r" + else: + return "\n" # Default to Unix style newline + + +class DocumentBase(ABC): + """Describes the minimum functionality a Document implementation must + provide in order to be used by the TextArea widget.""" + + @abstractmethod + def replace_range(self, start: Location, end: Location, text: str) -> EditResult: + """Replace the text at the given range. + + Args: + start: A tuple (row, column) where the edit starts. + end: A tuple (row, column) where the edit ends. + text: The text to insert between start and end. + + Returns: + The new end location after the edit is complete. + """ + + @property + @abstractmethod + def text(self) -> str: + """The text from the document as a string.""" + + @property + @abstractmethod + def newline(self) -> Newline: + """Return the line separator used in the document.""" + + @abstractmethod + def get_line(self, index: int) -> str: + """Returns the line with the given index from the document. + + This is used in rendering lines, and will be called by the + TextArea for each line that is rendered. + + Args: + index: The index of the line in the document. + + Returns: + The str instance representing the line. + """ + + @abstractmethod + def get_text_range(self, start: Location, end: Location) -> str: + """Get the text that falls between the start and end locations. + + Args: + start: The start location of the selection. + end: The end location of the selection. + + Returns: + The text between start (inclusive) and end (exclusive). + """ + + @abstractmethod + def get_size(self, indent_width: int) -> Size: + """Get the size of the document. + + The height is generally the number of lines, and the width + is generally the maximum cell length of all the lines. + + Args: + indent_width: The width to use for tab characters. + + Returns: + The Size of the document bounding box. + """ + + def query_syntax_tree( + self, + query: "Query", + start_point: tuple[int, int] | None = None, + end_point: tuple[int, int] | None = None, + ) -> list[tuple["Node", str]]: + """Query the tree-sitter syntax tree. + + The default implementation always returns an empty list. + + To support querying in a subclass, this must be implemented. + + Args: + query: The tree-sitter Query to perform. + start_point: The (row, column byte) to start the query at. + end_point: The (row, column byte) to end the query at. + + Returns: + A tuple containing the nodes and text captured by the query. + """ + return [] + + def prepare_query(self, query: str) -> "Query" | None: + return None + + @property + @abstractmethod + def line_count(self) -> int: + """Returns the number of lines in the document.""" + + @overload + def __getitem__(self, line_index: int) -> str: + ... + + @overload + def __getitem__(self, line_index: slice) -> list[str]: + ... + + @abstractmethod + def __getitem__(self, line_index: int | slice) -> str | list[str]: + """Return the content of a line as a string, excluding newline characters. + + Args: + line_index: The index or slice of the line(s) to retrieve. + + Returns: + The line or list of lines requested. + """ + + +class Document(DocumentBase): + """A document which can be opened in a TextArea.""" + + def __init__(self, text: str) -> None: + self._newline = _detect_newline_style(text) + """The type of newline used in the text.""" + self._lines: list[str] = text.splitlines(keepends=False) + """The lines of the document, excluding newline characters. + + If there's a newline at the end of the file, the final line is an empty string. + """ + if text.endswith(tuple(VALID_NEWLINES)) or not text: + self._lines.append("") + + @property + def lines(self) -> list[str]: + """Get the document as a list of strings, where each string represents a line. + + Newline characters are not included in at the end of the strings. + + The newline character used in this document can be found via the `Document.newline` property. + """ + return self._lines + + @property + def text(self) -> str: + """Get the text from the document.""" + return self._newline.join(self._lines) + + @property + def newline(self) -> Newline: + """Get the Newline used in this document (e.g. '\r\n', '\n'. etc.)""" + return self._newline + + def get_size(self, tab_width: int) -> Size: + """The Size of the document, taking into account the tab rendering width. + + Args: + tab_width: The width to use for tab indents. + + Returns: + The size (width, height) of the document. + """ + lines = self._lines + cell_lengths = [cell_len(line.expandtabs(tab_width)) for line in lines] + max_cell_length = max(cell_lengths, default=0) + height = len(lines) + return Size(max_cell_length, height) + + def replace_range(self, start: Location, end: Location, text: str) -> EditResult: + """Replace text at the given range. + + Args: + start: A tuple (row, column) where the edit starts. + end: A tuple (row, column) where the edit ends. + text: The text to insert between start and end. + + Returns: + The EditResult containing information about the completed + replace operation. + """ + top, bottom = sorted((start, end)) + top_row, top_column = top + bottom_row, bottom_column = bottom + + insert_lines = text.splitlines() + if text.endswith(tuple(VALID_NEWLINES)): + # Special case where a single newline character is inserted. + insert_lines.append("") + + lines = self._lines + + replaced_text = self.get_text_range(top, bottom) + if bottom_row >= len(lines): + after_selection = "" + else: + after_selection = lines[bottom_row][bottom_column:] + + if top_row >= len(lines): + before_selection = "" + else: + before_selection = lines[top_row][:top_column] + + if insert_lines: + insert_lines[0] = before_selection + insert_lines[0] + destination_column = len(insert_lines[-1]) + insert_lines[-1] = insert_lines[-1] + after_selection + else: + destination_column = len(before_selection) + insert_lines = [before_selection + after_selection] + + lines[top_row : bottom_row + 1] = insert_lines + destination_row = top_row + len(insert_lines) - 1 + + end_location = (destination_row, destination_column) + return EditResult(end_location, replaced_text) + + def get_text_range(self, start: Location, end: Location) -> str: + """Get the text that falls between the start and end locations. + + Returns the text between `start` and `end`, including the appropriate + line separator character as specified by `Document._newline`. Note that + `_newline` is set automatically to the first line separator character + found in the document. + + Args: + start: The start location of the selection. + end: The end location of the selection. + + Returns: + The text between start (inclusive) and end (exclusive). + """ + if start == end: + return "" + + top, bottom = sorted((start, end)) + top_row, top_column = top + bottom_row, bottom_column = bottom + lines = self._lines + if top_row == bottom_row: + line = lines[top_row] + selected_text = line[top_column:bottom_column] + else: + start_line = lines[top_row] + end_line = lines[bottom_row] if bottom_row <= self.line_count - 1 else "" + selected_text = start_line[top_column:] + for row in range(top_row + 1, bottom_row): + selected_text += self._newline + lines[row] + + if bottom_row < self.line_count: + selected_text += self._newline + selected_text += end_line[:bottom_column] + + return selected_text + + @property + def line_count(self) -> int: + """Returns the number of lines in the document.""" + return len(self._lines) + + def get_line(self, index: int) -> str: + """Returns the line with the given index from the document. + + Args: + index: The index of the line in the document. + + Returns: + The string representing the line. + """ + line_string = self[index] + return line_string + + @overload + def __getitem__(self, line_index: int) -> str: + ... + + @overload + def __getitem__(self, line_index: slice) -> list[str]: + ... + + def __getitem__(self, line_index: int | slice) -> str | list[str]: + """Return the content of a line as a string, excluding newline characters. + + Args: + line_index: The index or slice of the line(s) to retrieve. + + Returns: + The line or list of lines requested. + """ + return self._lines[line_index] + + +Location = Tuple[int, int] +"""A location (row, column) within the document. Indexing starts at 0.""" + + +class Selection(NamedTuple): + """A range of characters within a document from a start point to the end point. + The location of the cursor is always considered to be the `end` point of the selection. + The selection is inclusive of the minimum point and exclusive of the maximum point. + """ + + start: Location = (0, 0) + """The start location of the selection. + + If you were to click and drag a selection inside a text-editor, this is where you *started* dragging. + """ + end: Location = (0, 0) + """The end location of the selection. + + If you were to click and drag a selection inside a text-editor, this is where you *finished* dragging. + """ + + @classmethod + def cursor(cls, location: Location) -> "Selection": + """Create a Selection with the same start and end point - a "cursor". + + Args: + location: The location to create the zero-width Selection. + """ + return cls(location, location) + + @property + def is_empty(self) -> bool: + """Return True if the selection has 0 width, i.e. it's just a cursor.""" + start, end = self + return start == end diff --git a/src/textual/document/_languages.py b/src/textual/document/_languages.py new file mode 100644 index 000000000..a33f7544e --- /dev/null +++ b/src/textual/document/_languages.py @@ -0,0 +1,13 @@ +BUILTIN_LANGUAGES = sorted( + [ + "markdown", + "yaml", + "sql", + "css", + "html", + "json", + "python", + "regex", + "toml", + ] +) diff --git a/src/textual/document/_syntax_aware_document.py b/src/textual/document/_syntax_aware_document.py new file mode 100644 index 000000000..3fd828ae4 --- /dev/null +++ b/src/textual/document/_syntax_aware_document.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +try: + from tree_sitter import Language, Node, Parser, Tree + from tree_sitter.binding import Query + from tree_sitter_languages import get_language, get_parser + + TREE_SITTER = True +except ImportError: + TREE_SITTER = False + +from textual.document._document import Document, EditResult, Location, _utf8_encode +from textual.document._languages import BUILTIN_LANGUAGES + + +class SyntaxAwareDocumentError(Exception): + """General error raised when SyntaxAwareDocument is used incorrectly.""" + + +class SyntaxAwareDocument(Document): + """A wrapper around a Document which also maintains a tree-sitter syntax + tree when the document is edited. + + The primary reason for this split is actually to keep tree-sitter stuff separate, + since it isn't supported in Python 3.7. By having the tree-sitter code + isolated in this subclass, it makes it easier to conditionally import. However, + it does come with other design flaws (e.g. Document is required to have methods + which only really make sense on SyntaxAwareDocument). + + If you're reading this and Python 3.7 is no longer supported by Textual, + consider merging this subclass into the `Document` superclass. + """ + + def __init__( + self, + text: str, + language: str | Language, + ): + """Construct a SyntaxAwareDocument. + + Args: + text: The initial text contained in the document. + language: The language to use. You can pass a string to use a supported + language, or pass in your own tree-sitter `Language` object. + """ + + if not TREE_SITTER: + raise RuntimeError("SyntaxAwareDocument unavailable.") + + super().__init__(text) + self.language: Language | None = None + """The tree-sitter Language or None if tree-sitter is unavailable.""" + + self._parser: Parser | None = None + """The tree-sitter Parser or None if tree-sitter is unavailable.""" + + # If the language is `None`, then avoid doing any parsing related stuff. + if isinstance(language, str): + if language not in BUILTIN_LANGUAGES: + raise SyntaxAwareDocumentError(f"Invalid language {language!r}") + self.language = get_language(language) + self._parser = get_parser(language) + else: + self.language = language + self._parser = Parser() + self._parser.set_language(language) + + self._syntax_tree: Tree = self._parser.parse(self._read_callable) # type: ignore + """The tree-sitter Tree (syntax tree) built from the document.""" + + @property + def language_name(self) -> str | None: + return self.language.name if self.language else None + + def prepare_query(self, query: str) -> Query | None: + """Prepare a tree-sitter tree query. + + Queries should be prepared once, then reused. + + To execute a query, call `query_syntax_tree`. + + Args: + The string query to prepare. + + Returns: + The prepared query. + """ + if not TREE_SITTER: + raise SyntaxAwareDocumentError( + "Couldn't prepare query - tree-sitter is not available on this architecture." + ) + + if self.language is None: + raise SyntaxAwareDocumentError( + "Couldn't prepare query - no language assigned." + ) + + return self.language.query(query) + + def query_syntax_tree( + self, + query: Query, + start_point: tuple[int, int] | None = None, + end_point: tuple[int, int] | None = None, + ) -> list[tuple["Node", str]]: + """Query the tree-sitter syntax tree. + + The default implementation always returns an empty list. + + To support querying in a subclass, this must be implemented. + + Args: + query: The tree-sitter Query to perform. + start_point: The (row, column byte) to start the query at. + end_point: The (row, column byte) to end the query at. + + Returns: + A tuple containing the nodes and text captured by the query. + """ + + if not TREE_SITTER: + raise SyntaxAwareDocumentError( + "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 + + def replace_range(self, start: Location, end: Location, text: str) -> EditResult: + """Replace text at the given range. + + Args: + start: A tuple (row, column) where the edit starts. + end: A tuple (row, column) where the edit ends. + text: The text to insert between start and end. + + Returns: + The new end location after the edit is complete. + """ + top, bottom = sorted((start, end)) + + # An optimisation would be finding the byte offsets as a single operation rather + # than doing two passes over the document content. + start_byte = self._location_to_byte_offset(top) + start_point = self._location_to_point(top) + old_end_byte = self._location_to_byte_offset(bottom) + old_end_point = self._location_to_point(bottom) + + replace_result = super().replace_range(start, end, text) + + text_byte_length = len(_utf8_encode(text)) + end_location = replace_result.end_location + assert self._syntax_tree is not None + assert self._parser is not None + self._syntax_tree.edit( + start_byte=start_byte, + old_end_byte=old_end_byte, + new_end_byte=start_byte + text_byte_length, + start_point=start_point, + old_end_point=old_end_point, + new_end_point=self._location_to_point(end_location), + ) + # Incrementally parse the document. + self._syntax_tree = self._parser.parse( + self._read_callable, self._syntax_tree # type: ignore[arg-type] + ) + + return replace_result + + def get_line(self, line_index: int) -> str: + """Return the string representing the line, not including new line characters. + + Args: + line_index: The index of the line. + + Returns: + The string representing the line. + """ + line_string = self[line_index] + return line_string + + def _location_to_byte_offset(self, location: Location) -> int: + """Given a document coordinate, return the byte offset of that coordinate. + This method only does work if tree-sitter was imported, otherwise it returns 0. + + Args: + location: The location to convert. + + Returns: + An integer byte offset for the given location. + """ + lines = self._lines + row, column = location + lines_above = lines[:row] + end_of_line_width = len(self.newline) + bytes_lines_above = sum( + len(_utf8_encode(line)) + end_of_line_width for line in lines_above + ) + if row < len(lines): + bytes_on_left = len(_utf8_encode(lines[row][:column])) + else: + bytes_on_left = 0 + byte_offset = bytes_lines_above + bytes_on_left + return byte_offset + + def _location_to_point(self, location: Location) -> tuple[int, int]: + """Convert a document location (row_index, column_index) to a tree-sitter + point (row_index, byte_offset_from_start_of_row). If tree-sitter isn't available + returns (0, 0). + + Args: + location: A location (row index, column codepoint offset) + + Returns: + The point corresponding to that location (row index, column byte offset). + """ + lines = self._lines + row, column = location + if row < len(lines): + bytes_on_left = len(_utf8_encode(lines[row][:column])) + else: + bytes_on_left = 0 + return row, bytes_on_left + + def _read_callable(self, byte_offset: int, point: tuple[int, int]) -> bytes: + """A callable which informs tree-sitter about the document content. + + This is passed to tree-sitter which will call it frequently to retrieve + the bytes from the document. + + Args: + byte_offset: The number of (utf-8) bytes from the start of the document. + point: A tuple (row index, column *byte* offset). Note that this differs + from our Location tuple which is (row_index, column codepoint offset). + + Returns: + All the utf-8 bytes between the byte_offset/point and the end of the current + line _including_ the line separator character(s). Returns None if the + offset/point requested by tree-sitter doesn't correspond to a byte. + """ + row, column = point + lines = self._lines + newline = self.newline + + row_out_of_bounds = row >= len(lines) + if row_out_of_bounds: + return b"" + else: + row_text = lines[row] + + encoded_row = _utf8_encode(row_text) + encoded_row_length = len(encoded_row) + + if column < encoded_row_length: + return encoded_row[column:] + _utf8_encode(newline) + elif column == encoded_row_length: + return _utf8_encode(newline[0]) + elif column == encoded_row_length + 1: + if newline == "\r\n": + return b"\n" + + return b"" diff --git a/src/textual/events.py b/src/textual/events.py index af8aaf053..7cff7d01d 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -417,6 +417,19 @@ class MouseEvent(InputEvent, bubble=True): """ if self.screen_offset not in widget.content_region: return None + return self.get_content_offset_capture(widget) + + def get_content_offset_capture(self, widget: Widget) -> Offset: + """Get offset from a widget's content area. + + This method works even if the offset is outside the widget content region. + + Args: + widget: Widget receiving the event. + + Returns: + An offset where the origin is at the top left of the content area. + """ return self.offset - widget.gutter.top_left def _apply_offset(self, x: int, y: int) -> MouseEvent: diff --git a/src/textual/expand_tabs.py b/src/textual/expand_tabs.py new file mode 100644 index 000000000..9227f796c --- /dev/null +++ b/src/textual/expand_tabs.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import re + +from rich.cells import cell_len + +_TABS_SPLITTER_RE = re.compile(r"(.*?\t|.+?$)") + + +def expand_tabs_inline(line: str, tab_size: int = 4) -> str: + """Expands tabs, taking into account double cell characters. + + Args: + line: The text to expand tabs in. + tab_size: Number of cells in a tab. + Returns: + New string with tabs replaced with spaces. + """ + if "\t" not in line: + return line + new_line_parts: list[str] = [] + add_part = new_line_parts.append + cell_position = 0 + parts = _TABS_SPLITTER_RE.findall(line) + + for part in parts: + if part.endswith("\t"): + part = f"{part[:-1]} " + cell_position += cell_len(part) + tab_remainder = cell_position % tab_size + if tab_remainder: + spaces = tab_size - tab_remainder + part += spaces * " " + add_part(part) + + return "".join(new_line_parts) + + +if __name__ == "__main__": + print(expand_tabs_inline("\tbar")) + print(expand_tabs_inline("1\tbar")) + print(expand_tabs_inline("12\tbar")) + print(expand_tabs_inline("123\tbar")) + print(expand_tabs_inline("1234\tbar")) + print(expand_tabs_inline("💩\tbar")) + print(expand_tabs_inline("💩💩\tbar")) + print(expand_tabs_inline("💩💩💩\tbar")) + print(expand_tabs_inline("F💩\tbar")) + print(expand_tabs_inline("F💩O\tbar")) diff --git a/src/textual/screen.py b/src/textual/screen.py index b93e9dc46..631dadb4b 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -953,7 +953,9 @@ class Screen(Generic[ScreenResultType], Widget): except errors.NoWidget: self.set_focus(None) else: - if isinstance(event, events.MouseUp) and widget.focusable: + if isinstance(event, events.MouseDown) and widget.focusable: + self.set_focus(widget) + elif isinstance(event, events.MouseUp) and widget.focusable: if self.focused is not widget: self.set_focus(widget) event.stop() diff --git a/src/textual/widget.py b/src/textual/widget.py index b6afa893b..b229c7073 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3191,7 +3191,7 @@ class Widget(DOMNode): def begin_capture_print(self, stdout: bool = True, stderr: bool = True) -> None: """Capture text from print statements (or writes to stdout / stderr). - If printing is captured, the widget will be send an [events.Print][textual.events.Print] message. + If printing is captured, the widget will be sent an [events.Print][textual.events.Print] message. Call [end_capture_print][textual.widget.Widget.end_capture_print] to disable print capture. diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index af7bd8968..cd6e21f13 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -41,6 +41,7 @@ if typing.TYPE_CHECKING: from ._switch import Switch from ._tabbed_content import TabbedContent, TabPane from ._tabs import Tab, Tabs + from ._text_area import TextArea from ._tooltip import Tooltip from ._tree import Tree from ._welcome import Welcome @@ -79,6 +80,7 @@ __all__ = [ "TabbedContent", "TabPane", "Tabs", + "TextArea", "RichLog", "Tooltip", "Tree", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index a6f22febc..d4db2f8f5 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -33,6 +33,7 @@ from ._tabbed_content import TabbedContent as TabbedContent from ._tabbed_content import TabPane as TabPane from ._tabs import Tab as Tab from ._tabs import Tabs as Tabs +from ._text_area import TextArea as TextArea from ._tooltip import Tooltip as Tooltip from ._tree import Tree as Tree from ._welcome import Welcome as Welcome diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py new file mode 100644 index 000000000..f40478f08 --- /dev/null +++ b/src/textual/widgets/_text_area.py @@ -0,0 +1,1865 @@ +from __future__ import annotations + +import re +from collections import defaultdict +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path +from typing import TYPE_CHECKING, Any, Iterable, Optional, Tuple + +from rich.style import Style +from rich.text import Text + +from textual._text_area_theme import TextAreaTheme +from textual._tree_sitter import TREE_SITTER +from textual.color import Color +from textual.document._document import ( + Document, + DocumentBase, + EditResult, + Location, + Selection, + _utf8_encode, +) +from textual.document._languages import BUILTIN_LANGUAGES +from textual.document._syntax_aware_document import ( + SyntaxAwareDocument, + SyntaxAwareDocumentError, +) +from textual.expand_tabs import expand_tabs_inline + +if TYPE_CHECKING: + from tree_sitter import Language + from tree_sitter.binding import Query + +from textual import events, log +from textual._cells import cell_len +from textual._types import Literal, Protocol, runtime_checkable +from textual.binding import Binding +from textual.events import MouseEvent +from textual.geometry import Offset, Region, Size, Spacing, clamp +from textual.reactive import Reactive, reactive +from textual.scroll_view import ScrollView +from textual.strip import Strip + +_OPENING_BRACKETS = {"{": "}", "[": "]", "(": ")"} +_CLOSING_BRACKETS = {v: k for k, v in _OPENING_BRACKETS.items()} +_TREE_SITTER_PATH = Path(__file__) / "../../../../tree-sitter/" +_HIGHLIGHTS_PATH = _TREE_SITTER_PATH / "highlights/" + +StartColumn = int +EndColumn = Optional[int] +HighlightName = str +Highlight = Tuple[StartColumn, EndColumn, HighlightName] +"""A tuple representing a syntax highlight within one line.""" + + +class ThemeDoesNotExist(Exception): + """Raised when the user tries to use a theme which does not exist. + This means a theme which is not builtin, or has not been registered. + """ + + pass + + +class LanguageDoesNotExist(Exception): + """Raised when the user tries to use a language which does not exist. + This means a language which is not builtin, or has not been registered. + """ + + pass + + +@dataclass +class TextAreaLanguage: + """A container for a language which has been registered with the TextArea. + + Attributes: + name: The name of the language. + language: The tree-sitter Language. + highlight_query: The tree-sitter highlight query corresponding to the language, as a string. + """ + + name: str + language: "Language" + highlight_query: str + + +class TextArea(ScrollView, can_focus=True): + DEFAULT_CSS = """\ +TextArea { + width: 1fr; + height: 1fr; +} +""" + + BINDINGS = [ + Binding("escape", "screen.focus_next", "Shift Focus", show=False), + # Cursor movement + Binding("up", "cursor_up", "cursor up", show=False), + Binding("down", "cursor_down", "cursor down", show=False), + Binding("left", "cursor_left", "cursor left", show=False), + Binding("right", "cursor_right", "cursor right", show=False), + Binding("ctrl+left", "cursor_word_left", "cursor word left", show=False), + Binding("ctrl+right", "cursor_word_right", "cursor word right", show=False), + Binding("home,ctrl+a", "cursor_line_start", "cursor line start", show=False), + Binding("end,ctrl+e", "cursor_line_end", "cursor line end", show=False), + Binding("pageup", "cursor_page_up", "cursor page up", show=False), + Binding("pagedown", "cursor_page_down", "cursor page down", show=False), + # Making selections (generally holding the shift key and moving cursor) + Binding( + "ctrl+shift+left", + "cursor_word_left(True)", + "cursor left word select", + show=False, + ), + Binding( + "ctrl+shift+right", + "cursor_word_right(True)", + "cursor right word select", + show=False, + ), + Binding( + "shift+home", + "cursor_line_start(True)", + "cursor line start select", + show=False, + ), + Binding( + "shift+end", "cursor_line_end(True)", "cursor line end select", show=False + ), + Binding("shift+up", "cursor_up(True)", "cursor up select", show=False), + Binding("shift+down", "cursor_down(True)", "cursor down select", show=False), + Binding("shift+left", "cursor_left(True)", "cursor left select", show=False), + Binding("shift+right", "cursor_right(True)", "cursor right select", show=False), + # Shortcut ways of making selections + # Binding("f5", "select_word", "select word", show=False), + Binding("f6", "select_line", "select line", show=False), + Binding("f7", "select_all", "select all", show=False), + # Deletion + Binding("backspace", "delete_left", "delete left", show=False), + Binding( + "ctrl+w", "delete_word_left", "delete left to start of word", show=False + ), + Binding("delete,ctrl+d", "delete_right", "delete right", show=False), + Binding( + "ctrl+f", "delete_word_right", "delete right to start of word", show=False + ), + Binding("ctrl+x", "delete_line", "delete line", show=False), + Binding( + "ctrl+u", "delete_to_start_of_line", "delete to line start", show=False + ), + Binding("ctrl+k", "delete_to_end_of_line", "delete to line end", show=False), + ] + """ + | Key(s) | Description | + | :- | :- | + | escape | Focus on the next item. | + | up | Move the cursor up. | + | down | Move the cursor down. | + | left | Move the cursor left. | + | ctrl+left | Move the cursor to the start of the word. | + | ctrl+shift+left | Move the cursor to the start of the word and select. | + | right | Move the cursor right. | + | ctrl+right | Move the cursor to the end of the word. | + | ctrl+shift+right | Move the cursor to the end of the word and select. | + | home,ctrl+a | Move the cursor to the start of the line. | + | end,ctrl+e | Move the cursor to the end of the line. | + | shift+home | Move the cursor to the start of the line and select. | + | shift+end | Move the cursor to the end of the line and select. | + | pageup | Move the cursor one page up. | + | pagedown | Move the cursor one page down. | + | shift+up | Select while moving the cursor up. | + | shift+down | Select while moving the cursor down. | + | shift+left | Select while moving the cursor left. | + | shift+right | Select while moving the cursor right. | + | backspace | Delete character to the left of cursor. | + | ctrl+w | Delete from cursor to start of the word. | + | delete,ctrl+d | Delete character to the right of cursor. | + | ctrl+f | Delete from cursor to end of the word. | + | ctrl+x | Delete the current line. | + | ctrl+u | Delete from cursor to the start of the line. | + | ctrl+k | Delete from cursor to the end of the line. | + | f6 | Select the current line. | + | f7 | Select all text in the document. | + """ + + language: Reactive[str | None] = reactive(None, always_update=True, init=False) + """The language to use. + + This must be set to a valid, non-None value for syntax highlighting to work. + + If the value is a string, a built-in language parser will be used if available. + + If you wish to use an unsupported language, you'll have to register + it first using [`TextArea.register_language`][textual.widgets._text_area.TextArea.register_language]. + """ + + theme: Reactive[str | None] = reactive(None, always_update=True, init=False) + """The name of the theme to use. + + Themes must be registered using [`TextArea.register_theme`][textual.widgets._text_area.TextArea.register_theme] before they can be used. + + Syntax highlighting is only possible when the `language` attribute is set. + """ + + selection: Reactive[Selection] = reactive(Selection(), always_update=True) + """The selection start and end locations (zero-based line_index, offset). + + This represents the cursor location and the current selection. + + The `Selection.end` always refers to the cursor location. + + If no text is selected, then `Selection.end == Selection.start` is True. + + The text selected in the document is available via the `TextArea.selected_text` property. + """ + + show_line_numbers: Reactive[bool] = reactive(True) + """True to show the line number column on the left edge, otherwise False. + + Changing this value will immediately re-render the `TextArea`.""" + + indent_width: Reactive[int] = reactive(4) + """The width of tabs or the multiple of spaces to align to on pressing the `tab` key. + + If the document currently open contains tabs that are currently visible on screen, + altering this value will immediately change the display width of the visible tabs. + """ + + match_cursor_bracket: Reactive[bool] = reactive(True) + """If the cursor is at a bracket, highlight the matching bracket (if found).""" + + cursor_blink: Reactive[bool] = reactive(True) + """True if the cursor should blink.""" + + _cursor_blink_visible: Reactive[bool] = reactive(True, repaint=False) + """Indicates where the cursor is in the blink cycle. If it's currently + not visible due to blinking, this is False.""" + + def __init__( + self, + text: str = "", + *, + language: str | None = None, + theme: str | None = None, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Construct a new `TextArea`. + + Args: + text: The initial text to load into the TextArea. + language: The language to use. + theme: The theme to use. + 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. + disabled: True if the widget is disabled. + """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self._initial_text = text + + self._languages: dict[str, TextAreaLanguage] = {} + """Maps language names to TextAreaLanguage.""" + + self._themes: dict[str, TextAreaTheme] = {} + """Maps theme names to TextAreaTheme.""" + + self.indent_type: Literal["tabs", "spaces"] = "spaces" + """Whether to indent using tabs or spaces.""" + + self._word_pattern = re.compile(r"(?<=\W)(?=\w)|(?<=\w)(?=\W)") + """Compiled regular expression for what we consider to be a 'word'.""" + + self._last_intentional_cell_width: int = 0 + """Tracks the last column (measured in terms of cell length, since we care here about where the cursor + visually moves rather than logical characters) the user explicitly navigated to so that we can reset to it + whenever possible.""" + + self._undo_stack: list[Undoable] = [] + """A stack (the end of the list is the top of the stack) for tracking edits.""" + + self._selecting = False + """True if we're currently selecting text using the mouse, otherwise False.""" + + self._matching_bracket_location: Location | None = None + """The location (row, column) of the bracket which matches the bracket the + 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._theme: TextAreaTheme | None = None + """The `TextAreaTheme` corresponding to the set theme name. When the `theme` + reactive is set as a string, the watcher will update this attribute to the + corresponding `TextAreaTheme` object.""" + + self.language = language + """The language of the `TextArea`.""" + + self.theme: str | None = theme + """The name of the theme of the `TextArea` as set by the user.""" + + @staticmethod + def _get_builtin_highlight_query(language_name: str) -> str: + """Get the highlight query for a builtin language. + + Args: + language_name: The name of the builtin language. + + Returns: + The highlight query. + """ + try: + highlight_query_path = ( + Path(_HIGHLIGHTS_PATH.resolve()) / f"{language_name}.scm" + ) + highlight_query = highlight_query_path.read_text() + except OSError: + highlight_query = "" + + return highlight_query + + 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 capture in captures: + node, highlight_name = capture + 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)) + + def _watch_selection(self, selection: Selection) -> None: + """When the cursor moves, scroll it into view.""" + self.scroll_cursor_visible() + cursor_location = selection.end + cursor_row, cursor_column = cursor_location + + try: + character = self.document[cursor_row][cursor_column] + except IndexError: + character = "" + + # Record the location of a matching closing/opening bracket. + match_location = self.find_matching_bracket(character, cursor_location) + self._matching_bracket_location = match_location + if match_location is not None: + match_row, match_column = match_location + if match_row in range(*self._visible_line_indices): + self.refresh_lines(match_row) + + def find_matching_bracket( + self, bracket: str, search_from: Location + ) -> Location | None: + """If the character is a bracket, find the matching bracket. + + Args: + bracket: The character we're searching for the matching bracket of. + search_from: The location to start the search. + + Returns: + The `Location` of the matching bracket, or `None` if it's not found. + If the character is not available for bracket matching, `None` is returned. + """ + match_location = None + bracket_stack = [] + if bracket in _OPENING_BRACKETS: + for candidate, candidate_location in self._yield_character_locations( + search_from + ): + if candidate in _OPENING_BRACKETS: + bracket_stack.append(candidate) + elif candidate in _CLOSING_BRACKETS: + if ( + bracket_stack + and bracket_stack[-1] == _CLOSING_BRACKETS[candidate] + ): + bracket_stack.pop() + if not bracket_stack: + match_location = candidate_location + break + elif bracket in _CLOSING_BRACKETS: + for ( + candidate, + candidate_location, + ) in self._yield_character_locations_reverse(search_from): + if candidate in _CLOSING_BRACKETS: + bracket_stack.append(candidate) + elif candidate in _OPENING_BRACKETS: + if ( + bracket_stack + and bracket_stack[-1] == _OPENING_BRACKETS[candidate] + ): + bracket_stack.pop() + if not bracket_stack: + match_location = candidate_location + break + + return match_location + + def _validate_selection(self, selection: Selection) -> Selection: + """Clamp the selection to valid locations.""" + start, end = selection + clamp_visitable = self.clamp_visitable + return Selection(clamp_visitable(start), clamp_visitable(end)) + + def _watch_language(self, language: str | None) -> None: + """When the language is updated, update the type of document.""" + if language is not None and language not in self.available_languages: + raise LanguageDoesNotExist( + f"{language!r} is not a builtin language, or it has not been registered. " + f"To use a custom language, register it first using `register_language`, " + f"then switch to it by setting the `TextArea.language` attribute." + ) + + self._set_document( + self.document.text if self.document is not None else self._initial_text, + language, + ) + self._initial_text = "" + + def _watch_show_line_numbers(self) -> None: + """The line number gutter contributes to virtual size, so recalculate.""" + self._refresh_size() + + def _watch_indent_width(self) -> None: + """Changing width of tabs will change document display width.""" + self._refresh_size() + + def _watch_theme(self, theme: str | None) -> None: + """We set the styles on this widget when the theme changes, to ensure that + if padding is applied, the colours match.""" + + if theme is None: + # If the theme is None, use the default. + theme_object = TextAreaTheme.default() + else: + # If the user supplied a string theme name, find it and apply it. + try: + theme_object = self._themes[theme] + except KeyError: + theme_object = TextAreaTheme.get_builtin_theme(theme) + + if theme_object is None: + raise ThemeDoesNotExist( + f"{theme!r} is not a builtin theme, or it has not been registered. " + f"To use a custom theme, register it first using `register_theme`, " + f"then switch to that theme by setting the `TextArea.theme` attribute." + ) + + self._theme = theme_object + if theme_object: + base_style = theme_object.base_style + if base_style: + color = base_style.color + background = base_style.bgcolor + if color: + self.styles.color = Color.from_rich_color(color) + if background: + self.styles.background = Color.from_rich_color(background) + + @property + def available_themes(self) -> set[str]: + """A list of the names of the themes available to the `TextArea`. + + The values in this list can be assigned `theme` reactive attribute of + `TextArea`. + + You can retrieve the full specification for a theme by passing one of + the strings from this list into `TextAreaTheme.get_by_name(theme_name: str)`. + + Alternatively, you can directly retrieve a list of `TextAreaTheme` objects + (which contain the full theme specification) by calling + `TextAreaTheme.builtin_themes()`. + """ + return { + theme.name for theme in TextAreaTheme.builtin_themes() + } | self._themes.keys() + + def register_theme(self, theme: TextAreaTheme) -> None: + """Register a theme for use by the `TextArea`. + + After registering a theme, you can set themes by assigning the theme + name to the `TextArea.theme` reactive attribute. For example + `text_area.theme = "my_custom_theme"` where `"my_custom_theme"` is the + name of the theme you registered. + + If you supply a theme with a name that already exists that theme + will be overwritten. + """ + self._themes[theme.name] = theme + + @property + def available_languages(self) -> set[str]: + """A list of the names of languages available to the `TextArea`. + + The values in this list can be assigned to the `language` reactive attribute + of `TextArea`. + + The returned list contains the builtin languages plus those registered via the + `register_language` method. Builtin languages will be listed before + user-registered languages, but there are no other ordering guarantees. + """ + return set(BUILTIN_LANGUAGES) | self._languages.keys() + + def register_language( + self, + language: str | "Language", + highlight_query: str, + ) -> None: + """Register a language and corresponding highlight query. + + Calling this method does not change the language of the `TextArea`. + On switching to this language (via the `language` reactive attribute), + syntax highlighting will be performed using the given highlight query. + + If a string `name` is supplied for a builtin supported language, then + this method will update the default highlight query for that language. + + Registering a language only registers it to this instance of `TextArea`. + + Args: + language: A string referring to a builtin language or a tree-sitter `Language` object. + highlight_query: The highlight query to use for syntax highlighting this language. + """ + + # If tree-sitter is unavailable, do nothing. + if not TREE_SITTER: + return + + from tree_sitter_languages import get_language + + if isinstance(language, str): + language_name = language + language = get_language(language_name) + else: + language_name = language.name + + # Update the custom languages. When changing the document, + # we should first look in here for a language specification. + # If nothing is found, then we can go to the builtin languages. + self._languages[language_name] = TextAreaLanguage( + name=language_name, + language=language, + highlight_query=highlight_query, + ) + # If we updated the currently set language, rebuild the highlights + # using the newly updated highlights query. + if language_name == self.language: + self._set_document(self.text, language_name) + + def _set_document(self, text: str, language: str | None) -> None: + """Construct and return an appropriate document. + + Args: + text: The text of the document. + language: The name of the language to use. This must either be a + built-in supported language, or a language previously registered + via the `register_language` method. + """ + self._highlight_query = None + if TREE_SITTER and language: + # Attempt to get the override language. + text_area_language = self._languages.get(language, None) + document_language: str | "Language" + if text_area_language: + document_language = text_area_language.language + highlight_query = text_area_language.highlight_query + else: + document_language = language + highlight_query = self._get_builtin_highlight_query(language) + document: DocumentBase + try: + document = SyntaxAwareDocument(text, document_language) + except SyntaxAwareDocumentError: + document = Document(text) + log.warning( + f"Parser not found for language {document_language!r}. Parsing disabled." + ) + else: + self._highlight_query = document.prepare_query(highlight_query) + elif language and not TREE_SITTER: + log.warning( + "tree-sitter not available in this environment. Parsing disabled." + ) + document = Document(text) + else: + document = Document(text) + + self.document = document + self._build_highlight_map() + + @property + def _visible_line_indices(self) -> tuple[int, int]: + """Return the visible line indices as a tuple (top, bottom). + + Returns: + A tuple (top, bottom) indicating the top and bottom visible line indices. + """ + return self.scroll_offset.y, self.scroll_offset.y + self.size.height + + def load_text(self, text: str) -> None: + """Load text into the TextArea. + + This will replace the text currently in the TextArea. + + Args: + text: The text to load into the TextArea. + """ + self._set_document(text, self.language) + self.move_cursor((0, 0)) + self._refresh_size() + + def load_document(self, document: DocumentBase) -> None: + """Load a document into the TextArea. + + Args: + document: The document to load into the TextArea. + """ + self.document = document + self.move_cursor((0, 0)) + self._refresh_size() + + @property + def is_syntax_aware(self) -> bool: + """True if the TextArea is currently syntax aware - i.e. it's parsing document content.""" + return isinstance(self.document, SyntaxAwareDocument) + + def _yield_character_locations( + self, start: Location + ) -> Iterable[tuple[str, Location]]: + """Yields character locations starting from the given location. + + Does not yield location of line separator characters like `\\n`. + + Args: + start: The location to start yielding from. + + Returns: + Yields tuples of (character, (row, column)). + """ + row, column = start + document = self.document + line_count = document.line_count + + while 0 <= row < line_count: + line = document[row] + while column < len(line): + yield line[column], (row, column) + column += 1 + column = 0 + row += 1 + + def _yield_character_locations_reverse( + self, start: Location + ) -> Iterable[tuple[str, Location]]: + row, column = start + document = self.document + line_count = document.line_count + + while line_count > row >= 0: + line = document[row] + if column == -1: + column = len(line) - 1 + while column >= 0: + yield line[column], (row, column) + column -= 1 + row -= 1 + + def _refresh_size(self) -> None: + """Update the virtual size of the TextArea.""" + width, height = self.document.get_size(self.indent_width) + # +1 width to make space for the cursor resting at the end of the line + self.virtual_size = Size(width + self.gutter_width + 1, height) + + def render_line(self, widget_y: int) -> Strip: + """Render a single line of the TextArea. Called by Textual. + + Args: + widget_y: Y Coordinate of line relative to the widget region. + + Returns: + A rendered line. + """ + document = self.document + scroll_x, scroll_y = self.scroll_offset + + # Account for how much the TextArea is scrolled. + line_index = widget_y + scroll_y + + # Render the lines beyond the valid line numbers + out_of_bounds = line_index >= document.line_count + if out_of_bounds: + return Strip.blank(self.size.width) + + theme = self._theme + + # Get the line from the Document. + line_string = document.get_line(line_index) + line = Text(line_string, end="") + + line_character_count = len(line) + line.tab_size = self.indent_width + virtual_width, virtual_height = self.virtual_size + expanded_length = max(virtual_width, self.size.width) + line.set_length(expanded_length) + + selection = self.selection + start, end = selection + selection_top, selection_bottom = sorted(selection) + selection_top_row, selection_top_column = selection_top + selection_bottom_row, selection_bottom_column = selection_bottom + + highlights = self._highlights + if highlights and theme: + line_bytes = _utf8_encode(line_string) + byte_to_codepoint = build_byte_to_codepoint_dict(line_bytes) + get_highlight_from_theme = theme.syntax_styles.get + line_highlights = highlights[line_index] + for highlight_start, highlight_end, highlight_name in line_highlights: + node_style = get_highlight_from_theme(highlight_name) + if node_style is not None: + line.stylize( + node_style, + byte_to_codepoint.get(highlight_start, 0), + byte_to_codepoint.get(highlight_end) if highlight_end else None, + ) + + cursor_row, cursor_column = end + cursor_line_style = theme.cursor_line_style if theme else None + if cursor_line_style and cursor_row == line_index: + line.stylize(cursor_line_style) + + # Selection styling + if start != end and selection_top_row <= line_index <= selection_bottom_row: + # If this row intersects with the selection range + selection_style = theme.selection_style if theme else None + cursor_row, _ = end + if selection_style: + if line_character_count == 0 and line_index != cursor_row: + # A simple highlight to show empty lines are included in the selection + line = Text("▌", end="", style=Style(color=selection_style.bgcolor)) + line.set_length(self.virtual_size.width) + else: + if line_index == selection_top_row == selection_bottom_row: + # Selection within a single line + line.stylize( + selection_style, + start=selection_top_column, + end=selection_bottom_column, + ) + else: + # Selection spanning multiple lines + if line_index == selection_top_row: + line.stylize( + selection_style, + start=selection_top_column, + end=line_character_count, + ) + elif line_index == selection_bottom_row: + line.stylize(selection_style, end=selection_bottom_column) + else: + line.stylize(selection_style, end=line_character_count) + + # Highlight the cursor + matching_bracket = self._matching_bracket_location + match_cursor_bracket = self.match_cursor_bracket + draw_matched_brackets = ( + match_cursor_bracket and matching_bracket is not None and start == end + ) + + if cursor_row == line_index: + draw_cursor = not self.cursor_blink or ( + self.cursor_blink and self._cursor_blink_visible + ) + if draw_matched_brackets: + matching_bracket_style = theme.bracket_matching_style if theme else None + if matching_bracket_style: + line.stylize( + matching_bracket_style, + cursor_column, + cursor_column + 1, + ) + + if draw_cursor: + cursor_style = theme.cursor_style if theme else None + if cursor_style: + line.stylize(cursor_style, cursor_column, cursor_column + 1) + + # Highlight the partner opening/closing bracket. + if draw_matched_brackets: + # mypy doesn't know matching bracket is guaranteed to be non-None + assert matching_bracket is not None + bracket_match_row, bracket_match_column = matching_bracket + if theme and bracket_match_row == line_index: + matching_bracket_style = theme.bracket_matching_style + if matching_bracket_style: + line.stylize( + matching_bracket_style, + bracket_match_column, + bracket_match_column + 1, + ) + + # Build the gutter text for this line + gutter_width = self.gutter_width + if self.show_line_numbers: + if cursor_row == line_index: + gutter_style = theme.cursor_line_gutter_style if theme else None + else: + gutter_style = theme.gutter_style if theme else None + + gutter_width_no_margin = gutter_width - 2 + gutter = Text( + f"{line_index + 1:>{gutter_width_no_margin}} ", + style=gutter_style or "", + end="", + ) + else: + gutter = Text("", end="") + + # Render the gutter and the text of this line + console = self.app.console + gutter_segments = console.render(gutter) + text_segments = console.render( + line, + console.options.update_width(expanded_length), + ) + + # Crop the line to show only the visible part (some may be scrolled out of view) + gutter_strip = Strip(gutter_segments, cell_length=gutter_width) + text_strip = Strip(text_segments).crop( + scroll_x, scroll_x + virtual_width - gutter_width + ) + + # Stylize the line the cursor is currently on. + if cursor_row == line_index: + text_strip = text_strip.extend_cell_length( + expanded_length, cursor_line_style + ) + else: + text_strip = text_strip.extend_cell_length( + expanded_length, theme.base_style if theme else None + ) + + # Join and return the gutter and the visible portion of this line + strip = Strip.join([gutter_strip, text_strip]).simplify() + + return strip.apply_style( + theme.base_style + if theme and theme.base_style is not None + else self.rich_style + ) + + @property + def text(self) -> str: + """The entire text content of the document.""" + return self.document.text + + @property + def selected_text(self) -> str: + """The text between the start and end points of the current selection.""" + start, end = self.selection + return self.get_text_range(start, end) + + def get_text_range(self, start: Location, end: Location) -> str: + """Get the text between a start and end location. + + Args: + start: The start location. + end: The end location. + + Returns: + The text between start and end. + """ + start, end = sorted((start, end)) + return self.document.get_text_range(start, end) + + def edit(self, edit: Edit) -> Any: + """Perform an Edit. + + Args: + edit: The Edit to perform. + + Returns: + Data relating to the edit that may be useful. The data returned + may be different depending on the edit performed. + """ + result = edit.do(self) + self._refresh_size() + edit.after(self) + self._build_highlight_map() + return result + + async def _on_key(self, event: events.Key) -> None: + """Handle key presses which correspond to document inserts.""" + key = event.key + insert_values = { + "tab": " " * self._find_columns_to_next_tab_stop(), + "enter": "\n", + } + self._restart_blink() + if event.is_printable or key in insert_values: + event.stop() + event.prevent_default() + insert = insert_values.get(key, event.character) + # `insert` is not None because event.character cannot be + # None because we've checked that it's printable. + assert insert is not None + start, end = self.selection + self.replace(insert, start, end, maintain_selection_offset=False) + + def _find_columns_to_next_tab_stop(self) -> int: + """Get the location of the next tab stop after the cursors position on the current line. + + If the cursor is already at a tab stop, this returns the *next* tab stop location. + + Returns: + The number of cells to the next tab stop from the current cursor column. + """ + cursor_row, cursor_column = self.cursor_location + line_text = self.document[cursor_row] + indent_width = self.indent_width + if not line_text: + return indent_width + + width_before_cursor = self.get_column_width(cursor_row, cursor_column) + spaces_to_insert = indent_width - ( + (indent_width + width_before_cursor) % indent_width + ) + + return spaces_to_insert + + def get_target_document_location(self, event: MouseEvent) -> Location: + """Given a MouseEvent, return the row and column offset of the event in document-space. + + Args: + event: The MouseEvent. + + Returns: + The location of the mouse event within the document. + """ + scroll_x, scroll_y = self.scroll_offset + target_x = event.x - self.gutter_width + scroll_x - self.gutter.left + target_x = max(target_x, 0) + target_row = clamp( + event.y + scroll_y - self.gutter.top, + 0, + self.document.line_count - 1, + ) + target_column = self.cell_width_to_column_index(target_x, target_row) + return target_row, target_column + + # --- Lower level event/key handling + @property + def gutter_width(self) -> int: + """The width of the gutter (the left column containing line numbers). + + Returns: + The cell-width of the line number column. If `show_line_numbers` is `False` returns 0. + """ + # The longest number in the gutter plus two extra characters: `│ `. + gutter_margin = 2 + gutter_width = ( + len(str(self.document.line_count + 1)) + gutter_margin + if self.show_line_numbers + else 0 + ) + return gutter_width + + def _on_mount(self, _: events.Mount) -> None: + self.blink_timer = self.set_interval( + 0.5, + self._toggle_cursor_blink_visible, + pause=not (self.cursor_blink and self.has_focus), + ) + + def _on_blur(self, _: events.Blur) -> None: + self._pause_blink(visible=True) + + def _on_focus(self, _: events.Focus) -> None: + self._restart_blink() + + def _toggle_cursor_blink_visible(self) -> None: + """Toggle visibility of the cursor for the purposes of 'cursor blink'.""" + self._cursor_blink_visible = not self._cursor_blink_visible + cursor_row, _ = self.cursor_location + self.refresh_lines(cursor_row) + + def _restart_blink(self) -> None: + """Reset the cursor blink timer.""" + if self.cursor_blink: + self._cursor_blink_visible = True + self.blink_timer.reset() + + def _pause_blink(self, visible: bool = True) -> None: + """Pause the cursor blinking but ensure it stays visible.""" + self._cursor_blink_visible = visible + self.blink_timer.pause() + + async def _on_mouse_down(self, event: events.MouseDown) -> None: + """Update the cursor position, and begin a selection using the mouse.""" + target = self.get_target_document_location(event) + self.selection = Selection.cursor(target) + self._selecting = True + # Capture the mouse so that if the cursor moves outside the + # TextArea widget while selecting, the widget still scrolls. + self.capture_mouse() + self._pause_blink(visible=True) + + async def _on_mouse_move(self, event: events.MouseMove) -> None: + """Handles click and drag to expand and contract the selection.""" + if self._selecting: + target = self.get_target_document_location(event) + selection_start, _ = self.selection + self.selection = Selection(selection_start, target) + + async def _on_mouse_up(self, event: events.MouseUp) -> None: + """Finalise the selection that has been made using the mouse.""" + self._selecting = False + self.release_mouse() + self.record_cursor_width() + self._restart_blink() + + async def _on_paste(self, event: events.Paste) -> None: + """When a paste occurs, insert the text from the paste event into the document.""" + self.replace(event.text, *self.selection) + + def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: + """Return the column that the cell width corresponds to on the given row. + + Args: + cell_width: The cell width to convert. + row_index: The index of the row to examine. + + Returns: + The column corresponding to the cell width on that row. + """ + tab_width = self.indent_width + total_cell_offset = 0 + line = self.document[row_index] + for column_index, character in enumerate(line): + total_cell_offset += cell_len(expand_tabs_inline(character, tab_width)) + if total_cell_offset >= cell_width + 1: + return column_index + return len(line) + + def clamp_visitable(self, location: Location) -> Location: + """Clamp the given location to the nearest visitable location. + + Args: + location: The location to clamp. + + Returns: + The nearest location that we could conceivably navigate to using the cursor. + """ + document = self.document + + row, column = location + try: + line_text = document[row] + except IndexError: + line_text = "" + + row = clamp(row, 0, document.line_count - 1) + column = clamp(column, 0, len(line_text)) + + return row, column + + # --- Cursor/selection utilities + def scroll_cursor_visible( + self, center: bool = False, animate: bool = False + ) -> Offset: + """Scroll the `TextArea` such that the cursor is visible on screen. + + Args: + center: True if the cursor should be scrolled to the center. + animate: True if we should animate while scrolling. + + Returns: + The offset that was scrolled to bring the cursor into view. + """ + row, column = self.selection.end + text = self.document[row][:column] + column_offset = cell_len(expand_tabs_inline(text, self.indent_width)) + scroll_offset = self.scroll_to_region( + Region(x=column_offset, y=row, width=3, height=1), + spacing=Spacing(right=self.gutter_width), + animate=animate, + force=True, + center=center, + ) + return scroll_offset + + def move_cursor( + self, + location: Location, + select: bool = False, + center: bool = False, + record_width: bool = True, + ) -> None: + """Move the cursor to a location. + + Args: + location: The location to move the cursor to. + select: If True, select text between the old and new location. + center: If True, scroll such that the cursor is centered. + record_width: If True, record the cursor column cell width after navigating + so that we jump back to the same width the next time we move to a row + that is wide enough. + """ + if select: + start, end = self.selection + self.selection = Selection(start, location) + else: + self.selection = Selection.cursor(location) + + if record_width: + self.record_cursor_width() + + if center: + self.scroll_cursor_visible(center) + + def move_cursor_relative( + self, + rows: int = 0, + columns: int = 0, + select: bool = False, + center: bool = False, + record_width: bool = True, + ) -> None: + """Move the cursor relative to its current location. + + Args: + rows: The number of rows to move down by (negative to move up) + columns: The number of columns to move right by (negative to move left) + select: If True, select text between the old and new location. + center: If True, scroll such that the cursor is centered. + record_width: If True, record the cursor column cell width after navigating + so that we jump back to the same width the next time we move to a row + that is wide enough. + """ + clamp_visitable = self.clamp_visitable + start, end = self.selection + current_row, current_column = end + target = clamp_visitable((current_row + rows, current_column + columns)) + self.move_cursor(target, select, center, record_width) + + def select_line(self, index: int) -> None: + """Select all the text in the specified line. + + Args: + index: The index of the line to select (starting from 0). + """ + try: + line = self.document[index] + except IndexError: + return + else: + self.selection = Selection((index, 0), (index, len(line))) + self.record_cursor_width() + + def action_select_line(self) -> None: + """Select all the text on the current line.""" + cursor_row, _ = self.cursor_location + self.select_line(cursor_row) + + def select_all(self) -> None: + """Select all of the text in the `TextArea`.""" + last_line = self.document.line_count - 1 + length_of_last_line = len(self.document[last_line]) + selection_start = (0, 0) + selection_end = (last_line, length_of_last_line) + self.selection = Selection(selection_start, selection_end) + self.record_cursor_width() + + def action_select_all(self) -> None: + """Select all the text in the document.""" + self.select_all() + + @property + def cursor_location(self) -> Location: + """The current location of the cursor in the document. + + This is a utility for accessing the `end` of `TextArea.selection`. + """ + return self.selection.end + + @cursor_location.setter + def cursor_location(self, location: Location) -> None: + """Set the cursor_location to a new location. + + If a selection is in progress, the anchor point will remain. + """ + self.move_cursor(location, select=not self.selection.is_empty) + + @property + def cursor_at_first_line(self) -> bool: + """True if and only if the cursor is on the first line.""" + return self.selection.end[0] == 0 + + @property + def cursor_at_last_line(self) -> bool: + """True if and only if the cursor is on the last line.""" + return self.selection.end[0] == self.document.line_count - 1 + + @property + def cursor_at_start_of_line(self) -> bool: + """True if and only if the cursor is at column 0.""" + return self.selection.end[1] == 0 + + @property + def cursor_at_end_of_line(self) -> bool: + """True if and only if the cursor is at the end of a row.""" + cursor_row, cursor_column = self.selection.end + row_length = len(self.document[cursor_row]) + cursor_at_end = cursor_column == row_length + return cursor_at_end + + @property + def cursor_at_start_of_text(self) -> bool: + """True if and only if the cursor is at location (0, 0)""" + return self.selection.end == (0, 0) + + @property + def cursor_at_end_of_text(self) -> bool: + """True if and only if the cursor is at the very end of the document.""" + return self.cursor_at_last_line and self.cursor_at_end_of_line + + # ------ Cursor movement actions + def action_cursor_left(self, select: bool = False) -> None: + """Move the cursor one location to the left. + + If the cursor is at the left edge of the document, try to move it to + the end of the previous line. + + Args: + select: If True, select the text while moving. + """ + new_cursor_location = self.get_cursor_left_location() + self.move_cursor(new_cursor_location, select=select) + + def get_cursor_left_location(self) -> Location: + """Get the location the cursor will move to if it moves left. + + Returns: + The location of the cursor if it moves left. + """ + if self.cursor_at_start_of_text: + return 0, 0 + cursor_row, cursor_column = self.selection.end + length_of_row_above = len(self.document[cursor_row - 1]) + target_row = cursor_row if cursor_column != 0 else cursor_row - 1 + target_column = cursor_column - 1 if cursor_column != 0 else length_of_row_above + return target_row, target_column + + def action_cursor_right(self, select: bool = False) -> None: + """Move the cursor one location to the right. + + If the cursor is at the end of a line, attempt to go to the start of the next line. + + Args: + select: If True, select the text while moving. + """ + target = self.get_cursor_right_location() + self.move_cursor(target, select=select) + + def get_cursor_right_location(self) -> Location: + """Get the location the cursor will move to if it moves right. + + Returns: + the location the cursor will move to if it moves right. + """ + if self.cursor_at_end_of_text: + return self.selection.end + cursor_row, cursor_column = self.selection.end + target_row = cursor_row + 1 if self.cursor_at_end_of_line else cursor_row + target_column = 0 if self.cursor_at_end_of_line else cursor_column + 1 + return target_row, target_column + + def action_cursor_down(self, select: bool = False) -> None: + """Move the cursor down one cell. + + Args: + select: If True, select the text while moving. + """ + target = self.get_cursor_down_location() + self.move_cursor(target, record_width=False, select=select) + + def get_cursor_down_location(self) -> Location: + """Get the location the cursor will move to if it moves down. + + Returns: + The location the cursor will move to if it moves down. + """ + cursor_row, cursor_column = self.selection.end + if self.cursor_at_last_line: + return cursor_row, len(self.document[cursor_row]) + + target_row = min(self.document.line_count - 1, cursor_row + 1) + # Attempt to snap last intentional cell length + target_column = self.cell_width_to_column_index( + self._last_intentional_cell_width, target_row + ) + target_column = clamp(target_column, 0, len(self.document[target_row])) + return target_row, target_column + + def action_cursor_up(self, select: bool = False) -> None: + """Move the cursor up one cell. + + Args: + select: If True, select the text while moving. + """ + target = self.get_cursor_up_location() + self.move_cursor(target, record_width=False, select=select) + + def get_cursor_up_location(self) -> Location: + """Get the location the cursor will move to if it moves up. + + Returns: + The location the cursor will move to if it moves up. + """ + if self.cursor_at_first_line: + return 0, 0 + cursor_row, cursor_column = self.selection.end + target_row = max(0, cursor_row - 1) + # Attempt to snap last intentional cell length + target_column = self.cell_width_to_column_index( + self._last_intentional_cell_width, target_row + ) + target_column = clamp(target_column, 0, len(self.document[target_row])) + return target_row, target_column + + def action_cursor_line_end(self, select: bool = False) -> None: + """Move the cursor to the end of the line.""" + location = self.get_cursor_line_end_location() + self.move_cursor(location, select=select) + + def get_cursor_line_end_location(self) -> Location: + """Get the location of the end of the current line. + + Returns: + The (row, column) location of the end of the cursors current line. + """ + start, end = self.selection + cursor_row, cursor_column = end + target_column = len(self.document[cursor_row]) + return cursor_row, target_column + + def action_cursor_line_start(self, select: bool = False) -> None: + """Move the cursor to the start of the line.""" + + cursor_row, cursor_column = self.cursor_location + line = self.document[cursor_row] + + first_non_whitespace = 0 + for index, code_point in enumerate(line): + if not code_point.isspace(): + first_non_whitespace = index + break + + if cursor_column <= first_non_whitespace and cursor_column != 0: + target = self.get_cursor_line_start_location() + self.move_cursor(target, select=select) + else: + target = cursor_row, first_non_whitespace + self.move_cursor(target, select=select) + + def get_cursor_line_start_location(self) -> Location: + """Get the location of the start of the current line. + + Returns: + The (row, column) location of the start of the cursors current line. + """ + _start, end = self.selection + cursor_row, _cursor_column = end + return cursor_row, 0 + + def action_cursor_word_left(self, select: bool = False) -> None: + """Move the cursor left by a single word, skipping trailing whitespace. + + Args: + select: Whether to select while moving the cursor. + """ + if self.cursor_at_start_of_text: + return + target = self.get_cursor_word_left_location() + self.move_cursor(target, select=select) + + def get_cursor_word_left_location(self) -> Location: + """Get the location the cursor will jump to if it goes 1 word left. + + Returns: + The location the cursor will jump on "jump word left". + """ + cursor_row, cursor_column = self.cursor_location + if cursor_row > 0 and cursor_column == 0: + # Going to the previous row + return cursor_row - 1, len(self.document[cursor_row - 1]) + + # Staying on the same row + line = self.document[cursor_row][:cursor_column] + search_string = line.rstrip() + matches = list(re.finditer(self._word_pattern, search_string)) + cursor_column = matches[-1].start() if matches else 0 + return cursor_row, cursor_column + + def action_cursor_word_right(self, select: bool = False) -> None: + """Move the cursor right by a single word, skipping leading whitespace.""" + + if self.cursor_at_end_of_text: + return + + target = self.get_cursor_word_right_location() + self.move_cursor(target, select=select) + + def get_cursor_word_right_location(self) -> Location: + """Get the location the cursor will jump to if it goes 1 word right. + + Returns: + The location the cursor will jump on "jump word right". + """ + cursor_row, cursor_column = self.selection.end + line = self.document[cursor_row] + if cursor_row < self.document.line_count - 1 and cursor_column == len(line): + # Moving to the line below + return cursor_row + 1, 0 + + # Staying on the same line + search_string = line[cursor_column:] + pre_strip_length = len(search_string) + search_string = search_string.lstrip() + strip_offset = pre_strip_length - len(search_string) + + matches = list(re.finditer(self._word_pattern, search_string)) + if matches: + cursor_column += matches[0].start() + strip_offset + else: + cursor_column = len(line) + + return cursor_row, cursor_column + + def action_cursor_page_up(self) -> None: + """Move the cursor and scroll up one page.""" + height = self.content_size.height + _, cursor_location = self.selection + row, column = cursor_location + target = (row - height, column) + self.scroll_relative(y=-height, animate=False) + self.move_cursor(target) + + def action_cursor_page_down(self) -> None: + """Move the cursor and scroll down one page.""" + height = self.content_size.height + _, cursor_location = self.selection + row, column = cursor_location + target = (row + height, column) + self.scroll_relative(y=height, animate=False) + self.move_cursor(target) + + def get_column_width(self, row: int, column: int) -> int: + """Get the cell offset of the column from the start of the row. + + Args: + row: The row index. + column: The column index (codepoint offset from start of row). + + Returns: + The cell width of the column relative to the start of the row. + """ + line = self.document[row] + return cell_len(expand_tabs_inline(line[:column], self.indent_width)) + + def record_cursor_width(self) -> None: + """Record the current cell width of the cursor. + + This is used where we navigate up and down through rows. + If we're in the middle of a row, and go down to a row with no + content, then we go down to another row, we want our cursor to + jump back to the same offset that we were originally at. + """ + row, column = self.selection.end + column_cell_length = self.get_column_width(row, column) + self._last_intentional_cell_width = column_cell_length + + # --- Editor operations + def insert( + self, + text: str, + location: Location | None = None, + *, + maintain_selection_offset: bool = True, + ) -> EditResult: + """Insert text into the document. + + Args: + text: The text to insert. + location: The location to insert text, or None to use the cursor location. + maintain_selection_offset: If True, the active Selection will be updated + such that the same text is selected before and after the selection, + if possible. Otherwise, the cursor will jump to the end point of the + edit. + + Returns: + An `EditResult` containing information about the edit. + """ + if location is None: + location = self.cursor_location + return self.edit(Edit(text, location, location, maintain_selection_offset)) + + def delete( + self, + start: Location, + end: Location, + *, + maintain_selection_offset: bool = True, + ) -> EditResult: + """Delete the text between two locations in the document. + + Args: + start: The start location. + end: The end location. + maintain_selection_offset: If True, the active Selection will be updated + such that the same text is selected before and after the selection, + if possible. Otherwise, the cursor will jump to the end point of the + edit. + + Returns: + An `EditResult` containing information about the edit. + """ + top, bottom = sorted((start, end)) + return self.edit(Edit("", top, bottom, maintain_selection_offset)) + + def replace( + self, + insert: str, + start: Location, + end: Location, + *, + maintain_selection_offset: bool = True, + ) -> EditResult: + """Replace text in the document with new text. + + Args: + insert: The text to insert. + start: The start location + end: The end location. + maintain_selection_offset: If True, the active Selection will be updated + such that the same text is selected before and after the selection, + if possible. Otherwise, the cursor will jump to the end point of the + edit. + + Returns: + An `EditResult` containing information about the edit. + """ + return self.edit(Edit(insert, start, end, maintain_selection_offset)) + + def clear(self) -> None: + """Delete all text from the document.""" + document = self.document + last_line = document[-1] + document_end = (document.line_count, len(last_line)) + self.delete((0, 0), document_end, maintain_selection_offset=False) + + def action_delete_left(self) -> None: + """Deletes the character to the left of the cursor and updates the cursor location. + + If there's a selection, then the selected range is deleted.""" + + selection = self.selection + start, end = selection + + if selection.is_empty: + end = self.get_cursor_left_location() + + self.delete(start, end, maintain_selection_offset=False) + + def action_delete_right(self) -> None: + """Deletes the character to the right of the cursor and keeps the cursor at the same location. + + If there's a selection, then the selected range is deleted.""" + + selection = self.selection + start, end = selection + + if selection.is_empty: + end = self.get_cursor_right_location() + + self.delete(start, end, maintain_selection_offset=False) + + def action_delete_line(self) -> None: + """Deletes the lines which intersect with the selection.""" + start, end = self.selection + start, end = sorted((start, end)) + start_row, start_column = start + end_row, end_column = end + + # Generally editors will only delete line the end line of the + # selection if the cursor is not at column 0 of that line. + if start_row != end_row and end_column == 0 and end_row >= 0: + end_row -= 1 + + from_location = (start_row, 0) + to_location = (end_row + 1, 0) + + self.delete(from_location, to_location, maintain_selection_offset=False) + + def action_delete_to_start_of_line(self) -> None: + """Deletes from the cursor location to the start of the line.""" + from_location = self.selection.end + cursor_row, cursor_column = from_location + to_location = (cursor_row, 0) + self.delete(from_location, to_location, maintain_selection_offset=False) + + def action_delete_to_end_of_line(self) -> None: + """Deletes from the cursor location to the end of the line.""" + from_location = self.selection.end + cursor_row, cursor_column = from_location + to_location = (cursor_row, len(self.document[cursor_row])) + self.delete(from_location, to_location, maintain_selection_offset=False) + + def action_delete_word_left(self) -> None: + """Deletes the word to the left of the cursor and updates the cursor location.""" + if self.cursor_at_start_of_text: + return + + # If there's a non-zero selection, then "delete word left" typically only + # deletes the characters within the selection range, ignoring word boundaries. + start, end = self.selection + if start != end: + self.delete(start, end, maintain_selection_offset=False) + return + + to_location = self.get_cursor_word_left_location() + self.delete(self.selection.end, to_location, maintain_selection_offset=False) + + def action_delete_word_right(self) -> None: + """Deletes the word to the right of the cursor and keeps the cursor at the same location. + + Note that the location that we delete to using this action is not the same + as the location we move to when we move the cursor one word to the right. + This action does not skip leading whitespace, whereas cursor movement does. + """ + if self.cursor_at_end_of_text: + return + + start, end = self.selection + if start != end: + self.delete(start, end, maintain_selection_offset=False) + return + + cursor_row, cursor_column = end + + # Check the current line for a word boundary + line = self.document[cursor_row][cursor_column:] + matches = list(re.finditer(self._word_pattern, line)) + + current_row_length = len(self.document[cursor_row]) + if matches: + to_location = (cursor_row, cursor_column + matches[0].end()) + elif ( + cursor_row < self.document.line_count - 1 + and cursor_column == current_row_length + ): + to_location = (cursor_row + 1, 0) + else: + to_location = (cursor_row, current_row_length) + + self.delete(end, to_location, maintain_selection_offset=False) + + +@dataclass +class Edit: + """Implements the Undoable protocol to replace text at some range within a document.""" + + text: str + """The text to insert. An empty string is equivalent to deletion.""" + from_location: Location + """The start location of the insert.""" + to_location: Location + """The end location of the insert""" + maintain_selection_offset: bool + """If True, the selection will maintain its offset to the replacement range.""" + _updated_selection: Selection | None = field(init=False, default=None) + """Where the selection should move to after the replace happens.""" + + def do(self, text_area: TextArea) -> EditResult: + """Perform the edit operation. + + Args: + text_area: The `TextArea` to perform the edit on. + + Returns: + An `EditResult` containing information about the replace operation. + """ + text = self.text + + edit_from = self.from_location + edit_to = self.to_location + + # This code is mostly handling how we adjust TextArea.selection + # when an edit is made to the document programmatically. + # We want a user who is typing away to maintain their relative + # position in the document even if an insert happens before + # their cursor position. + + edit_top, edit_bottom = sorted((edit_from, edit_to)) + edit_bottom_row, edit_bottom_column = edit_bottom + + selection_start, selection_end = text_area.selection + selection_start_row, selection_start_column = selection_start + selection_end_row, selection_end_column = selection_end + + replace_result = text_area.document.replace_range(edit_from, edit_to, text) + + new_edit_to_row, new_edit_to_column = replace_result.end_location + + # TODO: We could maybe improve the situation where the selection + # and the edit range overlap with each other. + column_offset = new_edit_to_column - edit_bottom_column + target_selection_start_column = ( + selection_start_column + column_offset + if edit_bottom_row == selection_start_row + and edit_bottom_column <= selection_start_column + else selection_start_column + ) + target_selection_end_column = ( + selection_end_column + column_offset + if edit_bottom_row == selection_end_row + and edit_bottom_column <= selection_end_column + else selection_end_column + ) + + row_offset = new_edit_to_row - edit_bottom_row + target_selection_start_row = selection_start_row + row_offset + target_selection_end_row = selection_end_row + row_offset + + if self.maintain_selection_offset: + self._updated_selection = Selection( + start=(target_selection_start_row, target_selection_start_column), + end=(target_selection_end_row, target_selection_end_column), + ) + else: + self._updated_selection = Selection.cursor(replace_result.end_location) + + return replace_result + + def undo(self, text_area: TextArea) -> EditResult: + """Undo the edit operation. + + Args: + text_area: The `TextArea` to undo the insert operation on. + + Returns: + An `EditResult` containing information about the replace operation. + """ + raise NotImplementedError() + + def after(self, text_area: TextArea) -> None: + """Possibly update the cursor location after the widget has been refreshed. + + Args: + text_area: The `TextArea` this operation was performed on. + """ + if self._updated_selection is not None: + text_area.selection = self._updated_selection + text_area.record_cursor_width() + + +@runtime_checkable +class Undoable(Protocol): + """Protocol for actions performed in the text editor which can be done and undone. + + These are typically actions which affect the document (e.g. inserting and deleting + text), but they can really be anything. + + To perform an edit operation, pass the Edit to `TextArea.edit()`""" + + def do(self, text_area: TextArea) -> Any: + """Do the action. + + Args: + The `TextArea` to perform the action on. + + Returns: + Anything. This protocol doesn't prescribe what is returned. + """ + + def undo(self, text_area: TextArea) -> Any: + """Undo the action. + + Args: + The `TextArea` to perform the action on. + + Returns: + Anything. This protocol doesn't prescribe what is returned. + """ + + +@lru_cache(maxsize=128) +def build_byte_to_codepoint_dict(data: bytes) -> dict[int, int]: + """Build a mapping of utf-8 byte offsets to codepoint offsets for the given data. + + Args: + data: utf-8 bytes. + + Returns: + A `dict[int, int]` mapping byte indices to codepoint indices within `data`. + """ + byte_to_codepoint = {} + current_byte_offset = 0 + code_point_offset = 0 + + while current_byte_offset < len(data): + byte_to_codepoint[current_byte_offset] = code_point_offset + first_byte = data[current_byte_offset] + + # Single-byte character + if (first_byte & 0b10000000) == 0: + current_byte_offset += 1 + # 2-byte character + elif (first_byte & 0b11100000) == 0b11000000: + current_byte_offset += 2 + # 3-byte character + elif (first_byte & 0b11110000) == 0b11100000: + current_byte_offset += 3 + # 4-byte character + elif (first_byte & 0b11111000) == 0b11110000: + current_byte_offset += 4 + else: + raise ValueError(f"Invalid UTF-8 byte: {first_byte}") + + code_point_offset += 1 + + # Mapping for the end of the string + byte_to_codepoint[current_byte_offset] = code_point_offset + return byte_to_codepoint diff --git a/src/textual/widgets/rule.py b/src/textual/widgets/rule.py index ef4f57d56..a9ab5d23e 100644 --- a/src/textual/widgets/rule.py +++ b/src/textual/widgets/rule.py @@ -1,9 +1,4 @@ -from ._rule import ( - InvalidLineStyle, - InvalidRuleOrientation, - LineStyle, - RuleOrientation, -) +from ._rule import InvalidLineStyle, InvalidRuleOrientation, LineStyle, RuleOrientation __all__ = [ "InvalidLineStyle", diff --git a/src/textual/widgets/text_area.py b/src/textual/widgets/text_area.py new file mode 100644 index 000000000..82a69e38b --- /dev/null +++ b/src/textual/widgets/text_area.py @@ -0,0 +1,37 @@ +from textual._text_area_theme import TextAreaTheme +from textual.document._document import ( + Document, + DocumentBase, + EditResult, + Location, + Selection, +) +from textual.document._languages import BUILTIN_LANGUAGES +from textual.document._syntax_aware_document import SyntaxAwareDocument +from textual.widgets._text_area import ( + Edit, + EndColumn, + Highlight, + HighlightName, + LanguageDoesNotExist, + StartColumn, + ThemeDoesNotExist, +) + +__all__ = [ + "BUILTIN_LANGUAGES", + "Document", + "DocumentBase", + "Edit", + "EditResult", + "EndColumn", + "Highlight", + "HighlightName", + "LanguageDoesNotExist", + "Location", + "Selection", + "StartColumn", + "SyntaxAwareDocument", + "TextAreaTheme", + "ThemeDoesNotExist", +] diff --git a/tests/document/test_document.py b/tests/document/test_document.py new file mode 100644 index 000000000..b6e995278 --- /dev/null +++ b/tests/document/test_document.py @@ -0,0 +1,100 @@ +import pytest + +from textual.widgets.text_area import Document + +TEXT = """I must not fear. +Fear is the mind-killer.""" + +TEXT_NEWLINE = TEXT + "\n" +TEXT_WINDOWS = TEXT.replace("\n", "\r\n") +TEXT_WINDOWS_NEWLINE = TEXT_NEWLINE.replace("\n", "\r\n") + + +@pytest.mark.parametrize( + "text", [TEXT, TEXT_NEWLINE, TEXT_WINDOWS, TEXT_WINDOWS_NEWLINE] +) +def test_text(text): + """The text we put in is the text we get out.""" + document = Document(text) + assert document.text == text + + +def test_lines_newline_eof(): + document = Document(TEXT_NEWLINE) + assert document.lines == ["I must not fear.", "Fear is the mind-killer.", ""] + + +def test_lines_no_newline_eof(): + document = Document(TEXT) + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + ] + + +def test_lines_windows(): + document = Document(TEXT_WINDOWS) + assert document.lines == ["I must not fear.", "Fear is the mind-killer."] + + +def test_lines_windows_newline(): + document = Document(TEXT_WINDOWS_NEWLINE) + assert document.lines == ["I must not fear.", "Fear is the mind-killer.", ""] + + +def test_newline_unix(): + document = Document(TEXT) + assert document.newline == "\n" + + +def test_newline_windows(): + document = Document(TEXT_WINDOWS) + assert document.newline == "\r\n" + + +def test_get_selected_text_no_selection(): + document = Document(TEXT) + selection = document.get_text_range((0, 0), (0, 0)) + assert selection == "" + + +def test_get_selected_text_single_line(): + document = Document(TEXT_WINDOWS) + selection = document.get_text_range((0, 2), (0, 6)) + assert selection == "must" + + +def test_get_selected_text_multiple_lines_unix(): + document = Document(TEXT) + selection = document.get_text_range((0, 2), (1, 2)) + assert selection == "must not fear.\nFe" + + +def test_get_selected_text_multiple_lines_windows(): + document = Document(TEXT_WINDOWS) + selection = document.get_text_range((0, 2), (1, 2)) + assert selection == "must not fear.\r\nFe" + + +def test_get_selected_text_including_final_newline_unix(): + document = Document(TEXT_NEWLINE) + selection = document.get_text_range((0, 0), (2, 0)) + assert selection == TEXT_NEWLINE + + +def test_get_selected_text_including_final_newline_windows(): + document = Document(TEXT_WINDOWS_NEWLINE) + selection = document.get_text_range((0, 0), (2, 0)) + assert selection == TEXT_WINDOWS_NEWLINE + + +def test_get_selected_text_no_newline_at_end_of_file(): + document = Document(TEXT) + selection = document.get_text_range((0, 0), (2, 0)) + assert selection == TEXT + + +def test_get_selected_text_no_newline_at_end_of_file_windows(): + document = Document(TEXT_WINDOWS) + selection = document.get_text_range((0, 0), (2, 0)) + assert selection == TEXT_WINDOWS diff --git a/tests/document/test_document_delete.py b/tests/document/test_document_delete.py new file mode 100644 index 000000000..d00fa686c --- /dev/null +++ b/tests/document/test_document_delete.py @@ -0,0 +1,146 @@ +import pytest + +from textual.widgets.text_area import Document, EditResult + +TEXT = """I must not fear. +Fear is the mind-killer. +I forgot the rest of the quote. +Sorry Will.""" + + +@pytest.fixture +def document(): + document = Document(TEXT) + return document + + +def test_delete_single_character(document): + replace_result = document.replace_range((0, 0), (0, 1), "") + assert replace_result == EditResult(end_location=(0, 0), replaced_text="I") + assert document.lines == [ + " must not fear.", + "Fear is the mind-killer.", + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_single_newline(document): + """Testing deleting newline from right to left""" + replace_result = document.replace_range((1, 0), (0, 16), "") + assert replace_result == EditResult(end_location=(0, 16), replaced_text="\n") + assert document.lines == [ + "I must not fear.Fear is the mind-killer.", + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_near_end_of_document(document): + """Test deleting a range near the end of a document.""" + replace_result = document.replace_range((1, 0), (3, 11), "") + assert replace_result == EditResult( + end_location=(1, 0), + replaced_text="Fear is the mind-killer.\n" + "I forgot the rest of the quote.\n" + "Sorry Will.", + ) + assert document.lines == [ + "I must not fear.", + "", + ] + + +def test_delete_clearing_the_document(document): + replace_result = document.replace_range((0, 0), (4, 0), "") + assert replace_result == EditResult( + end_location=(0, 0), + replaced_text=TEXT, + ) + assert document.lines == [""] + + +def test_delete_multiple_characters_on_one_line(document): + replace_result = document.replace_range((0, 2), (0, 7), "") + assert replace_result == EditResult( + end_location=(0, 2), + replaced_text="must ", + ) + assert document.lines == [ + "I not fear.", + "Fear is the mind-killer.", + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_multiple_lines_partially_spanned(document): + """Deleting a selection that partially spans the first and final lines of the selection.""" + replace_result = document.replace_range((0, 2), (2, 2), "") + assert replace_result == EditResult( + end_location=(0, 2), + replaced_text="must not fear.\nFear is the mind-killer.\nI ", + ) + assert document.lines == [ + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_end_of_line(document): + """Testing deleting newline from left to right""" + replace_result = document.replace_range((0, 16), (1, 0), "") + assert replace_result == EditResult( + end_location=(0, 16), + replaced_text="\n", + ) + assert document.lines == [ + "I must not fear.Fear is the mind-killer.", + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_single_line_excluding_newline(document): + """Delete from the start to the end of the line.""" + replace_result = document.replace_range((2, 0), (2, 31), "") + assert replace_result == EditResult( + end_location=(2, 0), + replaced_text="I forgot the rest of the quote.", + ) + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + "", + "Sorry Will.", + ] + + +def test_delete_single_line_including_newline(document): + """Delete from the start of a line to the start of the line below.""" + replace_result = document.replace_range((2, 0), (3, 0), "") + assert replace_result == EditResult( + end_location=(2, 0), + replaced_text="I forgot the rest of the quote.\n", + ) + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + "Sorry Will.", + ] + + +TEXT_NEWLINE_EOF = """\ +I must not fear. +Fear is the mind-killer. +""" + + +def test_delete_end_of_file_newline(): + document = Document(TEXT_NEWLINE_EOF) + replace_result = document.replace_range((2, 0), (1, 24), "") + assert replace_result == EditResult(end_location=(1, 24), replaced_text="\n") + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + ] diff --git a/tests/document/test_document_insert.py b/tests/document/test_document_insert.py new file mode 100644 index 000000000..ea706c9ab --- /dev/null +++ b/tests/document/test_document_insert.py @@ -0,0 +1,107 @@ +from textual.widgets.text_area import Document + +TEXT = """I must not fear. +Fear is the mind-killer.""" + + +def test_insert_no_newlines(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), " really") + assert document.lines == [ + "I really must not fear.", + "Fear is the mind-killer.", + ] + + +def test_insert_empty_string(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "") + assert document.lines == ["I must not fear.", "Fear is the mind-killer."] + + +def test_insert_invalid_column(): + document = Document(TEXT) + document.replace_range((0, 999), (0, 999), " really") + assert document.lines == ["I must not fear. really", "Fear is the mind-killer."] + + +def test_insert_invalid_row_and_column(): + document = Document(TEXT) + document.replace_range((999, 0), (999, 0), " really") + assert document.lines == ["I must not fear.", "Fear is the mind-killer.", " really"] + + +def test_insert_range_newline_file_start(): + document = Document(TEXT) + document.replace_range((0, 0), (0, 0), "\n") + assert document.lines == ["", "I must not fear.", "Fear is the mind-killer."] + + +def test_insert_newline_splits_line(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "\n") + assert document.lines == ["I", " must not fear.", "Fear is the mind-killer."] + + +def test_insert_newline_splits_line_selection(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 6), "\n") + assert document.lines == ["I", " not fear.", "Fear is the mind-killer."] + + +def test_insert_multiple_lines_ends_with_newline(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "Hello,\nworld!\n") + assert document.lines == [ + "IHello,", + "world!", + " must not fear.", + "Fear is the mind-killer.", + ] + + +def test_insert_multiple_lines_ends_with_no_newline(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "Hello,\nworld!") + assert document.lines == [ + "IHello,", + "world! must not fear.", + "Fear is the mind-killer.", + ] + + +def test_insert_multiple_lines_starts_with_newline(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "\nHello,\nworld!\n") + assert document.lines == [ + "I", + "Hello,", + "world!", + " must not fear.", + "Fear is the mind-killer.", + ] + + +def test_insert_range_text_no_newlines(): + """Ensuring we can do a simple replacement of text.""" + document = Document(TEXT) + document.replace_range((0, 2), (0, 6), "MUST") + assert document.lines == [ + "I MUST not fear.", + "Fear is the mind-killer.", + ] + + +TEXT_NEWLINE_EOF = """\ +I must not fear. +Fear is the mind-killer. +""" + + +def test_newline_eof(): + document = Document(TEXT_NEWLINE_EOF) + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + "", + ] diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 969ee7ffb..a1b1206db 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -29858,6 +29858,3249 @@ ''' # --- +# name: test_text_area_language_rendering[css] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + +  1  /* This is a comment in CSS */ +  2   +  3  /* Basic selectors and properties */ +  4  body {                                 +  5      font-family: Arial, sans-serif;    +  6      background-color: #f4f4f4;         +  7      margin: 0;                         +  8      padding: 0;                        +  9  }                                      + 10   + 11  /* Class and ID selectors */ + 12  .header {                              + 13      background-color: #333;            + 14      color: #fff;                       + 15      padding: 10px0;                   + 16      text-align: center;                + 17  }                                      + 18   + 19  #logo {                                + 20      font-size: 24px;                   + 21      font-weight: bold;                 + 22  }                                      + 23   + 24  /* Descendant and child selectors */ + 25  .nav ul {                              + 26      list-style-type: none;             + 27      padding: 0;                        + 28  }                                      + 29   + 30  .nav > li {                            + 31      display: inline-block;             + 32      margin-right: 10px;                + 33  }                                      + 34   + 35  /* Pseudo-classes */ + 36  a:hover {                              + 37      text-decoration: underline;        + 38  }                                      + 39   + 40  input:focus {                          + 41      border-color: #007BFF;             + 42  }                                      + 43   + 44  /* Media query */ + 45  @media (max-width: 768px) {            + 46      body {                             + 47          font-size: 16px;               + 48      }                                  + 49   + 50      .header {                          + 51          padding: 5px0;                + 52      }                                  + 53  }                                      + 54   + 55  /* Keyframes animation */ + 56  @keyframes slideIn {                   + 57  from {                             + 58          transform: translateX(-100%);  + 59      }                                  + 60  to {                               + 61          transform: translateX(0);      + 62      }                                  + 63  }                                      + 64   + 65  .slide-in-element {                    + 66      animation: slideIn 0.5s forwards;  + 67  }                                      + 68   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[html] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + +  1  <!DOCTYPE html>                                                              +  2  <html lang="en">                                                            +  3   +  4  <head>                                                                      +  5  <!-- Meta tags --> +  6      <meta charset="UTF-8">                                                  +  7      <meta name="viewport" content="width=device-width, initial-scale=1.0" +  8  <!-- Title --> +  9      <title>HTML Test Page</title>                                           + 10  <!-- Link to CSS --> + 11      <link rel="stylesheet" href="styles.css">                               + 12  </head>                                                                     + 13   + 14  <body>                                                                      + 15  <!-- Header section --> + 16      <header class="header">                                                 + 17          <h1 id="logo">HTML Test Page</h1>                                   + 18      </header>                                                               + 19   + 20  <!-- Navigation --> + 21      <nav class="nav">                                                       + 22          <ul>                                                                + 23              <li><a href="#">Home</a></li>                                   + 24              <li><a href="#">About</a></li>                                  + 25              <li><a href="#">Contact</a></li>                                + 26          </ul>                                                               + 27      </nav>                                                                  + 28   + 29  <!-- Main content area --> + 30      <main>                                                                  + 31          <article>                                                           + 32              <h2>Welcome to the Test Page</h2>                               + 33              <p>This is a paragraph to test the HTML structure.</p>          + 34              <img src="test-image.jpg" alt="Test Image" width="300">         + 35          </article>                                                          + 36      </main>                                                                 + 37   + 38  <!-- Form --> + 39      <section>                                                               + 40          <form action="/submit" method="post">                               + 41              <label for="name">Name:</label>                                 + 42              <input type="text" id="name" name="name">                       + 43              <input type="submit" value="Submit">                            + 44          </form>                                                             + 45      </section>                                                              + 46   + 47  <!-- Footer --> + 48      <footer>                                                                + 49          <p>&copy; 2023 HTML Test Page</p>                                   + 50      </footer>                                                               + 51   + 52  <!-- Script tag --> + 53      <script src="scripts.js"></script>                                      + 54  </body>                                                                     + 55   + 56  </html>                                                                     + 57   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[json] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + +  1  { +  2  "name""John Doe",                            +  3  "age"30,                                     +  4  "isStudent"false,                            +  5  "address": {                                   +  6  "street""123 Main St",                   +  7  "city""Anytown",                         +  8  "state""CA",                             +  9  "zip""12345" + 10      },                                             + 11  "phoneNumbers": [                              + 12          {                                          + 13  "type""home",                        + 14  "number""555-555-1234" + 15          },                                         + 16          {                                          + 17  "type""work",                        + 18  "number""555-555-5678" + 19          }                                          + 20      ],                                             + 21  "hobbies": ["reading""hiking""swimming"],  + 22  "pets": [                                      + 23          {                                          + 24  "type""dog",                         + 25  "name""Fido" + 26          },                                         + 27      ],                                             + 28  "graduationYear"null + 29  } + 30   + 31   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[markdown] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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.gif) + 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   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[python] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[regex] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + +  1  ^abc            # Matches any string that starts with "abc"                  +  2  abc$            # Matches any string that ends with "abc"                    +  3  ^abc$           # Matches the string "abc" and nothing else                  +  4  a.b             # Matches any string containing "a", any character, then "b" +  5  a[.]b           # Matches the string "a.b"                                   +  6  a|b             # Matches either "a" or "b"                                  +  7  a{2}            # Matches "aa"                                               +  8  a{2,}           # Matches two or more consecutive "a" characters             +  9  a{2,5}          # Matches between 2 and 5 consecutive "a" characters         + 10  a?              # Matches "a" or nothing (0 or 1 occurrence of "a") + 11  a*              # Matches zero or more consecutive "a" characters            + 12  a+              # Matches one or more consecutive "a" characters             + 13  \d              # Matches any digit (equivalent to [0-9]) + 14  \D              # Matches any non-digit                                      + 15  \w              # Matches any word character (equivalent to [a-zA-Z0-9_]) + 16  \W              # Matches any non-word character                             + 17  \s              # Matches any whitespace character (spaces, tabs, line break + 18  \S              # Matches any non-whitespace character                       + 19  (?i)abc         # Case-insensitive match for "abc"                           + 20  (?:a|b)         # Non-capturing group for either "a" or "b"                  + 21  (?<=a)b         # Positive lookbehind: matches "b" that is preceded by "a"   + 22  (?<!a)b         # Negative lookbehind: matches "b" that is not preceded by " + 23  a(?=b)          # Positive lookahead: matches "a" that is followed by "b"    + 24  a(?!b)          # Negative lookahead: matches "a" that is not followed by "b + 25   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[sql] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + +  1  -- This is a comment in SQL +  2   +  3  -- Create tables +  4  CREATETABLE Authors (                                                       +  5      AuthorID INT PRIMARY KEY,                                                +  6      Name VARCHAR(255NOT NULL,                                              +  7      Country VARCHAR(50)                                                      +  8  );                                                                           +  9   + 10  CREATETABLE Books (                                                         + 11      BookID INT PRIMARY KEY,                                                  + 12      Title VARCHAR(255NOT NULL,                                             + 13      AuthorID INT,                                                            + 14      PublishedDate DATE,                                                      + 15      FOREIGN KEY (AuthorID) REFERENCES Authors(AuthorID)                      + 16  );                                                                           + 17   + 18  -- Insert data + 19  INSERTINTO Authors (AuthorID, Name, Country) VALUES (1'George Orwell''U + 20   + 21  INSERTINTO Books (BookID, Title, AuthorID, PublishedDate) VALUES (1'1984' + 22   + 23  -- Update data + 24  UPDATE Authors SET Country ='United Kingdom'WHERE Country ='UK';          + 25   + 26  -- Select data with JOIN + 27  SELECT Books.Title, Authors.Name                                             + 28  FROM Books                                                                   + 29  JOIN Authors ON Books.AuthorID = Authors.AuthorID;                           + 30   + 31  -- Delete data (commented to preserve data for other examples) + 32  -- DELETE FROM Books WHERE BookID = 1; + 33   + 34  -- Alter table structure + 35  ALTER TABLE Authors ADD COLUMN BirthDate DATE;                               + 36   + 37  -- Create index + 38  CREATEINDEX idx_author_name ON Authors(Name);                               + 39   + 40  -- Drop index (commented to avoid actually dropping it) + 41  -- DROP INDEX idx_author_name ON Authors; + 42   + 43  -- End of script + 44   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[toml] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + +  1  # This is a comment in TOML +  2   +  3  string = "Hello, world!" +  4  integer = 42 +  5  float = 3.14 +  6  boolean = true +  7  datetime = 1979-05-27T07:32:00Z +  8   +  9  fruits = ["apple""banana""cherry" + 10   + 11  [address]                               + 12  street = "123 Main St" + 13  city = "Anytown" + 14  state = "CA" + 15  zip = "12345" + 16   + 17  [person.john]                           + 18  name = "John Doe" + 19  age = 28 + 20  is_student = false + 21   + 22   + 23  [[animals]]                             + 24  name = "Fido" + 25  type = "dog" + 26   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[yaml] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + +  1  # This is a comment in YAML +  2   +  3  # Scalars +  4  string"Hello, world!" +  5  integer42 +  6  float3.14 +  7  booleantrue +  8   +  9  # Sequences (Arrays) + 10  fruits:                                               + 11    - Apple + 12    - Banana + 13    - Cherry + 14   + 15  # Nested sequences + 16  persons:                                              + 17    - nameJohn + 18  age28 + 19  is_studentfalse + 20    - nameJane + 21  age22 + 22  is_studenttrue + 23   + 24  # Mappings (Dictionaries) + 25  address:                                              + 26  street123 Main St + 27  cityAnytown + 28  stateCA + 29  zip'12345' + 30   + 31  # Multiline string + 32  description|                                        + 33    This is a multiline                                 + 34    string in YAML. + 35   + 36  # Inline and nested collections + 37  colors: { redFF0000green00FF00blue0000FF }  + 38   + + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection0] + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + I am a line. + ▌                     + I am another line.             + + I am the final line.  + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection1] + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + I am a line. + ▌                     + I am another line.    + + I am the final line.  + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection2] + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + I am a line. + ▌                     + I am another line. + ▌                     + I am the final line.  + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection3] + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + I am a line. + ▌                     + I am another line. + ▌                     + I am the final line. + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection4] + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + I am a line.          + + I am another line.    + + I am the final line.  + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection5] + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + I am a line.          + + I am another line.             + + I am the final line.  + + + + + ''' +# --- +# name: test_text_area_themes[dracula] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + 1  defhello(name): + 2      x =123 + 3  whilenotFalse:            + 4  print("hello "+ name)  + 5  continue + 6   + + + + + + ''' +# --- +# name: test_text_area_themes[github_light] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + 1  defhello(name): + 2  x=123 + 3  whilenotFalse:            + 4  print("hello "+name + 5  continue + 6   + + + + + + ''' +# --- +# name: test_text_area_themes[monokai] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + 1  defhello(name): + 2      x =123 + 3  whilenotFalse:            + 4  print("hello "+ name)  + 5  continue + 6   + + + + + + ''' +# --- +# name: test_text_area_themes[vscode_dark] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + 1  defhello(name): + 2      x =123 + 3  whilenotFalse:            + 4  print("hello "+ name)  + 5  continue + 6   + + + + + + ''' +# --- # name: test_text_log_blank_write ''' diff --git a/tests/snapshot_tests/language_snippets.py b/tests/snapshot_tests/language_snippets.py new file mode 100644 index 000000000..fd7a6a295 --- /dev/null +++ b/tests/snapshot_tests/language_snippets.py @@ -0,0 +1,466 @@ +PYTHON = """\ +import math +from os import path + +# I'm a comment :) + +string_var = "Hello, world!" +int_var = 42 +float_var = 3.14 +complex_var = 1 + 2j + +list_var = [1, 2, 3, 4, 5] +tuple_var = (1, 2, 3, 4, 5) +set_var = {1, 2, 3, 4, 5} +dict_var = {"a": 1, "b": 2, "c": 3} + +def function_no_args(): + return "No arguments" + +def function_with_args(a, b): + return a + b + +def function_with_default_args(a=0, b=0): + return a * b + +lambda_func = lambda x: x**2 + +if int_var == 42: + print("It's the answer!") +elif int_var < 42: + print("Less than the answer.") +else: + print("Greater than the answer.") + +for index, value in enumerate(list_var): + print(f"Index: {index}, Value: {value}") + +counter = 0 +while counter < 5: + print(f"Counter value: {counter}") + counter += 1 + +squared_numbers = [x**2 for x in range(10) if x % 2 == 0] + +try: + result = 10 / 0 +except ZeroDivisionError: + print("Cannot divide by zero!") +finally: + print("End of try-except block.") + +class Animal: + def __init__(self, name): + self.name = name + + def speak(self): + raise NotImplementedError("Subclasses must implement this method.") + +class Dog(Animal): + def speak(self): + return f"{self.name} says Woof!" + +def fibonacci(n): + a, b = 0, 1 + for _ in range(n): + yield a + a, b = b, a + b + +for num in fibonacci(5): + print(num) + +with open('test.txt', 'w') as f: + f.write("Testing with statement.") + +@my_decorator +def say_hello(): + print("Hello!") + +say_hello() +""" + + +MARKDOWN = """\ +Heading +======= + +Sub-heading +----------- + +### Heading + +#### H4 Heading + +##### H5 Heading + +###### H6 Heading + + +Paragraphs are separated +by a blank line. + +Two spaces at the end of a line +produces a line break. + +Text attributes _italic_, +**bold**, `monospace`. + +Horizontal rule: + +--- + +Bullet list: + + * apples + * oranges + * pears + +Numbered list: + + 1. lather + 2. rinse + 3. repeat + +An [example](http://example.com). + +> Markdown uses email-style > characters for blockquoting. +> +> Lorem ipsum + +![progress](https://github.com/textualize/rich/raw/master/imgs/progress.gif) + + +``` +a=1 +``` + +```python +import this +``` + +```somelang +foobar +``` + + import this + + +1. List item + + Code block +""" + +YAML = """\ +# This is a comment in YAML + +# Scalars +string: "Hello, world!" +integer: 42 +float: 3.14 +boolean: true + +# Sequences (Arrays) +fruits: + - Apple + - Banana + - Cherry + +# Nested sequences +persons: + - name: John + age: 28 + is_student: false + - name: Jane + age: 22 + is_student: true + +# Mappings (Dictionaries) +address: + street: 123 Main St + city: Anytown + state: CA + zip: '12345' + +# Multiline string +description: | + This is a multiline + string in YAML. + +# Inline and nested collections +colors: { red: FF0000, green: 00FF00, blue: 0000FF } +""" + +TOML = """\ +# This is a comment in TOML + +string = "Hello, world!" +integer = 42 +float = 3.14 +boolean = true +datetime = 1979-05-27T07:32:00Z + +fruits = ["apple", "banana", "cherry"] + +[address] +street = "123 Main St" +city = "Anytown" +state = "CA" +zip = "12345" + +[person.john] +name = "John Doe" +age = 28 +is_student = false + + +[[animals]] +name = "Fido" +type = "dog" +""" + +SQL = """\ +-- This is a comment in SQL + +-- Create tables +CREATE TABLE Authors ( + AuthorID INT PRIMARY KEY, + Name VARCHAR(255) NOT NULL, + Country VARCHAR(50) +); + +CREATE TABLE Books ( + BookID INT PRIMARY KEY, + Title VARCHAR(255) NOT NULL, + AuthorID INT, + PublishedDate DATE, + FOREIGN KEY (AuthorID) REFERENCES Authors(AuthorID) +); + +-- Insert data +INSERT INTO Authors (AuthorID, Name, Country) VALUES (1, 'George Orwell', 'UK'); + +INSERT INTO Books (BookID, Title, AuthorID, PublishedDate) VALUES (1, '1984', 1, '1949-06-08'); + +-- Update data +UPDATE Authors SET Country = 'United Kingdom' WHERE Country = 'UK'; + +-- Select data with JOIN +SELECT Books.Title, Authors.Name +FROM Books +JOIN Authors ON Books.AuthorID = Authors.AuthorID; + +-- Delete data (commented to preserve data for other examples) +-- DELETE FROM Books WHERE BookID = 1; + +-- Alter table structure +ALTER TABLE Authors ADD COLUMN BirthDate DATE; + +-- Create index +CREATE INDEX idx_author_name ON Authors(Name); + +-- Drop index (commented to avoid actually dropping it) +-- DROP INDEX idx_author_name ON Authors; + +-- End of script +""" + +CSS = """\ +/* This is a comment in CSS */ + +/* Basic selectors and properties */ +body { + font-family: Arial, sans-serif; + background-color: #f4f4f4; + margin: 0; + padding: 0; +} + +/* Class and ID selectors */ +.header { + background-color: #333; + color: #fff; + padding: 10px 0; + text-align: center; +} + +#logo { + font-size: 24px; + font-weight: bold; +} + +/* Descendant and child selectors */ +.nav ul { + list-style-type: none; + padding: 0; +} + +.nav > li { + display: inline-block; + margin-right: 10px; +} + +/* Pseudo-classes */ +a:hover { + text-decoration: underline; +} + +input:focus { + border-color: #007BFF; +} + +/* Media query */ +@media (max-width: 768px) { + body { + font-size: 16px; + } + + .header { + padding: 5px 0; + } +} + +/* Keyframes animation */ +@keyframes slideIn { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +.slide-in-element { + animation: slideIn 0.5s forwards; +} +""" + +HTML = """\ + + + + + + + + + HTML Test Page + + + + + + +
+

HTML Test Page

+
+ + + + + +
+
+

Welcome to the Test Page

+

This is a paragraph to test the HTML structure.

+ Test Image +
+
+ + +
+
+ + + +
+
+ + + + + + + + + +""" + +JSON = """\ +{ + "name": "John Doe", + "age": 30, + "isStudent": false, + "address": { + "street": "123 Main St", + "city": "Anytown", + "state": "CA", + "zip": "12345" + }, + "phoneNumbers": [ + { + "type": "home", + "number": "555-555-1234" + }, + { + "type": "work", + "number": "555-555-5678" + } + ], + "hobbies": ["reading", "hiking", "swimming"], + "pets": [ + { + "type": "dog", + "name": "Fido" + }, + ], + "graduationYear": null +} + +""" + +REGEX = """\ +^abc # Matches any string that starts with "abc" +abc$ # Matches any string that ends with "abc" +^abc$ # Matches the string "abc" and nothing else +a.b # Matches any string containing "a", any character, then "b" +a[.]b # Matches the string "a.b" +a|b # Matches either "a" or "b" +a{2} # Matches "aa" +a{2,} # Matches two or more consecutive "a" characters +a{2,5} # Matches between 2 and 5 consecutive "a" characters +a? # Matches "a" or nothing (0 or 1 occurrence of "a") +a* # Matches zero or more consecutive "a" characters +a+ # Matches one or more consecutive "a" characters +\d # Matches any digit (equivalent to [0-9]) +\D # Matches any non-digit +\w # Matches any word character (equivalent to [a-zA-Z0-9_]) +\W # Matches any non-word character +\s # Matches any whitespace character (spaces, tabs, line breaks) +\S # Matches any non-whitespace character +(?i)abc # Case-insensitive match for "abc" +(?:a|b) # Non-capturing group for either "a" or "b" +(?<=a)b # Positive lookbehind: matches "b" that is preceded by "a" +(? ComposeResult: + text_area = TextArea() + text_area.cursor_blink = False + yield text_area + + +app = TextAreaSnapshot() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py b/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py new file mode 100644 index 000000000..e092f1672 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py @@ -0,0 +1,17 @@ +"""Tests the rendering of the TextArea for all supported languages.""" +from textual.app import App, ComposeResult +from textual.widgets import TextArea + + +class TextAreaUnfocusSnapshot(App): + AUTO_FOCUS = None + + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.cursor_blink = False + yield text_area + + +app = TextAreaUnfocusSnapshot() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index f0f478515..d60b94c58 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -2,6 +2,11 @@ from pathlib import Path import pytest +from tests.snapshot_tests.language_snippets import SNIPPETS +from textual.widgets.text_area import Selection, BUILTIN_LANGUAGES +from textual.widgets import TextArea +from textual.widgets.text_area import TextAreaTheme + # These paths should be relative to THIS directory. WIDGET_EXAMPLES_DIR = Path("../../docs/examples/widgets") LAYOUT_EXAMPLES_DIR = Path("../../docs/examples/guide/layout") @@ -89,7 +94,8 @@ def test_input_validation(snap_compare): "tab", "3", # This is valid, so -valid should be applied "tab", - *"-2", # -2 is invalid, so -invalid should be applied (and :focus, since we stop here) + *"-2", + # -2 is invalid, so -invalid should be applied (and :focus, since we stop here) ] assert snap_compare(SNAPSHOT_APPS_DIR / "input_validation.py", press=press) @@ -700,6 +706,85 @@ def test_nested_fr(snap_compare) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "nested_fr.py") +@pytest.mark.parametrize("language", BUILTIN_LANGUAGES) +def test_text_area_language_rendering(language, snap_compare): + # This test will fail if we're missing a snapshot test for a valid + # language. We should have a snapshot test for each language we support + # as the syntax highlighting will be completely different for each of them. + + snippet = SNIPPETS.get(language) + + def setup_language(pilot) -> None: + text_area = pilot.app.query_one(TextArea) + text_area.load_text(snippet) + text_area.language = language + + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area.py", + run_before=setup_language, + terminal_size=(80, snippet.count("\n") + 2), + ) + + +@pytest.mark.parametrize( + "selection", + [ + Selection((0, 0), (2, 8)), + Selection((1, 0), (0, 0)), + Selection((5, 2), (0, 0)), + Selection((0, 0), (4, 20)), + Selection.cursor((1, 0)), + Selection.cursor((2, 6)), + ], +) +def test_text_area_selection_rendering(snap_compare, selection): + text = """I am a line. + +I am another line. + +I am the final line.""" + + def setup_selection(pilot): + text_area = pilot.app.query_one(TextArea) + text_area.load_text(text) + text_area.show_line_numbers = False + text_area.selection = selection + + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area.py", + run_before=setup_selection, + terminal_size=(30, text.count("\n") + 1), + ) + + +@pytest.mark.parametrize("theme_name", + [theme.name for theme in TextAreaTheme.builtin_themes()]) +def test_text_area_themes(snap_compare, theme_name): + """Each theme should have its own snapshot with at least some Python + to check that the rendering is sensible. This also ensures that theme + switching results in the display changing correctly.""" + text = """\ +def hello(name): + x = 123 + while not False: + print("hello " + name) + continue +""" + + def setup_theme(pilot): + text_area = pilot.app.query_one(TextArea) + text_area.load_text(text) + text_area.language = "python" + text_area.selection = Selection((0, 1), (1, 9)) + text_area.theme = theme_name + + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area.py", + run_before=setup_theme, + terminal_size=(48, text.count("\n") + 2), + ) + + def test_digits(snap_compare) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "digits.py") diff --git a/tests/text_area/test_edit_via_api.py b/tests/text_area/test_edit_via_api.py new file mode 100644 index 000000000..4cf8602e0 --- /dev/null +++ b/tests/text_area/test_edit_via_api.py @@ -0,0 +1,522 @@ +"""Tests editing the document using the API (replace etc.) + +The tests in this module directly call the edit APIs on the TextArea rather +than going via bindings. + +Note that more extensive testing for editing is done at the Document level. +""" +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import EditResult, Selection + +TEXT = """\ +I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + +SIMPLE_TEXT = """\ +ABCDE +FGHIJ +KLMNO +PQRST +UVWXY +Z +""" + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.load_text(TEXT) + yield text_area + + +async def test_insert_text_start_maintain_selection_offset(): + """Ensure that we can maintain the offset between the location + an insert happens and the location of the selection.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor((0, 5)) + text_area.insert("Hello", location=(0, 0)) + assert text_area.text == "Hello" + TEXT + assert text_area.selection == Selection.cursor((0, 10)) + + +async def test_insert_text_start(): + """The document is correctly updated on inserting at the start. + If we don't maintain the selection offset, the cursor jumps + to the end of the edit and the selection is empty.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor((0, 5)) + text_area.insert("Hello", location=(0, 0), maintain_selection_offset=False) + assert text_area.text == "Hello" + TEXT + assert text_area.selection == Selection.cursor((0, 5)) + + +async def test_insert_empty_string(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + + text_area.insert("", location=(0, 3)) + + assert text_area.text == "0123456789" + + +async def test_replace_empty_string(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + + text_area.replace("", start=(0, 3), end=(0, 7)) + + assert text_area.text == "012789" + + +@pytest.mark.parametrize( + "cursor_location,insert_location,cursor_destination", + [ + ((0, 3), (0, 2), (0, 4)), # API insert just before cursor + ((0, 3), (0, 3), (0, 4)), # API insert at cursor location + ((0, 3), (0, 4), (0, 3)), # API insert just after cursor + ((0, 3), (0, 5), (0, 3)), # API insert just after cursor + ], +) +async def test_insert_character_near_cursor_maintain_selection_offset( + cursor_location, + insert_location, + cursor_destination, +): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("012345") + text_area.move_cursor(cursor_location) + text_area.insert("X", location=insert_location) + assert text_area.selection == Selection.cursor(cursor_destination) + + +async def test_insert_newlines_start(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.insert("\n\n\n") + assert text_area.text == "\n\n\n" + TEXT + assert text_area.selection == Selection.cursor((3, 0)) + + +async def test_insert_newlines_end(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.insert("\n\n\n", location=(4, 0)) + assert text_area.text == TEXT + "\n\n\n" + + +async def test_insert_windows_newlines(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + # Although we're inserting windows newlines, the configured newline on + # the Document inside the TextArea will be "\n", so when we check TextArea.text + # we expect to see "\n". + text_area.insert("\r\n\r\n\r\n") + assert text_area.text == "\n\n\n" + TEXT + + +async def test_insert_old_mac_newlines(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.insert("\r\r\r") + assert text_area.text == "\n\n\n" + TEXT + + +async def test_insert_text_non_cursor_location(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.insert("Hello", location=(4, 0)) + assert text_area.text == TEXT + "Hello" + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_insert_text_non_cursor_location_dont_maintain_offset(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((2, 3), (3, 5)) + + result = text_area.insert( + "Hello", + location=(4, 0), + maintain_selection_offset=False, + ) + + assert result == EditResult( + end_location=(4, 5), + replaced_text="", + ) + assert text_area.text == TEXT + "Hello" + + # Since maintain_selection_offset is False, the selection + # is reset to a cursor and goes to the end of the insert. + assert text_area.selection == Selection.cursor((4, 5)) + + +async def test_insert_multiline_text(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor((2, 5)) + text_area.insert("Hello,\nworld!", maintain_selection_offset=False) + expected_content = """\ +I must not fear. +Fear is the mind-killer. +Fear Hello, +world!is the little-death that brings total obliteration. +I will face my fear. +""" + assert text_area.cursor_location == (3, 6) # Cursor moved to end of insert + assert text_area.text == expected_content + + +async def test_insert_multiline_text_maintain_offset(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor((2, 5)) + result = text_area.insert("Hello,\nworld!") + + assert result == EditResult( + end_location=(3, 6), + replaced_text="", + ) + + # The insert happens at the cursor (default location) + # Offset is maintained - we inserted 1 line so cursor shifts + # down 1 line, and along by the length of the last insert line. + assert text_area.cursor_location == (3, 6) + expected_content = """\ +I must not fear. +Fear is the mind-killer. +Fear Hello, +world!is the little-death that brings total obliteration. +I will face my fear. +""" + assert text_area.text == expected_content + + +async def test_replace_multiline_text(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + # replace "Fear is the mind-killer\nFear is the little death...\n" + # with "Hello,\nworld!\n" + result = text_area.replace("Hello,\nworld!\n", start=(1, 0), end=(3, 0)) + expected_replaced_text = """\ +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +""" + assert result == EditResult( + end_location=(3, 0), + replaced_text=expected_replaced_text, + ) + + expected_content = """\ +I must not fear. +Hello, +world! +I will face my fear. +""" + assert text_area.selection == Selection.cursor((0, 0)) # cursor didnt move + assert text_area.text == expected_content + + +async def test_replace_multiline_text_maintain_selection(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + # To begin with, the user selects the word "face" + text_area.selection = Selection((3, 7), (3, 11)) + assert text_area.selected_text == "face" + + # Text is inserted via the API in a way that shifts + # the start and end locations of the word "face" in + # both the horizontal and vertical directions. + text_area.replace( + "Hello,\nworld!\n123\n456", + start=(1, 0), + end=(3, 0), + ) + expected_content = """\ +I must not fear. +Hello, +world! +123 +456I will face my fear. +""" + # Despite this insert, the selection locations are updated + # and the word face is still highlighted. This ensures that + # if text is insert programmatically, a user that is typing + # won't lose their place - the cursor will maintain the same + # relative position in the document as before. + assert text_area.selected_text == "face" + assert text_area.selection == Selection((4, 10), (4, 14)) + assert text_area.text == expected_content + + +async def test_delete_within_line(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((0, 11), (0, 15)) + assert text_area.selected_text == "fear" + + # Delete some text before the selection location. + result = text_area.delete((0, 6), (0, 10)) + + # Even though the word has 'shifted' left, it's still selected. + assert text_area.selection == Selection((0, 7), (0, 11)) + assert text_area.selected_text == "fear" + + # We've recorded exactly what text was replaced in the EditResult + assert result == EditResult( + end_location=(0, 6), + replaced_text=" not", + ) + + expected_text = """\ +I must fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + assert text_area.text == expected_text + + +async def test_delete_within_line_dont_maintain_offset(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.delete((0, 6), (0, 10), maintain_selection_offset=False) + expected_text = """\ +I must fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + assert text_area.selection == Selection.cursor((0, 6)) # cursor moved + assert text_area.text == expected_text + + +async def test_delete_multiple_lines_selection_above(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + # User has selected text on the first line... + text_area.selection = Selection((0, 2), (0, 6)) + assert text_area.selected_text == "must" + + # Some lines below are deleted... + result = text_area.delete((1, 0), (3, 0)) + + # The selection is not affected at all. + assert text_area.selection == Selection((0, 2), (0, 6)) + + # We've recorded the text that was deleted in the ReplaceResult. + # Lines of index 1 and 2 were deleted. Since the end + # location of the selection is (3, 0), the newline + # marker is included in the deletion. + expected_replaced_text = """\ +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +""" + assert result == EditResult( + end_location=(1, 0), + replaced_text=expected_replaced_text, + ) + assert ( + text_area.text + == """\ +I must not fear. +I will face my fear. +""" + ) + + +async def test_delete_empty_document(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("") + result = text_area.delete((0, 0), (1, 0)) + assert result.replaced_text == "" + assert text_area.text == "" + + +async def test_clear(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.clear() + + +async def test_clear_empty_document(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("") + text_area.clear() + + +@pytest.mark.parametrize( + "select_from,select_to", + [ + [(0, 3), (2, 1)], + [(2, 1), (0, 3)], # Ensuring independence from selection direction. + ], +) +async def test_insert_text_multiline_selection_top(select_from, select_to): + """ + An example to attempt to explain what we're testing here... + + X = edit range, * = character in TextArea, S = selection + + *********XX + XXXXX***SSS + SSSSSSSSSSS + SSSS******* + + If an edit happens at XXXX, we need to ensure that the SSS on the + same line is adjusted appropriately so that it's still highlighting + the same characters as before. + """ + app = TextAreaApp() + async with app.run_test(): + # ABCDE + # FGHIJ + # KLMNO + # PQRST + # UVWXY + # Z + text_area = app.query_one(TextArea) + text_area.load_text(SIMPLE_TEXT) + text_area.selection = Selection(select_from, select_to) + + # Check what text is selected. + expected_selected_text = "DE\nFGHIJ\nK" + assert text_area.selected_text == expected_selected_text + + result = text_area.replace( + "Hello", + start=(0, 0), + end=(0, 2), + ) + + assert result == EditResult(end_location=(0, 5), replaced_text="AB") + + # The edit range has grown from width 2 to width 5, so the + # top line of the selection was adjusted (column+=3) such that the + # same characters are highlighted: + # ... the selection is not changed after programmatic insert + # ... the same text is selected as before. + assert text_area.selected_text == expected_selected_text + + # The resulting text in the TextArea is correct. + assert text_area.text == "HelloCDE\nFGHIJ\nKLMNO\nPQRST\nUVWXY\nZ\n" + + +@pytest.mark.parametrize( + "select_from,select_to", + [ + [(0, 3), (2, 5)], + [(2, 5), (0, 3)], # Ensuring independence from selection direction. + ], +) +async def test_insert_text_multiline_selection_bottom(select_from, select_to): + """ + The edited text is within the selected text on the bottom line + of the selection. The bottom of the selection should be adjusted + such that any text that was previously selected is still selected. + """ + app = TextAreaApp() + async with app.run_test(): + # ABCDE + # FGHIJ + # KLMNO + # PQRST + # UVWXY + # Z + + text_area = app.query_one(TextArea) + text_area.load_text(SIMPLE_TEXT) + text_area.selection = Selection(select_from, select_to) + + # Check what text is selected. + assert text_area.selected_text == "DE\nFGHIJ\nKLMNO" + + result = text_area.replace( + "*", + start=(2, 0), + end=(2, 3), + ) + assert result == EditResult(end_location=(2, 1), replaced_text="KLM") + + # The 'NO' from the selection is still available on the + # bottom selection line, however the 'KLM' is replaced + # with '*'. Since 'NO' is still available, it's maintained + # within the selection. + assert text_area.selected_text == "DE\nFGHIJ\n*NO" + + # The resulting text in the TextArea is correct. + # 'KLM' replaced with '*' + assert text_area.text == "ABCDE\nFGHIJ\n*NO\nPQRST\nUVWXY\nZ\n" + + +async def test_delete_fully_within_selection(): + """User-facing selection should be best-effort adjusted when a programmatic + replacement is made to the document.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = Selection((0, 2), (0, 7)) + assert text_area.selected_text == "23456" + + result = text_area.delete((0, 4), (0, 6)) + assert result == EditResult( + replaced_text="45", + end_location=(0, 4), + ) + # We deleted 45, but the other characters are still available + assert text_area.selected_text == "236" + assert text_area.text == "01236789" + + +async def test_replace_fully_within_selection(): + """Adjust the selection when a replacement happens inside it.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = Selection((0, 2), (0, 7)) + assert text_area.selected_text == "23456" + + result = text_area.replace("XX", start=(0, 2), end=(0, 5)) + assert result == EditResult( + replaced_text="234", + end_location=(0, 4), + ) + assert text_area.selected_text == "XX56" diff --git a/tests/text_area/test_edit_via_bindings.py b/tests/text_area/test_edit_via_bindings.py new file mode 100644 index 000000000..aa99a63ad --- /dev/null +++ b/tests/text_area/test_edit_via_bindings.py @@ -0,0 +1,418 @@ +"""Tests some edits using the keyboard. + +All tests in this module should press keys on the keyboard which edit the document, +and check that the document content is updated as expected, as well as the cursor +location. + +Note that more extensive testing for editing is done at the Document level. +""" +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import Selection + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + +SIMPLE_TEXT = """\ +ABCDE +FGHIJ +KLMNO +PQRST +UVWXY +Z""" + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.load_text(TEXT) + yield text_area + + +async def test_single_keypress_printable_character(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + await pilot.press("x") + assert text_area.text == "x" + TEXT + + +async def test_single_keypress_enter(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + await pilot.press("enter") + assert text_area.text == "\n" + TEXT + + +@pytest.mark.parametrize( + "content,cursor_column,cursor_destination", + [ + ("", 0, 4), + ("x", 0, 4), + ("x", 1, 4), + ("xxx", 3, 4), + ("xxxx", 4, 8), + ("xxxxx", 5, 8), + ("xxxxxx", 6, 8), + ("💩", 1, 3), + ("💩💩", 2, 6), + ], +) +async def test_tab_with_spaces_goes_to_tab_stop( + content, cursor_column, cursor_destination +): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.indent_width = 4 + text_area.load_text(content) + text_area.cursor_location = (0, cursor_column) + + await pilot.press("tab") + + assert text_area.cursor_location[1] == cursor_destination + + +async def test_delete_left(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("Hello, world!") + text_area.move_cursor((0, 6)) + await pilot.press("backspace") + assert text_area.text == "Hello world!" + assert text_area.selection == Selection.cursor((0, 5)) + + +async def test_delete_left_start(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("Hello, world!") + await pilot.press("backspace") + assert text_area.text == "Hello, world!" + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_delete_left_end(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("Hello, world!") + text_area.move_cursor((0, 13)) + await pilot.press("backspace") + assert text_area.text == "Hello, world" + assert text_area.selection == Selection.cursor((0, 12)) + + +@pytest.mark.parametrize( + "key,selection", + [ + ("delete", Selection((1, 2), (3, 4))), + ("delete", Selection((3, 4), (1, 2))), + ("backspace", Selection((1, 2), (3, 4))), + ("backspace", Selection((3, 4), (1, 2))), + ], +) +async def test_deletion_with_non_empty_selection(key, selection): + """When there's a selection, pressing backspace or delete should delete everything + that is selected and reset the selection to a cursor at the appropriate location.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text(SIMPLE_TEXT) + text_area.selection = selection + await pilot.press(key) + assert text_area.selection == Selection.cursor((1, 2)) + assert ( + text_area.text + == """\ +ABCDE +FGT +UVWXY +Z""" + ) + + +async def test_delete_right(): + """Pressing 'delete' deletes the character to the right of the cursor.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("Hello, world!") + text_area.move_cursor((0, 13)) + await pilot.press("delete") + assert text_area.text == "Hello, world!" + assert text_area.selection == Selection.cursor((0, 13)) + + +async def test_delete_right_end_of_line(): + """Pressing 'delete' at the end of the line merges this line with the line below.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("hello\nworld!") + end_of_line = text_area.get_cursor_line_end_location() + text_area.move_cursor(end_of_line) + await pilot.press("delete") + assert text_area.selection == Selection.cursor((0, 5)) + assert text_area.text == "helloworld!" + + +@pytest.mark.parametrize( + "selection,expected_result", + [ + (Selection.cursor((0, 0)), ""), + (Selection.cursor((0, 4)), ""), + (Selection.cursor((0, 10)), ""), + (Selection((0, 2), (0, 4)), ""), + (Selection((0, 4), (0, 2)), ""), + ], +) +async def test_delete_line(selection, expected_result): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = selection + + await pilot.press("ctrl+x") + + assert text_area.selection == Selection.cursor((0, 0)) + assert text_area.text == expected_result + + +@pytest.mark.parametrize( + "selection,expected_result", + [ + # Cursors + (Selection.cursor((0, 0)), "345\n678\n9\n"), + (Selection.cursor((0, 2)), "345\n678\n9\n"), + (Selection.cursor((3, 1)), "012\n345\n678\n"), + (Selection.cursor((4, 0)), "012\n345\n678\n9\n"), + # Selections + (Selection((1, 1), (1, 2)), "012\n678\n9\n"), # non-empty single line selection + (Selection((1, 2), (2, 1)), "012\n9\n"), # delete lines selection touches + ( + Selection((1, 2), (3, 0)), + "012\n9\n", + ), # cursor at column 0 of line 3, should not be deleted! + ( + Selection((3, 0), (1, 2)), + "012\n9\n", + ), # opposite direction + (Selection((0, 0), (4, 0)), ""), # delete all lines + ], +) +async def test_delete_line_multiline_document(selection, expected_result): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("012\n345\n678\n9\n") + text_area.selection = selection + + await pilot.press("ctrl+x") + + cursor_row, _ = text_area.cursor_location + assert text_area.selection == Selection.cursor((cursor_row, 0)) + assert text_area.text == expected_result + + +@pytest.mark.parametrize( + "selection,expected_result", + [ + # Cursors + (Selection.cursor((0, 0)), ""), + (Selection.cursor((0, 5)), "01234"), + (Selection.cursor((0, 9)), "012345678"), + (Selection.cursor((0, 10)), "0123456789"), + # Selections + (Selection((0, 0), (0, 9)), "012345678"), + (Selection((0, 0), (0, 10)), "0123456789"), + (Selection((0, 2), (0, 5)), "01234"), + (Selection((0, 5), (0, 2)), "01"), + ], +) +async def test_delete_to_end_of_line(selection, expected_result): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = selection + + await pilot.press("ctrl+k") + + assert text_area.selection == Selection.cursor(selection.end) + assert text_area.text == expected_result + + +@pytest.mark.parametrize( + "selection,expected_result", + [ + # Cursors + (Selection.cursor((0, 0)), "0123456789"), + (Selection.cursor((0, 5)), "56789"), + (Selection.cursor((0, 9)), "9"), + (Selection.cursor((0, 10)), ""), + # Selections + (Selection((0, 0), (0, 9)), "9"), + (Selection((0, 0), (0, 10)), ""), + (Selection((0, 2), (0, 5)), "56789"), + (Selection((0, 5), (0, 2)), "23456789"), + ], +) +async def test_delete_to_start_of_line(selection, expected_result): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = selection + + await pilot.press("ctrl+u") + + assert text_area.selection == Selection.cursor((0, 0)) + assert text_area.text == expected_result + + +@pytest.mark.parametrize( + "selection,expected_result,final_selection", + [ + (Selection.cursor((0, 0)), " 012 345 6789", Selection.cursor((0, 0))), + (Selection.cursor((0, 4)), " 2 345 6789", Selection.cursor((0, 2))), + (Selection.cursor((0, 5)), " 345 6789", Selection.cursor((0, 2))), + ( + Selection.cursor((0, 6)), + " 345 6789", + Selection.cursor((0, 2)), + ), + (Selection.cursor((0, 14)), " 012 345 ", Selection.cursor((0, 10))), + # When there's a selection and you "delete word left", it just deletes the selection + (Selection((0, 4), (0, 11)), " 01789", Selection.cursor((0, 4))), + ], +) +async def test_delete_word_left(selection, expected_result, final_selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text(" 012 345 6789") + text_area.selection = selection + + await pilot.press("ctrl+w") + + assert text_area.text == expected_result + assert text_area.selection == final_selection + + +@pytest.mark.parametrize( + "selection,expected_result,final_selection", + [ + (Selection.cursor((0, 0)), "\t012 \t 345\t6789", Selection.cursor((0, 0))), + (Selection.cursor((0, 4)), "\t \t 345\t6789", Selection.cursor((0, 1))), + (Selection.cursor((0, 5)), "\t\t 345\t6789", Selection.cursor((0, 1))), + ( + Selection.cursor((0, 6)), + "\t 345\t6789", + Selection.cursor((0, 1)), + ), + (Selection.cursor((0, 15)), "\t012 \t 345\t", Selection.cursor((0, 11))), + # When there's a selection and you "delete word left", it just deletes the selection + (Selection((0, 4), (0, 11)), "\t0126789", Selection.cursor((0, 4))), + ], +) +async def test_delete_word_left_with_tabs(selection, expected_result, final_selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("\t012 \t 345\t6789") + text_area.selection = selection + + await pilot.press("ctrl+w") + + assert text_area.text == expected_result + assert text_area.selection == final_selection + + +async def test_delete_word_left_to_start_of_line(): + """If no word boundary found when we 'delete word left', then + the deletion happens to the start of the line.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123\n 456789") + text_area.selection = Selection.cursor((1, 3)) + + await pilot.press("ctrl+w") + + assert text_area.text == "0123\n456789" + assert text_area.selection == Selection.cursor((1, 0)) + + +async def test_delete_word_left_at_line_start(): + """If we're at the start of a line and we 'delete word left', the + line merges with the line above (if possible).""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123\n 456789") + text_area.selection = Selection.cursor((1, 0)) + + await pilot.press("ctrl+w") + + assert text_area.text == "0123 456789" + assert text_area.selection == Selection.cursor((0, 4)) + + +@pytest.mark.parametrize( + "selection,expected_result,final_selection", + [ + (Selection.cursor((0, 0)), "012 345 6789", Selection.cursor((0, 0))), + (Selection.cursor((0, 4)), " 01 345 6789", Selection.cursor((0, 4))), + (Selection.cursor((0, 5)), " 012345 6789", Selection.cursor((0, 5))), + (Selection.cursor((0, 14)), " 012 345 6789", Selection.cursor((0, 14))), + # When non-empty selection, "delete word right" just deletes the selection + (Selection((0, 4), (0, 11)), " 01789", Selection.cursor((0, 4))), + ], +) +async def test_delete_word_right(selection, expected_result, final_selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text(" 012 345 6789") + text_area.selection = selection + + await pilot.press("ctrl+f") + + assert text_area.text == expected_result + assert text_area.selection == final_selection + + +async def test_delete_word_right_delete_to_end_of_line(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("01234\n56789") + text_area.selection = Selection.cursor((0, 3)) + + await pilot.press("ctrl+f") + + assert text_area.text == "012\n56789" + assert text_area.selection == Selection.cursor((0, 3)) + + +async def test_delete_word_right_at_end_of_line(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("01234\n56789") + text_area.selection = Selection.cursor((0, 5)) + + await pilot.press("ctrl+f") + + assert text_area.text == "0123456789" + assert text_area.selection == Selection.cursor((0, 5)) diff --git a/tests/text_area/test_languages.py b/tests/text_area/test_languages.py new file mode 100644 index 000000000..dc8a59300 --- /dev/null +++ b/tests/text_area/test_languages.py @@ -0,0 +1,97 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import LanguageDoesNotExist + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea("print('hello')", language="python") + + +async def test_setting_builtin_language_via_constructor(): + class MyTextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea("print('hello')", language="python") + + app = MyTextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.language == "python" + + text_area.language = "markdown" + assert text_area.language == "markdown" + + +async def test_setting_builtin_language_via_attribute(): + class MyTextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea("print('hello')") + text_area.language = "python" + yield text_area + + app = MyTextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.language == "python" + + text_area.language = "markdown" + assert text_area.language == "markdown" + + +async def test_setting_language_to_none(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.language = None + assert text_area.language is None + + +async def test_setting_unknown_language(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + with pytest.raises(LanguageDoesNotExist): + text_area.language = "this-language-doesnt-exist" + + +async def test_register_language(): + app = TextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + + # Get the language from py-tree-sitter-languages... + from tree_sitter_languages import get_language + + language = get_language("elm") + + # ...and register it with no highlights + text_area.register_language(language, "") + + # Ensure that registered language is now available. + assert "elm" in text_area.available_languages + + # Switch to the newly registered language + text_area.language = "elm" + + assert text_area.language == "elm" + + +async def test_register_language_existing_language(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + # Before registering the language, we have highlights as expected. + assert len(text_area._highlights) > 0 + + # Overwriting the highlight query for Python... + text_area.register_language("python", "") + + # We've overridden the highlight query with a blank one, so there are no highlights. + assert text_area._highlights == {} diff --git a/tests/text_area/test_selection.py b/tests/text_area/test_selection.py new file mode 100644 index 000000000..d089aecc0 --- /dev/null +++ b/tests/text_area/test_selection.py @@ -0,0 +1,296 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import Selection + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.load_text(TEXT) + yield text_area + + +def test_default_selection(): + """The cursor starts at (0, 0) in the document.""" + text_area = TextArea() + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_cursor_location_get(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((1, 1), (2, 2)) + assert text_area.cursor_location == (2, 2) + + +async def test_cursor_location_set(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + target = (1, 2) + text_area.cursor_location = target + assert text_area.selection == Selection.cursor(target) + + +async def test_cursor_location_set_while_selecting(): + """If you set the cursor_location while a selection is in progress, + the start/anchor point of the selection will remain where it is.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((0, 0), (0, 2)) + target = (1, 2) + text_area.cursor_location = target + assert text_area.selection == Selection((0, 0), target) + + +async def test_move_cursor_select(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((1, 1), (2, 2)) + text_area.move_cursor((2, 3), select=True) + assert text_area.selection == Selection((1, 1), (2, 3)) + + +async def test_move_cursor_relative(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + text_area.move_cursor_relative(rows=1, columns=2) + assert text_area.selection == Selection.cursor((1, 2)) + + text_area.move_cursor_relative(rows=-1, columns=-2) + assert text_area.selection == Selection.cursor((0, 0)) + + text_area.move_cursor_relative(rows=1000, columns=1000) + assert text_area.selection == Selection.cursor((4, 0)) + + +async def test_selected_text_forward(): + """Selecting text from top to bottom results in the correct selected_text.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((0, 0), (2, 0)) + assert ( + text_area.selected_text + == """\ +I must not fear. +Fear is the mind-killer. +""" + ) + + +async def test_selected_text_backward(): + """Selecting text from bottom to top results in the correct selected_text.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((2, 0), (0, 0)) + assert ( + text_area.selected_text + == """\ +I must not fear. +Fear is the mind-killer. +""" + ) + + +async def test_selected_text_multibyte(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("こんにちは") + text_area.selection = Selection((0, 1), (0, 3)) + assert text_area.selected_text == "んに" + + +async def test_selection_clamp(): + """When you set the selection reactive, it's clamped to within the document bounds.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((99, 99), (100, 100)) + assert text_area.selection == Selection(start=(4, 0), end=(4, 0)) + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 0), (0, 0)), + ((0, 4), (0, 3)), + ((1, 0), (0, 16)), + ], +) +async def test_get_cursor_left_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor(start) + assert text_area.get_cursor_left_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 0), (0, 1)), + ((0, 16), (1, 0)), + ((3, 20), (4, 0)), + ((4, 0), (4, 0)), + ], +) +async def test_get_cursor_right_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor(start) + assert text_area.get_cursor_right_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 4), (0, 0)), # jump to start + ((1, 2), (0, 2)), # go to column above + ((2, 56), (1, 24)), # snap to end of row above + ], +) +async def test_get_cursor_up_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor(start) + # This is required otherwise the cursor will snap back to the + # last location navigated to (0, 0) + text_area.record_cursor_width() + assert text_area.get_cursor_up_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((3, 4), (4, 0)), # jump to end + ((1, 2), (2, 2)), # go to column above + ((2, 56), (3, 20)), # snap to end of row below + ], +) +async def test_get_cursor_down_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor(start) + # This is required otherwise the cursor will snap back to the + # last location navigated to (0, 0) + text_area.record_cursor_width() + assert text_area.get_cursor_down_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 0), (0, 0)), + ((0, 1), (0, 0)), + ((0, 2), (0, 0)), + ((0, 3), (0, 0)), + ((0, 4), (0, 3)), + ((0, 5), (0, 3)), + ((0, 6), (0, 3)), + ((0, 7), (0, 3)), + ((0, 10), (0, 7)), + ((1, 0), (0, 10)), + ((1, 2), (1, 0)), + ((1, 4), (1, 0)), + ((1, 7), (1, 4)), + ((1, 8), (1, 7)), + ((1, 13), (1, 11)), + ((1, 14), (1, 11)), + ], +) +async def test_cursor_word_left_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("AB CD EFG\n HI\tJK LM ") + text_area.move_cursor(start) + assert text_area.get_cursor_word_left_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 0), (0, 2)), + ((0, 1), (0, 2)), + ((0, 2), (0, 5)), + ((0, 3), (0, 5)), + ((0, 4), (0, 5)), + ((0, 5), (0, 10)), + ((0, 6), (0, 10)), + ((0, 7), (0, 10)), + ((0, 10), (1, 0)), + ((1, 0), (1, 6)), + ((1, 2), (1, 6)), + ((1, 4), (1, 6)), + ((1, 7), (1, 9)), + ((1, 8), (1, 9)), + ((1, 13), (1, 14)), + ((1, 14), (1, 14)), + ], +) +async def test_cursor_word_right_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("AB CD EFG\n HI\tJK LM ") + text_area.move_cursor(start) + assert text_area.get_cursor_word_right_location() == end + + +@pytest.mark.parametrize( + "content,expected_selection", + [ + ("123\n456\n789", Selection((0, 0), (2, 3))), + ("123\n456\n789\n", Selection((0, 0), (3, 0))), + ("", Selection((0, 0), (0, 0))), + ], +) +async def test_select_all(content, expected_selection): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text(content) + + text_area.select_all() + + assert text_area.selection == expected_selection + + +@pytest.mark.parametrize( + "index,content,expected_selection", + [ + (1, "123\n456\n789\n", Selection((1, 0), (1, 3))), + (2, "123\n456\n789\n", Selection((2, 0), (2, 3))), + (3, "123\n456\n789\n", Selection((3, 0), (3, 0))), + (1000, "123\n456\n789\n", Selection.cursor((0, 0))), + (0, "", Selection((0, 0), (0, 0))), + ], +) +async def test_select_line(index, content, expected_selection): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text(content) + + text_area.select_line(index) + + assert text_area.selection == expected_selection diff --git a/tests/text_area/test_selection_bindings.py b/tests/text_area/test_selection_bindings.py new file mode 100644 index 000000000..76d4586df --- /dev/null +++ b/tests/text_area/test_selection_bindings.py @@ -0,0 +1,318 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.geometry import Offset +from textual.widgets import TextArea +from textual.widgets.text_area import Document, Selection + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.load_text(TEXT) + yield text_area + + +async def test_mouse_click(): + """When you click the TextArea, the cursor moves to the expected location.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + await pilot.click(TextArea, Offset(x=5, y=2)) + assert text_area.selection == Selection.cursor((2, 2)) + + +async def test_mouse_click_clamp_from_right(): + """When you click to the right of the document bounds, the cursor is clamped + to within the document bounds.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + await pilot.click(TextArea, Offset(x=8, y=20)) + assert text_area.selection == Selection.cursor((4, 0)) + + +async def test_mouse_click_gutter_clamp(): + """When you click the gutter, it selects the start of the line.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + await pilot.click(TextArea, Offset(x=0, y=3)) + assert text_area.selection == Selection.cursor((3, 0)) + + +async def test_cursor_movement_basic(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("01234567\n012345\n0123456789") + + await pilot.press("right") + assert text_area.selection == Selection.cursor((0, 1)) + + await pilot.press("down") + assert text_area.selection == Selection.cursor((1, 1)) + + await pilot.press("left") + assert text_area.selection == Selection.cursor((1, 0)) + + await pilot.press("up") + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_cursor_selection_right(): + """When you press shift+right the selection is updated correctly.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + await pilot.press(*["shift+right"] * 3) + assert text_area.selection == Selection((0, 0), (0, 3)) + + +async def test_cursor_selection_right_to_previous_line(): + """When you press shift+right resulting in the cursor moving to the next line, + the selection is updated correctly.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.selection = Selection.cursor((0, 15)) + await pilot.press(*["shift+right"] * 4) + assert text_area.selection == Selection((0, 15), (1, 2)) + + +async def test_cursor_selection_left(): + """When you press shift+left the selection is updated correctly.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.selection = Selection.cursor((2, 5)) + await pilot.press(*["shift+left"] * 3) + assert text_area.selection == Selection((2, 5), (2, 2)) + + +async def test_cursor_selection_left_to_previous_line(): + """When you press shift+left resulting in the cursor moving back to the previous line, + the selection is updated correctly.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.selection = Selection.cursor((2, 2)) + await pilot.press(*["shift+left"] * 3) + + # The cursor jumps up to the end of the line above. + end_of_previous_line = len(TEXT.splitlines()[1]) + assert text_area.selection == Selection((2, 2), (1, end_of_previous_line)) + + +async def test_cursor_selection_up(): + """When you press shift+up the selection is updated correctly.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.move_cursor((2, 3)) + + await pilot.press("shift+up") + assert text_area.selection == Selection((2, 3), (1, 3)) + + +async def test_cursor_selection_up_when_cursor_on_first_line(): + """When you press shift+up the on the first line, it selects to the start.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.move_cursor((0, 4)) + + await pilot.press("shift+up") + assert text_area.selection == Selection((0, 4), (0, 0)) + await pilot.press("shift+up") + assert text_area.selection == Selection((0, 4), (0, 0)) + + +async def test_cursor_selection_down(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.move_cursor((2, 5)) + + await pilot.press("shift+down") + assert text_area.selection == Selection((2, 5), (3, 5)) + + +async def test_cursor_selection_down_when_cursor_on_last_line(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("ABCDEF\nGHIJK") + text_area.move_cursor((1, 2)) + + await pilot.press("shift+down") + assert text_area.selection == Selection((1, 2), (1, 5)) + await pilot.press("shift+down") + assert text_area.selection == Selection((1, 2), (1, 5)) + + +async def test_cursor_word_right(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("ABC DEF\nGHIJK") + + await pilot.press("ctrl+right") + + assert text_area.selection == Selection.cursor((0, 3)) + + +async def test_cursor_word_right_select(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("ABC DEF\nGHIJK") + + await pilot.press("ctrl+shift+right") + + assert text_area.selection == Selection((0, 0), (0, 3)) + + +async def test_cursor_word_left(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("ABC DEF\nGHIJK") + text_area.move_cursor((0, 7)) + + await pilot.press("ctrl+left") + + assert text_area.selection == Selection.cursor((0, 4)) + + +async def test_cursor_word_left_select(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("ABC DEF\nGHIJK") + text_area.move_cursor((0, 7)) + + await pilot.press("ctrl+shift+left") + + assert text_area.selection == Selection((0, 7), (0, 4)) + + +@pytest.mark.parametrize("key", ["end", "ctrl+e"]) +async def test_cursor_to_line_end(key): + """You can use the keyboard to jump the cursor to the end of the current line.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.selection = Selection.cursor((2, 2)) + await pilot.press(key) + eol_index = len(TEXT.splitlines()[2]) + assert text_area.cursor_location == (2, eol_index) + assert text_area.selection.is_empty + + +@pytest.mark.parametrize("key", ["home", "ctrl+a"]) +async def test_cursor_to_line_home_basic_behaviour(key): + """You can use the keyboard to jump the cursor to the start of the current line.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.selection = Selection.cursor((2, 2)) + await pilot.press(key) + assert text_area.cursor_location == (2, 0) + assert text_area.selection.is_empty + + +@pytest.mark.parametrize( + "cursor_start,cursor_destination", + [ + ((0, 0), (0, 4)), + ((0, 2), (0, 0)), + ((0, 4), (0, 0)), + ((0, 5), (0, 4)), + ((0, 9), (0, 4)), + ((0, 15), (0, 4)), + ], +) +async def test_cursor_line_home_smart_home(cursor_start, cursor_destination): + """If the line begins with whitespace, pressing home firstly goes + to the start of the (non-whitespace) content. Pressing it again takes you to column + 0. If you press it again, it goes back to the first non-whitespace column.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text(" hello world") + text_area.move_cursor(cursor_start) + await pilot.press("home") + assert text_area.selection == Selection.cursor(cursor_destination) + + +async def test_cursor_page_down(): + """Pagedown moves the cursor down 1 page, retaining column index.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("XXX\n" * 200) + text_area.selection = Selection.cursor((0, 1)) + await pilot.press("pagedown") + assert text_area.selection == Selection.cursor((app.console.height - 1, 1)) + + +async def test_cursor_page_up(): + """Pageup moves the cursor up 1 page, retaining column index.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("XXX\n" * 200) + text_area.selection = Selection.cursor((100, 1)) + await pilot.press("pageup") + assert text_area.selection == Selection.cursor( + (100 - app.console.height + 1, 1) + ) + + +async def test_cursor_vertical_movement_visual_alignment_snapping(): + """When you move the cursor vertically, it should stay vertically + aligned even when double-width characters are used.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_document(Document("こんにちは\n012345")) + text_area.move_cursor((1, 3), record_width=True) + + # The '3' is aligned with ん at (0, 1) + # こんにちは + # 012345 + # Pressing `up` takes us from (1, 3) to (0, 1) because record_width=True. + await pilot.press("up") + assert text_area.selection == Selection.cursor((0, 1)) + + # Pressing `down` takes us from (0, 1) to (1, 3) + await pilot.press("down") + assert text_area.selection == Selection.cursor((1, 3)) + + +async def test_select_line_binding(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.move_cursor((2, 2)) + + await pilot.press("f6") + + assert text_area.selection == Selection((2, 0), (2, 56)) + + +async def test_select_all_binding(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + + await pilot.press("f7") + + assert text_area.selection == Selection((0, 0), (4, 0)) diff --git a/tests/text_area/test_setting_themes.py b/tests/text_area/test_setting_themes.py new file mode 100644 index 000000000..8d165a98a --- /dev/null +++ b/tests/text_area/test_setting_themes.py @@ -0,0 +1,67 @@ +import pytest + +from textual._text_area_theme import TextAreaTheme +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets._text_area import ThemeDoesNotExist + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea("print('hello')", language="python") + + +async def test_default_theme(): + app = TextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.theme is None + + +async def test_setting_builtin_themes(): + class MyTextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea("print('hello')", language="python", theme="vscode_dark") + + app = MyTextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.theme == "vscode_dark" + + text_area.theme = "monokai" + assert text_area.theme == "monokai" + + +async def test_setting_theme_to_none(): + app = TextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.theme = None + assert text_area.theme is None + # When theme is None, we use the default theme. + assert text_area._theme.name == TextAreaTheme.default().name + + +async def test_setting_unknown_theme_raises_exception(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + with pytest.raises(ThemeDoesNotExist): + text_area.theme = "this-theme-doesnt-exist" + + +async def test_registering_and_setting_theme(): + app = TextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.register_theme(TextAreaTheme("my-theme")) + + assert "my-theme" in text_area.available_themes + + text_area.theme = "my-theme" + + assert text_area.theme == "my-theme" diff --git a/tests/text_area/test_text_area_theme.py b/tests/text_area/test_text_area_theme.py new file mode 100644 index 000000000..e69de29bb diff --git a/tree-sitter/highlights/bash.scm b/tree-sitter/highlights/bash.scm new file mode 100644 index 000000000..23bf03e69 --- /dev/null +++ b/tree-sitter/highlights/bash.scm @@ -0,0 +1,145 @@ +(simple_expansion) @none +(expansion + "${" @punctuation.special + "}" @punctuation.special) @none +[ + "(" + ")" + "((" + "))" + "{" + "}" + "[" + "]" + "[[" + "]]" + ] @punctuation.bracket + +[ + ";" + ";;" + (heredoc_start) + ] @punctuation.delimiter + +[ + "$" +] @punctuation.special + +[ + ">" + ">>" + "<" + "<<" + "&" + "&&" + "|" + "||" + "=" + "=~" + "==" + "!=" + ] @operator + +[ + (string) + (raw_string) + (ansi_c_string) + (heredoc_body) +] @string @spell + +(variable_assignment (word) @string) + +[ + "if" + "then" + "else" + "elif" + "fi" + "case" + "in" + "esac" + ] @conditional + +[ + "for" + "do" + "done" + "select" + "until" + "while" + ] @repeat + +[ + "declare" + "export" + "local" + "readonly" + "unset" + ] @keyword + +"function" @keyword.function + +(special_variable_name) @constant + +; trap -l +((word) @constant.builtin + (#match? @constant.builtin "^SIG(HUP|INT|QUIT|ILL|TRAP|ABRT|BUS|FPE|KILL|USR[12]|SEGV|PIPE|ALRM|TERM|STKFLT|CHLD|CONT|STOP|TSTP|TT(IN|OU)|URG|XCPU|XFSZ|VTALRM|PROF|WINCH|IO|PWR|SYS|RTMIN([+]([1-9]|1[0-5]))?|RTMAX(-([1-9]|1[0-4]))?)$")) + +((word) @boolean + (#any-of? @boolean "true" "false")) + +(comment) @comment @spell +(test_operator) @string + +(command_substitution + [ "$(" ")" ] @punctuation.bracket) + +(process_substitution + [ "<(" ")" ] @punctuation.bracket) + + +(function_definition + name: (word) @function) + +(command_name (word) @function.call) + +((command_name (word) @function.builtin) + (#any-of? @function.builtin + "alias" "bg" "bind" "break" "builtin" "caller" "cd" + "command" "compgen" "complete" "compopt" "continue" + "coproc" "dirs" "disown" "echo" "enable" "eval" + "exec" "exit" "fc" "fg" "getopts" "hash" "help" + "history" "jobs" "kill" "let" "logout" "mapfile" + "popd" "printf" "pushd" "pwd" "read" "readarray" + "return" "set" "shift" "shopt" "source" "suspend" + "test" "time" "times" "trap" "type" "typeset" + "ulimit" "umask" "unalias" "wait")) + +(command + argument: [ + (word) @parameter + (concatenation (word) @parameter) + ]) + +((word) @number + (#lua-match? @number "^[0-9]+$")) + +(file_redirect + descriptor: (file_descriptor) @operator + destination: (word) @parameter) + +(expansion + [ "${" "}" ] @punctuation.bracket) + +(variable_name) @variable + +((variable_name) @constant + (#lua-match? @constant "^[A-Z][A-Z_0-9]*$")) + +(case_item + value: (word) @parameter) + +(regex) @string.regex + +((program . (comment) @preproc) + (#lua-match? @preproc "^#!/")) diff --git a/tree-sitter/highlights/css.scm b/tree-sitter/highlights/css.scm new file mode 100644 index 000000000..b26f0ec96 --- /dev/null +++ b/tree-sitter/highlights/css.scm @@ -0,0 +1,91 @@ +[ + "@media" + "@charset" + "@namespace" + "@supports" + "@keyframes" + (at_keyword) + (to) + (from) + ] @keyword + +"@import" @include + +(comment) @comment @spell + +[ + (tag_name) + (nesting_selector) + (universal_selector) + ] @type + +(function_name) @function + +[ + "~" + ">" + "+" + "-" + "*" + "/" + "=" + "^=" + "|=" + "~=" + "$=" + "*=" + "and" + "or" + "not" + "only" + ] @operator + +(important) @type.qualifier + +(attribute_selector (plain_value) @string) +(pseudo_element_selector "::" (tag_name) @property) +(pseudo_class_selector (class_name) @property) + +[ + (class_name) + (id_name) + (property_name) + (feature_name) + (attribute_name) + ] @property + +(namespace_name) @namespace + +((property_name) @type.definition + (#lua-match? @type.definition "^[-][-]")) +((plain_value) @type + (#lua-match? @type "^[-][-]")) + +[ + (string_value) + (color_value) + (unit) + ] @string + +[ + (integer_value) + (float_value) + ] @number + +[ + "#" + "," + "." + ":" + "::" + ";" + ] @punctuation.delimiter + +[ + "{" + ")" + "(" + "}" + ] @punctuation.bracket + +(ERROR) @error diff --git a/tree-sitter/highlights/html.scm b/tree-sitter/highlights/html.scm new file mode 100644 index 000000000..15f2adb43 --- /dev/null +++ b/tree-sitter/highlights/html.scm @@ -0,0 +1,64 @@ +(tag_name) @tag +(erroneous_end_tag_name) @html.end_tag_error +(comment) @comment +(attribute_name) @tag.attribute +(attribute + (quoted_attribute_value) @string) +(text) @text @spell + +((element (start_tag (tag_name) @_tag) (text) @text.title) + (#eq? @_tag "title")) + +((element (start_tag (tag_name) @_tag) (text) @text.title.1) + (#eq? @_tag "h1")) + +((element (start_tag (tag_name) @_tag) (text) @text.title.2) + (#eq? @_tag "h2")) + +((element (start_tag (tag_name) @_tag) (text) @text.title.3) + (#eq? @_tag "h3")) + +((element (start_tag (tag_name) @_tag) (text) @text.title.4) + (#eq? @_tag "h4")) + +((element (start_tag (tag_name) @_tag) (text) @text.title.5) + (#eq? @_tag "h5")) + +((element (start_tag (tag_name) @_tag) (text) @text.title.6) + (#eq? @_tag "h6")) + +((element (start_tag (tag_name) @_tag) (text) @text.strong) + (#any-of? @_tag "strong" "b")) + +((element (start_tag (tag_name) @_tag) (text) @text.emphasis) + (#any-of? @_tag "em" "i")) + +((element (start_tag (tag_name) @_tag) (text) @text.strike) + (#any-of? @_tag "s" "del")) + +((element (start_tag (tag_name) @_tag) (text) @text.underline) + (#eq? @_tag "u")) + +((element (start_tag (tag_name) @_tag) (text) @text.literal) + (#any-of? @_tag "code" "kbd")) + +((element (start_tag (tag_name) @_tag) (text) @text.uri) + (#eq? @_tag "a")) + +((attribute + (attribute_name) @_attr + (quoted_attribute_value (attribute_value) @text.uri)) + (#any-of? @_attr "href" "src")) + +[ + "<" + ">" + "" +] @tag.delimiter + +"=" @operator + +(doctype) @constant + +"" + "=" + "==" + ">" + ">=" + ">>" + ">>=" + "@" + "@=" + "|" + "|=" + "~" + "->" +] @operator + +; Keywords +[ + "and" + "in" + "is" + "not" + "or" + "del" +] @keyword.operator + +[ + "def" + "lambda" +] @keyword.function + +[ + "assert" + "async" + "await" + "class" + "exec" + "global" + "nonlocal" + "pass" + "print" + "with" + "as" +] @keyword + +[ + "return" + "yield" +] @keyword.return +(yield "from" @keyword.return) + +(future_import_statement + "from" @include + "__future__" @constant.builtin) +(import_from_statement "from" @include) +"import" @include + +(aliased_import "as" @include) + +["if" "elif" "else" "match" "case"] @conditional + +["for" "while" "break" "continue"] @repeat + +[ + "try" + "except" + "raise" + "finally" +] @exception + +(raise_statement "from" @exception) + +(try_statement + (else_clause + "else" @exception)) + +["(" ")" "[" "]" "{" "}"] @punctuation.bracket + +(interpolation + "{" @punctuation.special + "}" @punctuation.special) + +["," "." ":" ";" (ellipsis)] @punctuation.delimiter + +;; Class definitions + +(class_definition name: (identifier) @type.class) + +(class_definition + body: (block + (function_definition + name: (identifier) @method))) + +(class_definition + superclasses: (argument_list + (identifier) @type)) + +((class_definition + body: (block + (expression_statement + (assignment + left: (identifier) @field)))) + (#match? @field "^([A-Z])@!.*$")) +((class_definition + body: (block + (expression_statement + (assignment + left: (_ + (identifier) @field))))) + (#match? @field "^([A-Z])@!.*$")) + +((class_definition + (block + (function_definition + name: (identifier) @constructor))) + (#any-of? @constructor "__new__" "__init__")) + +;; Error +(ERROR) @error diff --git a/tree-sitter/highlights/regex.scm b/tree-sitter/highlights/regex.scm new file mode 100644 index 000000000..7c671c2c0 --- /dev/null +++ b/tree-sitter/highlights/regex.scm @@ -0,0 +1,34 @@ +;; Forked from tree-sitter-regex +;; The MIT License (MIT) Copyright (c) 2014 Max Brunsfeld +[ + "(" + ")" + "(?" + "(?:" + "(?<" + ">" + "[" + "]" + "{" + "}" +] @regex.punctuation.bracket + +(group_name) @property + +;; These are escaped special characters that lost their special meaning +;; -> no special highlighting +(identity_escape) @string.regex + +(class_character) @constant + +[ + (control_letter_escape) + (character_class_escape) + (control_escape) + (start_assertion) + (end_assertion) + (boundary_assertion) + (non_boundary_assertion) +] @string.escape + +[ "*" "+" "?" "|" "=" "!" ] @regex.operator diff --git a/tree-sitter/highlights/sql.scm b/tree-sitter/highlights/sql.scm new file mode 100644 index 000000000..03a15fe38 --- /dev/null +++ b/tree-sitter/highlights/sql.scm @@ -0,0 +1,114 @@ +(string) @string +(number) @number +(comment) @comment + +(function_call + function: (identifier) @function) + +[ + (NULL) + (TRUE) + (FALSE) +] @constant.builtin + +([ + (type_cast + (type (identifier) @type.builtin)) + (create_function_statement + (type (identifier) @type.builtin)) + (create_function_statement + (create_function_parameters + (create_function_parameter (type (identifier) @type.builtin)))) + (create_type_statement + (type_spec_composite (type (identifier) @type.builtin))) + (create_table_statement + (table_parameters + (table_column (type (identifier) @type.builtin)))) + ] + (#match? + @type.builtin + "^(bigint|BIGINT|int8|INT8|bigserial|BIGSERIAL|serial8|SERIAL8|bit|BIT|varbit|VARBIT|boolean|BOOLEAN|bool|BOOL|box|BOX|bytea|BYTEA|character|CHARACTER|char|CHAR|varchar|VARCHAR|cidr|CIDR|circle|CIRCLE|date|DATE|float8|FLOAT8|inet|INET|integer|INTEGER|int|INT|int4|INT4|interval|INTERVAL|json|JSON|jsonb|JSONB|line|LINE|lseg|LSEG|macaddr|MACADDR|money|MONEY|numeric|NUMERIC|decimal|DECIMAL|path|PATH|pg_lsn|PG_LSN|point|POINT|polygon|POLYGON|real|REAL|float4|FLOAT4|smallint|SMALLINT|int2|INT2|smallserial|SMALLSERIAL|serial2|SERIAL2|serial|SERIAL|serial4|SERIAL4|text|TEXT|time|TIME|time|TIME|timestamp|TIMESTAMP|tsquery|TSQUERY|tsvector|TSVECTOR|txid_snapshot|TXID_SNAPSHOT|enum|ENUM|range|RANGE)$")) + +(identifier) @variable + +[ + "::" + "<" + "<=" + "<>" + "=" + ">" + ">=" +] @operator + +[ + "(" + ")" + "[" + "]" +] @punctuation.bracket + +[ + ";" + "." +] @punctuation.delimiter + +[ + (type) + (array_type) +] @type + +[ + (primary_key_constraint) + (unique_constraint) + (null_constraint) +] @keyword + +[ + "AND" + "AS" + "AUTO_INCREMENT" + "CREATE" + "CREATE_DOMAIN" + "CREATE_OR_REPLACE_FUNCTION" + "CREATE_SCHEMA" + "TABLE" + "TEMPORARY" + "CREATE_TYPE" + "DATABASE" + "FROM" + "GRANT" + "GROUP_BY" + "IF_NOT_EXISTS" + "INDEX" + "INNER" + "INSERT" + "INTO" + "IN" + "JOIN" + "LANGUAGE" + "LEFT" + "LOCAL" + "NOT" + "ON" + "OR" + "ORDER_BY" + "OUTER" + "PRIMARY_KEY" + "PUBLIC" + "RETURNS" + "SCHEMA" + "SELECT" + "SESSION" + "SET" + "TABLE" + "TIME_ZONE" + "TO" + "UNIQUE" + "UPDATE" + "USAGE" + "VALUES" + "WHERE" + "WITH" + "WITHOUT" +] @keyword diff --git a/tree-sitter/highlights/toml.scm b/tree-sitter/highlights/toml.scm new file mode 100644 index 000000000..9228d2807 --- /dev/null +++ b/tree-sitter/highlights/toml.scm @@ -0,0 +1,36 @@ +; Properties +;----------- + +(bare_key) @toml.type +(quoted_key) @string +(pair (bare_key)) @property + +; Literals +;--------- + +(boolean) @boolean +(comment) @comment @spell +(string) @string +(integer) @number +(float) @float +(offset_date_time) @toml.datetime +(local_date_time) @toml.datetime +(local_date) @toml.datetime +(local_time) @toml.datetime + +; Punctuation +;------------ + +"." @punctuation.delimiter +"," @punctuation.delimiter + +"=" @toml.operator + +"[" @punctuation.bracket +"]" @punctuation.bracket +"[[" @punctuation.bracket +"]]" @punctuation.bracket +"{" @punctuation.bracket +"}" @punctuation.bracket + +(ERROR) @toml.error diff --git a/tree-sitter/highlights/yaml.scm b/tree-sitter/highlights/yaml.scm new file mode 100644 index 000000000..a57f464df --- /dev/null +++ b/tree-sitter/highlights/yaml.scm @@ -0,0 +1,53 @@ +(boolean_scalar) @boolean +(null_scalar) @constant.builtin +(double_quote_scalar) @string +(single_quote_scalar) @string +((block_scalar) @string (#set! "priority" 99)) +(string_scalar) @string +(escape_sequence) @string.escape +(integer_scalar) @number +(float_scalar) @number +(comment) @comment +(anchor_name) @type +(alias_name) @type +(tag) @type +(ERROR) @error + +[ + (yaml_directive) + (tag_directive) + (reserved_directive) +] @preproc + +(block_mapping_pair + key: (flow_node [(double_quote_scalar) (single_quote_scalar)] @yaml.field)) +(block_mapping_pair + key: (flow_node (plain_scalar (string_scalar) @yaml.field))) + +(flow_mapping + (_ key: (flow_node [(double_quote_scalar) (single_quote_scalar)] @yaml.field))) +(flow_mapping + (_ key: (flow_node (plain_scalar (string_scalar) @yaml.field)))) + +[ + "," + "-" + ":" + ">" + "?" + "|" +] @punctuation.delimiter + +[ + "[" + "]" + "{" + "}" +] @punctuation.bracket + +[ + "*" + "&" + "---" + "..." +] @punctuation.special