* Add docstring and switch to tree-sitter-languages wheels - although the wheels arent working

* Adding highlights files

* Fix index error on SyntaxAwareDocument

* Narrowing highlighting scope

* Adding basic highlights for Markdown

* Using utf-8 byte length instead of codepoint count in syntax aware doc

* Start creating an ABC defining functionality required by Document impls

* Simplify tree-sitter logic

* Extracting more ABC

* Fix width calculation, add SyntaxTheme

* Ensure the highlight line style goes right to the very end

* Updating a docstring

* Renaming, and adding document width guide

* Ensuring that line number column toggling refreshes virtual size

* Ensuring that line number column toggling refreshes virtual size

* Width guide

* Fix focus event stopping

* Use release_mouse

* Improving a docstring

* Remove bash

* TextArea language snapshot testing

* Updating snapshots for TextArea since we now highlight more nodes

* Typing fixes

* Testing

* Adding tests

* Fixing language selection

* Refresh size on indent width change

* Testing, renaming, fixing display of selection

* Fix multibyte highlight glitch

* Fix deleting right with selection at end of document in TextArea

* Fixing utf-8 multibyte character issues

* Default location of text insertion is cursor position, add cursor_location properties

* Removing some debugging code

* Cursor location tests

* Updating snapshots

* Cached utf8 encoding

* TextArea selection snapshot testing

* Tidying docstrings and queries

* Updating selection snapshot output

* Binding for ESC to shift focus

* Only build the tree-sitter query once!

* Expand cursor scroll horizontal leeway in TextArea

* Property setter for cursor_location in TextArea shouldnt return value

* Avoiding NamedTuple subclassing - using type aliasing instead

* Tidying API, docstrings etc.

* Tidying the API and docstrings

* TextArea additional cursor tests

* Testing pageup and pagedown in TextArea

* Fix a faulty test

* Docstring in a test for TextArea edit

* Stop using DEFAULT_SYNTAX_THEME

* Docstrings

* Change cursor_destination to move_cursor, add more tests

* Remove faulty assertion

* Tidying cursor movement

* Tidying up, adding docstrings for component classes

* Fix a broken selection test

* Remove some unused highlighting machinery

* Fix some Python highlighting issues

* Make HTML syntax highlight nicely

* Create tag name for mismatching HTML end tag

* Add styling for YAML, update boolean styling

* Stylising toml types

* Styling floats

* JSON syntax highlighting

* Updating snapshots

* Syntax highlighting datetimes in TOML

* Namespace TOML errors in highlighting

* Add a move_cursor_relative method

* Update TOML TextArea snapshot for datetime highlighting support

* Adjusting selections

* At TextArea widget level, delete_range is insert_range of empty string

* Refactoring

* Dunder all, docstring fix

* Fix XFAIL

* Remove unused import

* More tests, tidying up

* Cleaning the API

* Docstrings for TextArea

* A bunch of docstrings, delete unused code

* More tidying and docstrings

* Cursor origin on document load, correctly handle delete word left/right when selection is non-empty, fix delete_line when selection spans multiple lines and is in reverse direction

* Moving things around

* Fixing dunder all to export DocumentBase

* Add docstring

* Record cursor width on programmatic insert since it can result in the cursor moving

* Typing fixes

* Fixing remaining typing issues with TextArea

* Add tree-sitter-languages stubs and fix typing issues in documents

* Fixing remaining typing issues with document

* Updating Syntax themes

* Improve highlighting, add initial TextArea docs page

* Add TextArea indent note

* Start TextArea guide inside reference

* Add TextArea to widget gallery

* Fleshing out TextArea docs

* Add note

* Fix TextArea programmatic insert/cursor interaction

* Improve a test

* Testing replacement within selection

* Testing double-width character keyboard navigation and deletion keybinds with active selections

* Testing "delete to start of line" TextArea binding

* Testing TextArea delete line methods and delete to end of line

* Testing shift selecting using keyboard in vertical direction

* Expand tests for home and end keybinds in TextArea

* Renaming tests, testing empty replace and insert

* Testing delete word left via API

* Testing delete word left via API

* Testing delete_word_left with tabs, and delete_word_right

* Remove unused variables

* Remove debugging width guide

* Fix snapshot report path

