Text area (#2931)
* 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>
2
.github/workflows/pythonpackage.yml
vendored
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 181 KiB |
BIN
docs/blog/images/text-area-learnings/maintain_offset.gif
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
docs/blog/images/text-area-learnings/text-area-api-insert.gif
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
docs/blog/images/text-area-learnings/text-area-pyinstrument.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
docs/blog/images/text-area-learnings/text-area-syntax-error.gif
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/blog/images/text-area-learnings/text-area-theme-cycle.gif
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
docs/blog/images/text-area-learnings/text-area-welcome.gif
Normal file
|
After Width: | Height: | Size: 89 KiB |
210
docs/blog/posts/text-area-learnings.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
{ 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.
|
||||
|
||||
{ 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>
|
||||
{ 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>
|
||||
{ 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>
|
||||
{ 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>
|
||||
{ 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!
|
||||
@@ -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):
|
||||
|
||||
140
docs/examples/widgets/java_highlights.scm
Normal 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
|
||||
34
docs/examples/widgets/text_area_custom_language.py
Normal 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()
|
||||
42
docs/examples/widgets/text_area_custom_theme.py
Normal 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()
|
||||
20
docs/examples/widgets/text_area_example.py
Normal 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()
|
||||
23
docs/examples/widgets/text_area_extended.py
Normal 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()
|
||||
23
docs/examples/widgets/text_area_selection.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ Example app showing the widget:
|
||||
|
||||
## Reactive Attributes
|
||||
|
||||
## Messages
|
||||
|
||||
## Bindings
|
||||
|
||||
|
||||
467
docs/widgets/text_area.md
Normal 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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
353
src/textual/_text_area_theme.py
Normal 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."""
|
||||
10
src/textual/_tree_sitter.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
0
src/textual/document/__init__.py
Normal file
389
src/textual/document/_document.py
Normal 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
|
||||
13
src/textual/document/_languages.py
Normal file
@@ -0,0 +1,13 @@
|
||||
BUILTIN_LANGUAGES = sorted(
|
||||
[
|
||||
"markdown",
|
||||
"yaml",
|
||||
"sql",
|
||||
"css",
|
||||
"html",
|
||||
"json",
|
||||
"python",
|
||||
"regex",
|
||||
"toml",
|
||||
]
|
||||
)
|
||||
268
src/textual/document/_syntax_aware_document.py
Normal 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""
|
||||
@@ -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:
|
||||
|
||||
49
src/textual/expand_tabs.py
Normal 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"))
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
1865
src/textual/widgets/_text_area.py
Normal file
@@ -1,9 +1,4 @@
|
||||
from ._rule import (
|
||||
InvalidLineStyle,
|
||||
InvalidRuleOrientation,
|
||||
LineStyle,
|
||||
RuleOrientation,
|
||||
)
|
||||
from ._rule import InvalidLineStyle, InvalidRuleOrientation, LineStyle, RuleOrientation
|
||||
|
||||
__all__ = [
|
||||
"InvalidLineStyle",
|
||||
|
||||
37
src/textual/widgets/text_area.py
Normal 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",
|
||||
]
|
||||
100
tests/document/test_document.py
Normal 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
|
||||
146
tests/document/test_document_delete.py
Normal 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.",
|
||||
]
|
||||
107
tests/document/test_document_insert.py
Normal 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.",
|
||||
"",
|
||||
]
|
||||
466
tests/snapshot_tests/language_snippets.py
Normal 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
|
||||
|
||||

|
||||
|
||||
|
||||
```
|
||||
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>© 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,
|
||||
}
|
||||
15
tests/snapshot_tests/snapshot_apps/text_area.py
Normal 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()
|
||||
17
tests/snapshot_tests/snapshot_apps/text_area_unfocus.py
Normal 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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
522
tests/text_area/test_edit_via_api.py
Normal 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"
|
||||
418
tests/text_area/test_edit_via_bindings.py
Normal 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))
|
||||
97
tests/text_area/test_languages.py
Normal 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 == {}
|
||||
296
tests/text_area/test_selection.py
Normal 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
|
||||
318
tests/text_area/test_selection_bindings.py
Normal 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))
|
||||
67
tests/text_area/test_setting_themes.py
Normal 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"
|
||||
0
tests/text_area/test_text_area_theme.py
Normal file
145
tree-sitter/highlights/bash.scm
Normal 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 "^#!/"))
|
||||
91
tree-sitter/highlights/css.scm
Normal 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
|
||||
64
tree-sitter/highlights/html.scm
Normal 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
|
||||
32
tree-sitter/highlights/json.scm
Normal 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 "\""))
|
||||
9
tree-sitter/highlights/markdown.scm
Normal 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
|
||||
327
tree-sitter/highlights/python.scm
Normal 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
|
||||
34
tree-sitter/highlights/regex.scm
Normal 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
|
||||
114
tree-sitter/highlights/sql.scm
Normal 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
|
||||
36
tree-sitter/highlights/toml.scm
Normal 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
|
||||
53
tree-sitter/highlights/yaml.scm
Normal 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
|
||||