* Deleting word left/right interaction with line ends fixes, ensure cursor width recorded on all edits

* Docstring fixes

* Unpin textual snapshot library dependency (issue is fixed)

* Docstring fixes

* Fix recording cursor width

* Fix a docstring

* Add select_all to TextArea

* Remove unused tree-sitter stuff from .gitignore

* Line select

* Make word pattern private in TextArea

* Add blinking cursor to TextArea

* Renaming, adding missing return typing

* Add selection bindings

* Moving cursor left/right by word while selecting

* Change escape keybind description, TextArea

* Stripping whitespace when going word left/right

* Add missing annotation

* Cursor word right and left parity with PyCharm

* Use repaint=False for cursor blink

* Improve focus/blur styling

* A whole bunch of TextArea testing

* Simplify delete_left and delete_right

* Testing hiding line numbers in snapshot

* Adding snapshot test for unfocus styling

* Create initial snapshot for text-area unfocused

* Support shift+home, shift+end

* Document shift+home, shift+end

* Add Dracula syntax highlighting theme

* Small change to delete_line behaviour when multiple lines selected to match vscode/pycharm behaviour

* Add test for new delete line logic

* Delete line improvement

* Add extra test for delete_line multiple selection

* Test cursor "smart" home behaviour

* Fix typo

* Highlight matching brackets

* Update snapshot

* Update snapshot

* Fix xfails

* Simplify delete_word_left

* Catch correct exception to ensure support for Python 3.7

* Add styling for Markdown

* Add styles for Dracula for Markdown

* Remove unused _fix_direction.py

* Add docstring to EditResult

* Use default=0 in max inside Document

* Remove redundant actions

* Use cell-width aware expand tabs implementation from @willmcgugan

* Construct strip with cell length

* Some TextArea keyword-only arguments

* Begin moving over to TextAreaTheme #skipci

* Prepare queries inside document #skip-ci

* Add comment

* Refactoring

* TextAreaTheme styling

* Setting width of blank selected lines

* Building the highlight map in the text area

* Remove unused default css from TextArea

* Moving highlighting stylize into widget

* Moving syntax highlighting into TextArea widget

* Remove unused code

* Optimise imports

* Fix highlighting when initial text supplied to TextArea

* Rebuild highlight map when the theme changes

* Extending

* Restore themes

* Remove old comment, fix docstring

* Fixing docstrings

* Fixing mypy

* Fixing mypy issues in document

* Tidying things

* Updating version

* Add theme

* Fix VSCode theme bracket matching

* Only match brackets when theres no selection

* Highlighting tidying

* Fix markdown header highlighting

* Setting theme correctly in background

* Tidying module interface

* Merging main

* Fixing a bunch of typing problems

* Fixing more typing problems

* Correctly setting theme object

* mypy

* Small fix to bracket matching

* Improve a docstring

* Fix docstring

* Testing builtin and custom languages

* Unit testing theme stuff

* Reworking themes

* Error handling

* Improve error message

* Testing new theme setting approach, error handling

* Improvements/tests for theme and language setting

* Remove unused TextArea unfocus snapshot

* Update snapshot file

* Adding theme snapshot tests

* Add `function.call` style binding in dark vscode theme

* Renaming a test file

* Making active line clearer on vscode theme

* Renaming tests

* A whole lot of docs for TextArea

* Update wording in docs

* A bit more docs

* Example on adding Java as a custom language

* More custom language docs

* Finishing up custom themeing/syntax highlighting guide for TextArea

* Add note on potential issue

* Fix wording

* Add note on Apple Silicon Python 3.7 fallback

* Add another note on Apple Silicon Python 3.7 fallback

* Fix class names in example files

* Add some documentation for useful TextArea APIs

* TextArea docs improvements

* TextArea docs typo fix

* Note about extending TextArea

* Tab-stop support when spaces used for indent

* Docs update

* Text area blog post (#3356)

* Start blog post

* Add demo script to blog post

* Continuing the blog post

* Yet more writing for TextArea blog post

* Working on closing section

* Finishing up

* Update docs/blog/posts/text-area-learnings.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/blog/posts/text-area-learnings.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Typo fix

* Update docs/blog/posts/text-area-learnings.md

Co-authored-by: Dave Pearson <davep@davep.org>

---------

Co-authored-by: Dave Pearson <davep@davep.org>

* Remove redundant pass

* Add docstring

* Docs fix

* Simplify docs

* Improve docstring

* Add links in docstrings

---------

Co-authored-by: Dave Pearson <davep@davep.org>
This commit is contained in:
Darren Burns
2023-09-21 11:10:14 +01:00
committed by GitHub
parent d204ba86b5
commit bbde62fc57
65 changed files with 12197 additions and 1262 deletions

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -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.
<!-- more -->
## 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!
<figure markdown>
![cursor_position_updating_via_api.png](../images/text-area-learnings/cursor_position_updating_via_api.png){ loading=lazy }
<figcaption>A TetrisArea white-boarding session.</figcaption>
</figure>
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!
<figure markdown>
![text-area-pyinstrument.png](../images/text-area-learnings/text-area-pyinstrument.png){ loading=lazy }
<figcaption>"pyinstrument -r html" produces this beautiful output.</figcaption>
</figure>
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.
<figure markdown>
![text-area-theme-cycle.gif](../images/text-area-learnings/text-area-theme-cycle.gif){ loading=lazy }
<figcaption>Cycling through a few of the builtin themes.</figcaption>
</figure>
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:
<figure markdown>
![text-area-syntax-error.gif](../images/text-area-learnings/text-area-syntax-error.gif){ loading=lazy }
<figcaption>Highlighting mismatched closing HTML tags in red.</figcaption>
</figure>
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!

View File

@@ -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):

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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):

View File

@@ -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

View File

@@ -32,6 +32,7 @@ Example app showing the widget:
## Reactive Attributes
## Messages
## Bindings

467
docs/widgets/text_area.md Normal file
View File

@@ -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 <span style="background-color: dodgerblue; color: white; padding: 0 2px;">blue</span>, and the cursor line <span style="background-color: yellow; color: black; padding: 0 2px;">yellow</span>.
Our theme will also syntax highlight strings as <span style="background-color: red; color: white; padding: 0 2px;">red</span>, and comments as <span style="background-color: magenta; color: black; padding: 0 2px;">magenta</span>.
```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

View File

@@ -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"

2649
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

View File

@@ -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

View File

@@ -0,0 +1,13 @@
BUILTIN_LANGUAGES = sorted(
[
"markdown",
"yaml",
"sql",
"css",
"html",
"json",
"python",
"regex",
"toml",
]
)

View File

@@ -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""

View File

@@ -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:

View File

@@ -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"))

View File

@@ -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()

View File

@@ -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.

View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,4 @@
from ._rule import (
InvalidLineStyle,
InvalidRuleOrientation,
LineStyle,
RuleOrientation,
)
from ._rule import InvalidLineStyle, InvalidRuleOrientation, LineStyle, RuleOrientation
__all__ = [
"InvalidLineStyle",

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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.",
]

View File

@@ -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.",
"",
]

File diff suppressed because one or more lines are too long

View File

@@ -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 = """\
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Meta tags -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Title -->
<title>HTML Test Page</title>
<!-- Link to CSS -->
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Header section -->
<header class="header">
<h1 id="logo">HTML Test Page</h1>
</header>
<!-- Navigation -->
<nav class="nav">
<ul>
<li><a href="#">Home</a></li>
<li><a href="#">About</a></li>
<li><a href="#">Contact</a></li>
</ul>
</nav>
<!-- Main content area -->
<main>
<article>
<h2>Welcome to the Test Page</h2>
<p>This is a paragraph to test the HTML structure.</p>
<img src="test-image.jpg" alt="Test Image" width="300">
</article>
</main>
<!-- Form -->
<section>
<form action="/submit" method="post">
<label for="name">Name:</label>
<input type="text" id="name" name="name">
<input type="submit" value="Submit">
</form>
</section>
<!-- Footer -->
<footer>
<p>&copy; 2023 HTML Test Page</p>
</footer>
<!-- Script tag -->
<script src="scripts.js"></script>
</body>
</html>
"""
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"
(?<!a)b # Negative lookbehind: matches "b" that is not preceded by "a"
a(?=b) # Positive lookahead: matches "a" that is followed by "b"
a(?!b) # Negative lookahead: matches "a" that is not followed by "b"
"""
SNIPPETS = {
"python": PYTHON,
"markdown": MARKDOWN,
"yaml": YAML,
"toml": TOML,
"sql": SQL,
"css": CSS,
"html": HTML,
"json": JSON,
"regex": REGEX,
}

View File

@@ -0,0 +1,15 @@
"""Tests the rendering of the TextArea for all supported languages."""
from textual.app import App, ComposeResult
from textual.widgets import TextArea
class TextAreaSnapshot(App):
def compose(self) -> ComposeResult:
text_area = TextArea()
text_area.cursor_blink = False
yield text_area
app = TextAreaSnapshot()
if __name__ == "__main__":
app.run()

View File

@@ -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()

View File

@@ -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")

View File

@@ -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"

View File

@@ -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))

View File

@@ -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 == {}

View File

@@ -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

View File

@@ -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))

View File

@@ -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"

View File

View File

@@ -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 "^#!/"))

View File

@@ -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

View File

@@ -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
"<!" @tag.delimiter

View File

@@ -0,0 +1,32 @@
[
(true)
(false)
] @boolean
(null) @json.null
(number) @number
(pair key: (string) @json.label)
(pair value: (string) @string)
(array (string) @string)
(string_content) @spell
(ERROR) @json.error
["," ":"] @punctuation.delimiter
[
"[" "]"
"{" "}"
] @punctuation.bracket
(("\"" @conceal)
(#set! conceal ""))
(escape_sequence) @string.escape
((escape_sequence) @conceal
(#eq? @conceal "\\\"")
(#set! conceal "\""))

View File

@@ -0,0 +1,9 @@
(heading_content) @heading
(list_marker) @comment
(strong_emphasis) @bold
(emphasis) @italic
(strikethrough) @strikethrough
(link) @link
(code_span) @inline_code
(info_string) @info_string
(fenced_code_block) @fenced_code_block

View File

@@ -0,0 +1,327 @@
;; From tree-sitter-python licensed under MIT License
; Copyright (c) 2016 Max Brunsfeld
; Adapted for Textual from:
; https://github.com/nvim-treesitter/nvim-treesitter/blob/f95ffd09ed35880c3a46ad2b968df361fa592a76/queries/python/highlights.scm
; Variables
(identifier) @variable
; Reset highlighting in f-string interpolations
(interpolation) @none
;; Identifier naming conventions
((identifier) @type
(#lua-match? @type "^[A-Z].*[a-z]"))
((identifier) @constant
(#lua-match? @constant "^[A-Z][A-Z_0-9]*$"))
((identifier) @constant.builtin
(#lua-match? @constant.builtin "^__[a-zA-Z0-9_]*__$"))
((identifier) @constant.builtin
(#any-of? @constant.builtin
;; https://docs.python.org/3/library/constants.html
"NotImplemented"
"Ellipsis"
"quit"
"exit"
"copyright"
"credits"
"license"))
((attribute
attribute: (identifier) @field)
(#match? @field "^([A-Z])@!.*$"))
((identifier) @type.builtin
(#any-of? @type.builtin
;; https://docs.python.org/3/library/exceptions.html
"BaseException" "Exception" "ArithmeticError" "BufferError" "LookupError" "AssertionError" "AttributeError"
"EOFError" "FloatingPointError" "GeneratorExit" "ImportError" "ModuleNotFoundError" "IndexError" "KeyError"
"KeyboardInterrupt" "MemoryError" "NameError" "NotImplementedError" "OSError" "OverflowError" "RecursionError"
"ReferenceError" "RuntimeError" "StopIteration" "StopAsyncIteration" "SyntaxError" "IndentationError" "TabError"
"SystemError" "SystemExit" "TypeError" "UnboundLocalError" "UnicodeError" "UnicodeEncodeError" "UnicodeDecodeError"
"UnicodeTranslateError" "ValueError" "ZeroDivisionError" "EnvironmentError" "IOError" "WindowsError"
"BlockingIOError" "ChildProcessError" "ConnectionError" "BrokenPipeError" "ConnectionAbortedError"
"ConnectionRefusedError" "ConnectionResetError" "FileExistsError" "FileNotFoundError" "InterruptedError"
"IsADirectoryError" "NotADirectoryError" "PermissionError" "ProcessLookupError" "TimeoutError" "Warning"
"UserWarning" "DeprecationWarning" "PendingDeprecationWarning" "SyntaxWarning" "RuntimeWarning"
"FutureWarning" "ImportWarning" "UnicodeWarning" "BytesWarning" "ResourceWarning"
;; https://docs.python.org/3/library/stdtypes.html
"bool" "int" "float" "complex" "list" "tuple" "range" "str"
"bytes" "bytearray" "memoryview" "set" "frozenset" "dict" "type"))
((assignment
left: (identifier) @type.definition
(type (identifier) @_annotation))
(#eq? @_annotation "TypeAlias"))
((assignment
left: (identifier) @type.definition
right: (call
function: (identifier) @_func))
(#any-of? @_func "TypeVar" "NewType"))
; Function calls
(call
function: (identifier) @function.call)
(call
function: (attribute
attribute: (identifier) @method.call))
((call
function: (identifier) @constructor)
(#lua-match? @constructor "^[A-Z]"))
((call
function: (attribute
attribute: (identifier) @constructor))
(#lua-match? @constructor "^[A-Z]"))
;; Decorators
((decorator "@" @attribute)
(#set! "priority" 101))
(decorator
(identifier) @attribute)
(decorator
(attribute
attribute: (identifier) @attribute))
(decorator
(call (identifier) @attribute))
(decorator
(call (attribute
attribute: (identifier) @attribute)))
((decorator
(identifier) @attribute.builtin)
(#any-of? @attribute.builtin "classmethod" "property"))
;; Builtin functions
((call
function: (identifier) @function.builtin)
(#any-of? @function.builtin
"abs" "all" "any" "ascii" "bin" "bool" "breakpoint" "bytearray" "bytes" "callable" "chr" "classmethod"
"compile" "complex" "delattr" "dict" "dir" "divmod" "enumerate" "eval" "exec" "filter" "float" "format"
"frozenset" "getattr" "globals" "hasattr" "hash" "help" "hex" "id" "input" "int" "isinstance" "issubclass"
"iter" "len" "list" "locals" "map" "max" "memoryview" "min" "next" "object" "oct" "open" "ord" "pow"
"print" "property" "range" "repr" "reversed" "round" "set" "setattr" "slice" "sorted" "staticmethod" "str"
"sum" "super" "tuple" "type" "vars" "zip" "__import__"))
;; Function definitions
(function_definition
name: (identifier) @function)
(type (identifier) @type)
(type
(subscript
(identifier) @type)) ; type subscript: Tuple[int]
((call
function: (identifier) @_isinstance
arguments: (argument_list
(_)
(identifier) @type))
(#eq? @_isinstance "isinstance"))
;; Normal parameters
(parameters
(identifier) @parameter)
;; Lambda parameters
(lambda_parameters
(identifier) @parameter)
(lambda_parameters
(tuple_pattern
(identifier) @parameter))
; Default parameters
(keyword_argument
name: (identifier) @parameter)
; Naming parameters on call-site
(default_parameter
name: (identifier) @parameter)
(typed_parameter
(identifier) @parameter)
(typed_default_parameter
(identifier) @parameter)
; Variadic parameters *args, **kwargs
(parameters
(list_splat_pattern ; *args
(identifier) @parameter))
(parameters
(dictionary_splat_pattern ; **kwargs
(identifier) @parameter))
;; Literals
(none) @constant.builtin
[(true) (false)] @boolean
((identifier) @variable.builtin
(#eq? @variable.builtin "self"))
((identifier) @variable.builtin
(#eq? @variable.builtin "cls"))
(integer) @number
(float) @float
(comment) @comment @spell
((module . (comment) @preproc)
(#match? @preproc "^#!/"))
(string) @string
(escape_sequence) @string.escape
; doc-strings
(expression_statement (string) @spell)
; Tokens
[
"-"
"-="
":="
"!="
"*"
"**"
"**="
"*="
"/"
"//"
"//="
"/="
"&"
"&="
"%"
"%="
"^"
"^="
"+"
"+="
"<"
"<<"
"<<="
"<="
"<>"
"="
"=="
">"
">="
">>"
">>="
"@"
"@="
"|"
"|="
"~"
"->"
] @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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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