1
0
Fork 0

Adding upstream version 0.15.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-24 11:29:34 +01:00
parent 0184169650
commit c6da052ee9
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
47 changed files with 6799 additions and 0 deletions

15
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,15 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"

47
.github/workflows/publish.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: Build and Publish Package
on:
pull_request:
branches:
- main
types:
- closed
jobs:
publish-package:
if: ${{ github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v') }}
runs-on: ubuntu-latest
steps:
- name: Check out textarea main branch
uses: actions/checkout@v4
with:
ref: main
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.2.2
- name: Configure poetry
run: poetry config --no-interaction pypi-token.pypi ${{ secrets.TEXTAREA_PYPI_TOKEN }}
- name: Get textarea Version
id: textarea_version
run: echo "textarea_version=$(poetry version --short)" >> $GITHUB_OUTPUT
- name: Build package
run: poetry build --no-interaction
- name: Publish package to PyPI
run: poetry publish --no-interaction
- name: Create a Github Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.textarea_version.outputs.textarea_version }}
target_commitish: main
token: ${{ secrets.TEXTAREA_RELEASE_TOKEN }}
body_path: CHANGELOG.md
files: |
LICENSE
dist/*textual_textarea*.whl
dist/*textual_textarea*.tar.gz

56
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,56 @@
name: Create Release Branch
on:
workflow_dispatch:
inputs:
newVersion:
description: A version number for this release (e.g., "0.1.0")
required: true
jobs:
prepare-release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check out textarea main branch
uses: actions/checkout@v4
with:
ref: main
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.2.2
- name: Create release branch
run: |
git checkout -b release/v${{ github.event.inputs.newVersion }}
git push --set-upstream origin release/v${{ github.event.inputs.newVersion }}
- name: Bump version
run: poetry version ${{ github.event.inputs.newVersion }} --no-interaction
- name: Ensure package can be built
run: poetry build --no-interaction
- name: Update CHANGELOG
uses: thomaseizinger/keep-a-changelog-new-release@v3
with:
version: ${{ github.event.inputs.newVersion }}
- name: Commit Changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: Bumps version to ${{ github.event.inputs.newVersion }}
- name: Create pull request into main
uses: thomaseizinger/create-pull-request@1.3.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
head: release/v${{ github.event.inputs.newVersion }}
base: main
title: v${{ github.event.inputs.newVersion }}
body: >
This PR was automatically generated. It bumps the version number
in pyproject.toml and updates CHANGELOG.md. You may have to close
this PR and reopen it to get the required checks to run.

46
.github/workflows/static.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: "Perform Static Analysis"
on:
pull_request:
# will cancel previous workflows triggered by the same event and for the same ref for PRs or same SHA otherwise
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.ref || github.sha }}
cancel-in-progress: true
jobs:
static:
name: Static Analysis - 3.11
runs-on: ubuntu-latest
steps:
- name: Check out Repo
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
id: setup-python
with:
python-version: "3.11"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.4.2
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v4
with:
path: .venv
key: static-venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
- name: Install python dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --sync --no-interaction --without dev
- name: Run analysis
run: |
source .venv/bin/activate
ruff format .
ruff check .
mypy

92
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,92 @@
name: Test
on:
push:
branches: [ main ]
pull_request:
# will cancel previous workflows triggered by the same event and for the same ref for PRs or same SHA otherwise
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.ref || github.sha }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
test-windows:
name: Windows - 3.10
runs-on: Windows-latest
timeout-minutes: 10
steps:
- name: Check out Repo
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.4.2
- name: Install python dependencies
run: poetry install --sync --no-interaction --only main,test
- name: Run tests
run: poetry run pytest
test:
name: ${{ matrix.os }} - ${{ matrix.py }}
runs-on: ${{ matrix.os }}-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os:
- ubuntu
- MacOs
py:
- "3.13"
- "3.12"
- "3.11"
- "3.10"
- "3.9"
steps:
- name: Check out Repo
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python ${{ matrix.py }}
id: setup-python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.py }}
- name: Load cached Poetry installation
id: cached-poetry-install
uses: actions/cache@v4
with:
path: ~/.local
key: poetry-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
- name: Install Poetry
if: steps.cached-poetry-install.outputs.cache-hit != 'true'
uses: snok/install-poetry@v1
with:
version: 1.4.2
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
- name: Install python dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --sync --no-interaction --only main,test
- name: Run tests
run: |
source .venv/bin/activate
pytest

166
.gitignore vendored Normal file
View file

@ -0,0 +1,166 @@
*.db
*.db.wal
*.sql
.profiles
Pipfile
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

24
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,24 @@
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.5.1
hooks:
- id: ruff-format
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.1
hooks:
- id: mypy
additional_dependencies:
- textual[syntax]>=0.89.1
- pytest
args:
- "--disallow-untyped-calls"
- "--disallow-untyped-defs"
- "--disallow-incomplete-defs"
- "--strict-optional"
- "--warn-return-any"
- "--warn-no-return"
- "--warn-redundant-casts"
- "--no-warn-unused-ignores"
- "--allow-untyped-decorators"

325
CHANGELOG.md Normal file
View file

@ -0,0 +1,325 @@
# textual-textarea CHANGELOG
All notable changes to this project will be documented in this file.
## [Unreleased]
## [0.15.0] - 2024-12-19
- **Breaking:** changes function return signature `query_syntax_tree`.
- **Breaking:** drops support for Python 3.8.
- **Breaking:** drops support for Pygments themes.
- Adds support for Python 3.13.
- Adds support for Textual app themes, including syntax highlighting using the theme colors.
- Updates syntax highlighting language libraries.
## [0.14.4] - 2024-10-09
- Fixes a crash caused by copying or pasting with the system clipboard in some rare system configurations.
## [0.14.3] - 2024-10-09
- Fixes a crash caused by pressing `ctrl+g` twice ([tconbeer/harlequin#654](https://github.com/tconbeer/harlequin/issues/654)).
## [0.14.2] - 2024-08-16
- Fixes a TypeError raised by Textual >= v0.76 from the goto input validator.
## [0.14.1] - 2024-08-15
- Fixes a bug where uncommenting a line using the `toggle_comment` action would leave behind a space in languages with comment markers that are longer than one character ([tconbeer/harlequin#616](https://github.com/tconbeer/harlequin/issues/616)).
## [0.14.0] - 2024-07-09
- Updates dependencies and removes black in favor of the ruff formatter.
## [0.13.1] - 2024-06-28
- Bumps the pyperclip version for improved clipboard support on Wayland (Linux) ([tconbeer/harlequin#585](https://github.com/tconbeer/harlequin/issues/585)).
## [0.13.0] - 2024-04-19
- Adds a "find" action with <kbd>ctrl+f</kbd> and "find next" action with <kbd>F3</kbd>.
- Adds "go to line" action with <kbd>ctrl+g</kbd>.
- Adds bindings for `ctrl+shift+home` and `ctrl+shift+end` to select while moving to document start/end.
- Uses the new, native undo and redo functionality provided by the Textual TextArea widget. This has some subtly different behavior ([#240](https://github.com/tconbeer/textual-textarea/issues/240).
## [0.12.0] - 2024-04-17
- Show the computed filepath in the Save and Open widgets ([#232](https://github.com/tconbeer/textual-textarea/issues/232) - thank you [@bjornasm](https://github.com/bjornasm)!).
- Fixes a crash from initializing the Error Modal incorrectly.
- Fixes a crash from saving to a path in a non-existent directory.
## [0.11.3] - 2024-02-09
- No longer changes focus on `escape` (regression since 0.11.0)
## [0.11.2] - 2024-02-08
- Adds a `parser` property to the `CodeEditor` class to return the document's tree-sitter parser.
## [0.11.1] - 2024-02-08
- Adds a `syntax_tree` property to the `CodeEditor` class to return the document's tree-sitter syntax tree.
## [0.11.0] - 2024-02-06
- Bumps textual dependency to >=0.48.1
- Breaking change: Renames the main class from TextArea to TextEditor, to avoid naming conflicts with the built-in TextArea widget (which caused issues with selectors, CSS, etc.).
- Breaking change: Replaces the `cursor` and `selection_anchor` API with `selection`.
- Adds public APIs: `line_count`, `get_line`, `get_text_range`, `copy_to_clipboard`, `pause_blink`, `restart_blink`, `prepare_query`, and `query_syntax_tree`.
## [0.10.0] - 2024-01-30
- Adds a `text` argument when initializing TextArea.
- Improves time to first paint by finding the system clipboard in a thread, instead of blocking mounting. This has an especially large impact on Windows.
## [0.9.5] - 2023-12-18
- Ignore spurious `ctrl+@` keys generated by Windows. (See [textualize/textual#872](https://github.com/Textualize/textual/issues/872)).
## [0.9.4] - 2023-12-18
- No longer show bindings in the footer when the open or save inputs are focussed.
## [0.9.3] - 2023-12-15
- Fixes an issue where very long completions with short prefixes were truncated improperly.
## [0.9.2] - 2023-12-13
- Hides the cursor and autocomplete list when the TextArea widget is not focussed. ([#177](https://github.com/tconbeer/textual-textarea/issues/177)).
## [0.9.1] - 2023-12-13
- Fixes an issue where the autocomplete list was displayed in the wrong location after pressing backspace.
## [0.9.0] - 2023-12-12
- TextArea now provides auto-complete. By default, it will auto-complete paths; to auto-complete words or
members of a namespace, set TextArea.word_completer, TextArea.member_completer, TextArea.path_completer
to a Callable\[[str], list\[tuple[str, str]]]. The callables will receive the current word (or path, etc.) as their
argument and should return a list of completions, where completions are (label, value) pairs.
- The TextArea is now focused when the Open or Save inputs are cancelled.
## [0.8.0] - 2023-12-06
- The TextArea has been completely overhauled. It now uses the built-in TextArea widget under the hood.
- This package now requires Textual >= 0.41.0, as it requires Textual's built-in TextArea widget.
- Double-click a word to select it; triple-click to select the row; quadruple-click to select the whole document.
- Fixes a bug with toggling comments.
## [0.7.3] - 2023-10-06
- The PathInput cursor no longer blinks if the app is run in headless mode (during tests). This only matters to prevent
flaky tests for snapshot testing this widget and downstream apps.
## [0.7.2] - 2023-10-06
- The TextArea cursor no longer blinks if the app is run in headless mode (during tests). This only matters to prevent
flaky tests for snapshot testing this widget and downstream apps.
## [0.7.1] - 2023-09-22
- TextArea now posts a `TextAreaSaved` message if it successfully saves a file.
## [0.7.0] - 2023-09-20
### Features
- TextArea now posts a `TextAreaClipboardError` message if it cannot access the system clipboard.
### Fixes
- TextArea now uses the contents of the system Paste message, instead of relying exclusively on the
system clipboard. This should improve compatibility when Harlequin's host does not share its
clipboard with the user's native system.
- When using the system clipboard, TextArea now initializes the clipboard on mount, resulting in
better performance when copying and pasting.
- `textual_textarea.key_handlers.Cursor` is now exported from the main `textual_textarea` package.
- Cursor position is no longer updated on a right-click.
## [0.6.0] - 2023-09-08
### Features
- Adds a new public method, `TextArea.insert_text_at_selection(text)`.
## [0.5.4] - 2023-09-01
### Bug Fixes
- <kbd>up</kbd>, <kbd>down</kbd>, <kbd>pageup</kbd>, and <kbd>pagedown</kbd> now better maintain the cursor's x-position when starting with an x-position that is longer than adjacent lines ([#94](https://github.com/tconbeer/textual-textarea/issues/94)).
## [0.5.3] - 2023-09-01
### Bug Fixes
- Undo is smarter about cursor positions and selections; it no longer saves a new checkpoint for every cursor position. ([#86](https://github.com/tconbeer/textual-textarea/issues/86)).
- Clicks within the container but outside text will still update the cursor ([#93](https://github.com/tconbeer/textual-textarea/issues/93)).
- The cursor is now scrolled into position much faster.
## [0.5.2] - 2023-08-23
### Bug Fixes
- TextArea now uses the highlight color from the Pygments Style to highlight selected text.
## [0.5.1] - 2023-08-23
### Bug Fixes
- Fixes a crash caused by <kbd>shift+delete</kbd> on a buffer with only one line.
## [0.5.0] - 2023-08-22
### Features
- Undo any input with <kbd>ctrl+z</kbd>; redo with <kbd>ctrl+y</kbd> ([#12](https://github.com/tconbeer/textual-textarea/issues/12)).
- <kbd>shift+delete</kbd> now deletes the current line if there is no selection ([#77](https://github.com/tconbeer/textual-textarea/issues/77)).
### Tests
- Adds basic fuzzing of text and keyboard inputs ([#50](https://github.com/tconbeer/textual-textarea/issues/50))
## [0.4.2] - 2023-08-03
### Bug Fixes
- No longer clears selection for more keystrokes (e.g,. <kbd>ctrl+j</kbd>)
- Better-maintains selection and cursor position when bulk commenting or uncommenting with <kbd>ctrl+/</kbd>
## [0.4.1] - 2023-08-03
### Features
- Adds a parameter to PathInput to allow <kbd>tab</kbd> to advance the focus.
## [0.4.0] - 2023-08-03
### Features
- Adds a suggester to autocomplete paths for the save and open file inputs.
- Adds a validator to validate paths for the save and open file inputs.
- `textual-textarea` now requires `textual` >=0.27.0
- Adds reactive properties to the textarea for `selection_anchor` position and
`selected_text`.
## [0.3.3] - 2023-07-28
### Features
- The open and save file inputs now expand the user home directory (`~`).
### Bug Fixes
- Selection should be better-maintained when pressing F-keys.
## [0.3.2] - 2023-07-14
### Bug Fixes
- Improves support for pasting text with `ctrl+v` on all platforms. ([#53](https://github.com/tconbeer/textual-textarea/issues/53)).
## [0.3.1] - 2023-06-26
### Bug Fixes
- Fixes issue where text area was aggressively capturing mouse events and not responding to mouse up events,
which would cause issues if your App had widgets other than the TextArea ([#42](https://github.com/tconbeer/textual-textarea/issues/42)).
- Fixes an issue where <kbd>PageUp</kbd> could cause a crash ([#46](https://github.com/tconbeer/textual-textarea/issues/46)).
## [0.3.0] - 2023-06-19
- Select text using click and drag ([#8](https://github.com/tconbeer/textual-textarea/issues/8)).
- Comment characters inserted with <kbd>ctrl+/</kbd> are now based on the language that the
TextArea is initialized with ([#24](https://github.com/tconbeer/textual-textarea/issues/24)).
- TextArea exposes a `language` property for the currently-configured language.
## [0.2.2] - 2023-06-15
### Features
- Adds a cursor attribute to TextArea to make it easier to get and set the TextInput's cursor position.
- Adds 3 attributes to TextArea to make it easier to access the child widgets: `text_input`, `text_container`, and `footer`.
### Bug Fixes
- Fixes a bug that was preventing the cursor from being scrolled into view.
## [0.2.1] - 2023-06-15
### Bug Fixes
- Fixes a bug where the TextArea did not update or have focus after opening a file ([#28](https://github.com/tconbeer/textual-textarea/issues/28))
- Fixes a bug where a missing space at the end of the buffer after opening a file could cause a crash
## [0.2.0] - 2023-06-14
### Features
- Uses the system clipboard (if it exists) for copy and paste operations, unless initialized
with `use_system_clipboard=False`.
- Adds a sample app that can be run with `python -m textual_textarea`.
## [0.1.2] - 2023-06-01
- Makes top-level TextArea widget focusable
- Loosens textual dependency to >=0.21.0
- Adds py.typed file
## [0.1.1] - 2023-06-01
- Exports TextArea class under the main textual_textarea module.
## [0.1.0] - 2023-06-01
- Initial release: TextArea is a feature-rich text area (multiline) input, with
support for syntax highlighting, themes, keyboard navigation, copy-paste, file
opening and saving, and more!
[unreleased]: https://github.com/tconbeer/textual-textarea/compare/0.15.0...HEAD
[0.15.0]: https://github.com/tconbeer/textual-textarea/compare/0.14.4...0.15.0
[0.14.4]: https://github.com/tconbeer/textual-textarea/compare/0.14.3...0.14.4
[0.14.3]: https://github.com/tconbeer/textual-textarea/compare/0.14.2...0.14.3
[0.14.2]: https://github.com/tconbeer/textual-textarea/compare/0.14.1...0.14.2
[0.14.1]: https://github.com/tconbeer/textual-textarea/compare/0.14.0...0.14.1
[0.14.0]: https://github.com/tconbeer/textual-textarea/compare/0.13.1...0.14.0
[0.13.1]: https://github.com/tconbeer/textual-textarea/compare/0.13.0...0.13.1
[0.13.0]: https://github.com/tconbeer/textual-textarea/compare/0.12.0...0.13.0
[0.12.0]: https://github.com/tconbeer/textual-textarea/compare/0.11.3...0.12.0
[0.11.3]: https://github.com/tconbeer/textual-textarea/compare/0.11.2...0.11.3
[0.11.2]: https://github.com/tconbeer/textual-textarea/compare/0.11.1...0.11.2
[0.11.1]: https://github.com/tconbeer/textual-textarea/compare/0.11.0...0.11.1
[0.11.0]: https://github.com/tconbeer/textual-textarea/compare/0.10.0...0.11.0
[0.10.0]: https://github.com/tconbeer/textual-textarea/compare/0.9.5...0.10.0
[0.9.5]: https://github.com/tconbeer/textual-textarea/compare/0.9.4...0.9.5
[0.9.4]: https://github.com/tconbeer/textual-textarea/compare/0.9.3...0.9.4
[0.9.3]: https://github.com/tconbeer/textual-textarea/compare/0.9.2...0.9.3
[0.9.2]: https://github.com/tconbeer/textual-textarea/compare/0.9.1...0.9.2
[0.9.1]: https://github.com/tconbeer/textual-textarea/compare/0.9.0...0.9.1
[0.9.0]: https://github.com/tconbeer/textual-textarea/compare/0.8.0...0.9.0
[0.8.0]: https://github.com/tconbeer/textual-textarea/compare/0.7.3...0.8.0
[0.7.3]: https://github.com/tconbeer/textual-textarea/compare/0.7.2...0.7.3
[0.7.2]: https://github.com/tconbeer/textual-textarea/compare/0.7.1...0.7.2
[0.7.1]: https://github.com/tconbeer/textual-textarea/compare/0.7.0...0.7.1
[0.7.0]: https://github.com/tconbeer/textual-textarea/compare/0.6.0...0.7.0
[0.6.0]: https://github.com/tconbeer/textual-textarea/compare/0.5.4...0.6.0
[0.5.4]: https://github.com/tconbeer/textual-textarea/compare/0.5.3...0.5.4
[0.5.3]: https://github.com/tconbeer/textual-textarea/compare/0.5.2...0.5.3
[0.5.2]: https://github.com/tconbeer/textual-textarea/compare/0.5.1...0.5.2
[0.5.1]: https://github.com/tconbeer/textual-textarea/compare/0.5.0...0.5.1
[0.5.0]: https://github.com/tconbeer/textual-textarea/compare/0.4.2...0.5.0
[0.4.2]: https://github.com/tconbeer/textual-textarea/compare/0.4.1...0.4.2
[0.4.1]: https://github.com/tconbeer/textual-textarea/compare/0.4.0...0.4.1
[0.4.0]: https://github.com/tconbeer/textual-textarea/compare/0.3.3...0.4.0
[0.3.3]: https://github.com/tconbeer/textual-textarea/compare/0.3.2...0.3.3
[0.3.2]: https://github.com/tconbeer/textual-textarea/compare/0.3.1...0.3.2
[0.3.1]: https://github.com/tconbeer/textual-textarea/compare/0.3.0...0.3.1
[0.3.0]: https://github.com/tconbeer/textual-textarea/compare/0.2.2...0.3.0
[0.2.2]: https://github.com/tconbeer/textual-textarea/compare/0.2.1...0.2.2
[0.2.1]: https://github.com/tconbeer/textual-textarea/compare/0.2.0...0.2.1
[0.2.0]: https://github.com/tconbeer/textual-textarea/compare/0.1.2...0.2.0
[0.1.2]: https://github.com/tconbeer/textual-textarea/compare/0.1.1...0.1.2
[0.1.1]: https://github.com/tconbeer/textual-textarea/compare/0.1.0...0.1.1
[0.1.0]: https://github.com/tconbeer/textual-textarea/compare/9832e9bbe1cd7a2ce9a4f09746eb1c2ddc8df842...0.1.0

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Ted Conbeer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

21
Makefile Normal file
View file

@ -0,0 +1,21 @@
.PHONY: check
check:
ruff format .
ruff check . --fix
mypy
pytest
.PHONY: lint
lint:
ruff format .
ruff check . --fix
mypy
.PHONY: serve
serve:
textual run --dev -c python -m textual_textarea
profiles: .profiles/startup.html
.profiles/startup.html: src/scripts/profile_startup.py pyproject.toml $(wildcard src/textual_textarea/**/*.py)
pyinstrument -r html -o .profiles/startup.html "src/scripts/profile_startup.py"

150
README.md Normal file
View file

@ -0,0 +1,150 @@
# Textual Textarea
![Textual Textarea Screenshot](textarea.svg)
## Note: This is **NOT** the official TextArea widget!
With v0.38.0, Textual added a built-in TextArea widget. You probably want to use
that widget instead of this one. This project predated the official widget; versions < v0.8.0
had a completely separate implmentation.
Since v0.8.0, this project uses the built-in TextArea widget, but adds the features outlined below.
## Installation
```
pip install textual-textarea
```
## Features
Full-featured text editor experience with VS-Code-like bindings, in your Textual App:
- Syntax highlighting and support for Pygments themes.
- Move cursor and scroll with mouse or keys (including <kbd>ctrl+arrow</kbd>, <kbd>PgUp/Dn</kbd>, <kbd>ctrl+Home/End</kbd>).
- Open (<kbd>ctrl+o</kbd>) and save (<kbd>ctrl+s</kbd>) files.
- Cut (<kbd>ctrl+x</kbd>), copy (<kbd>ctrl+c</kbd>), paste (<kbd>ctrl+u/v</kbd>), optionally using the system clipboard.
- Comment selections with <kbd>ctrl+/</kbd>.
- Indent and dedent (optionally for a multiline selection) to tab stops with <kbd>Tab</kbd> and <kbd>shift+Tab</kbd>.
- Automatic completions of quotes and brackets.
- Select text by double-, triple-, or quadruple-clicking.
- Quit with <kbd>ctrl+q</kbd>.
## Usage
### Initializing the Widget
The TextArea is a Textual Widget. You can add it to a Textual
app using `compose` or `mount`:
```python
from textual_textarea import TextEditor
from textual.app import App, ComposeResult
class TextApp(App, inherit_bindings=False):
def compose(self) -> ComposeResult:
yield TextEditor(text="hi", language="python", theme="nord-darker", id="ta")
def on_mount(self) -> None:
editor = self.query_one("#id", expect_type=TextEditor)
editor.focus()
app = TextApp()
app.run()
```
In addition to the standard Widget arguments, TextArea accepts three additional, optional arguments when initializing the widget:
- language (str): Must be `None` or the short name of a [Pygments lexer](https://pygments.org/docs/lexers/), e.g., `python`, `sql`, `as3`. Defaults to `None`.
- theme (str): Must be name of a [Pygments style](https://pygments.org/styles/), e.g., `bw`, `github-dark`, `solarized-light`. Defaults to `monokai`.
- use_system_clipboard (bool): Set to `False` to make the TextArea's copy and paste operations ignore the system clipboard. Defaults to `True`. Some Linux users may need to apt-install `xclip` or `xsel` to enable the system clipboard features.
The TextArea supports many actions and key bindings. **For proper binding of `ctrl+c` to the COPY action,
you must initialize your App with `inherit_bindings=False`** (as shown above), so that `ctrl+c` does not quit the app. The TextArea implements `ctrl+q` as quit; you way wish to mimic that in your app so that other in-focus widgets use the same behavior.
### Interacting with the Widget
#### Getting and Setting Text
The TextArea exposes a `text` property that contains the full text contained in the widget. You can retrieve or set the text by interacting with this property:
```python
editor = self.query_one(TextEditor)
old_text = editor.text
editor.text = "New Text!\n\nMany Lines!"
```
Similarly, the TextEditor exposes a `selected_text` property (read-only):
```python
editor = self.query_one(TextEditor)
selection = editor.selected_text
```
#### Inserting Text
You can insert text at the current selection:
```python
editor = self.query_one(TextEditor)
editor.text = "01234"
editor.selection = Selection((0, 2), (0, 2))
editor.insert_text_at_selection("\nabc\n")
assert editor.text == "01\nabc\n234"
assert editor.selection == Selection((2, 0), (2, 0))
```
#### Getting and Setting The Cursor Position
The TextEditor exposes a `selection` property that returns a textual.widgets.text_area.Selection:
```python
editor = self.query_one(TextEditor)
old_selection = editor.selection
editor.selection = Selection((999, 0),(999, 0)) # the cursor will move as close to line 999, pos 0 as possible
cursor_line_number = editor.selection.end[0]
cursor_x_position = editor.selection.end[1]
```
#### Getting and Setting The Language
Syntax highlighting and comment insertion depends on the configured language for the TextEditor.
The TextArea exposes a `language` property that returns `None` or a string that is equal to the short name of an installed tree-sitter language:
```python
editor = self.query_one(TextEditor)
old_language = editor.language
editor.language = "python"
```
#### Getting Theme Colors
If you would like the rest of your app to match the colors from the TextArea's theme, they are exposed via the `theme_colors` property.
```python
editor = self.query_one(TextEditor)
color = editor.theme_colors.contrast_text_color
bgcolor = editor.theme_colors.bgcolor
highlight = editor.theme_colors.selection_bgcolor
```
#### Adding Bindings and other Behavior
You can subclass TextEditor to add your own behavior. This snippet adds an action that posts a Submitted message containing the text of the TextEditor when the user presses <kbd>ctrl+j</kbd>:
```python
from textual.message import Message
from textual_textarea import TextEditor
class CodeEditor(TextEditor):
BINDINGS = [
("ctrl+j", "submit", "Run Query"),
]
class Submitted(Message, bubble=True):
def __init__(self, text: str) -> None:
super().__init__()
self.text = text
async def action_submit(self) -> None:
self.post_message(self.Submitted(self.text))
```

1816
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

67
pyproject.toml Normal file
View file

@ -0,0 +1,67 @@
[tool.poetry]
name = "textual-textarea"
version = "0.15.0"
description = "A text area (multi-line input) with syntax highlighting for Textual"
authors = ["Ted Conbeer <tconbeer@users.noreply.github.com>"]
license = "MIT"
homepage = "https://github.com/tconbeer/textual-textarea"
repository = "https://github.com/tconbeer/textual-textarea"
readme = "README.md"
packages = [{ include = "textual_textarea", from = "src" }]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.dependencies]
python = ">=3.9,<3.14"
textual = { version = ">=0.89.1,<2.0", extras = ["syntax"] }
pyperclip = "^1.9.0"
[tool.poetry.group.dev.dependencies]
pre-commit = "^3.3.1"
textual = "0.89.1"
textual-dev = "^1.2.1"
pyinstrument = "^5"
[tool.poetry.group.static.dependencies]
ruff = "^0.5"
mypy = "^1.10.0"
[tool.poetry.group.test.dependencies]
pytest = ">=7.3.1,<9.0.0"
pytest-asyncio = "^0.21"
[tool.ruff.lint]
select = ["A", "B", "E", "F", "I"]
[tool.mypy]
python_version = "3.9"
files = [
"src/textual_textarea/**/*.py",
"tests/**/*.py",
]
mypy_path = "src:stubs"
show_column_numbers = true
# show error messages from unrelated files
follow_imports = "normal"
# be strict
disallow_untyped_calls = true
disallow_untyped_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
strict_optional = true
warn_return_any = true
warn_no_return = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_unused_configs = true
no_implicit_reexport = true
strict_equality = true

0
src/scripts/__init__.py Normal file
View file

View file

@ -0,0 +1,23 @@
from textual.app import App, ComposeResult
from textual_textarea import TextEditor
class TextApp(App, inherit_bindings=False):
def compose(self) -> ComposeResult:
self.ta = TextEditor(
text="class TextApp(App):",
language="python",
theme="monokai",
use_system_clipboard=True,
id="ta",
)
yield self.ta
def on_mount(self) -> None:
self.ta.focus()
self.exit()
if __name__ == "__main__":
app = TextApp()
app.run()

View file

@ -0,0 +1,20 @@
from textual.app import App, ComposeResult
from textual_textarea import TextEditor
class TextApp(App, inherit_bindings=False):
def compose(self) -> ComposeResult:
self.editor = TextEditor(
language="python",
theme="monokai",
use_system_clipboard=True,
)
yield self.editor
def on_mount(self) -> None:
self.editor.focus()
if __name__ == "__main__":
app = TextApp()
app.run()

32
src/scripts/screenshot.py Normal file
View file

@ -0,0 +1,32 @@
import asyncio
from pathlib import Path
from textual.app import App, ComposeResult
from textual.widgets.text_area import Selection
from textual_textarea import TextEditor
contents = (Path(__file__).parent / "sample_code.py").open("r").read()
class TextApp(App, inherit_bindings=False):
def compose(self) -> ComposeResult:
self.editor = TextEditor(
language="python",
theme="monokai",
use_system_clipboard=True,
)
yield self.editor
def on_mount(self) -> None:
self.editor.focus()
async def take_screenshot() -> None:
app = TextApp()
async with app.run_test(size=(80, 24)):
app.editor.text = contents
app.editor.selection = Selection((7, 12), (8, 12))
app.save_screenshot("textarea.svg")
asyncio.run(take_screenshot())

View file

@ -0,0 +1,15 @@
from textual_textarea.messages import (
TextAreaClipboardError,
TextAreaSaved,
TextAreaThemeError,
)
from textual_textarea.path_input import PathInput
from textual_textarea.text_editor import TextEditor
__all__ = [
"TextEditor",
"PathInput",
"TextAreaClipboardError",
"TextAreaThemeError",
"TextAreaSaved",
]

View file

@ -0,0 +1,67 @@
from __future__ import annotations
import sys
from textual.app import App, ComposeResult
from textual.widgets import Footer, Placeholder
from textual_textarea import TextEditor
class FocusablePlaceholder(Placeholder, can_focus=True):
pass
class TextApp(App, inherit_bindings=False):
BINDINGS = [("ctrl+q", "quit")]
CSS = """
TextEditor {
height: 1fr;
}
Placeholder {
height: 0fr;
}
"""
def compose(self) -> ComposeResult:
try:
language = sys.argv[1]
except IndexError:
language = "sql"
yield FocusablePlaceholder()
self.editor = TextEditor(
language=language,
use_system_clipboard=True,
id="ta",
)
yield self.editor
yield Footer()
def watch_theme(self, theme: str) -> None:
self.editor.theme = theme
def on_mount(self) -> None:
self.theme = "gruvbox"
self.editor.focus()
def _completer(prefix: str) -> list[tuple[tuple[str, str], str]]:
words = [
"satisfy",
"season",
"second",
"seldom",
"select",
"self",
"separate",
"set",
"space",
"super",
"supercalifragilisticexpialadocioussupercalifragilisticexpialadocious",
]
return [((w, "word"), w) for w in words if w.startswith(prefix)]
self.editor.word_completer = _completer
app = TextApp()
app.run()

View file

@ -0,0 +1,289 @@
from __future__ import annotations
from typing import Callable
from rich.console import RenderableType
from rich.style import Style
from rich.text import Text
from textual import on, work
from textual.css.scalar import Scalar, ScalarOffset, Unit
from textual.events import Key, Resize
from textual.geometry import Size
from textual.message import Message
from textual.reactive import Reactive, reactive
from textual.widget import Widget
from textual.widgets import OptionList
from textual.widgets._option_list import NewOptionListContent
from textual.widgets.option_list import Option
from textual_textarea.messages import TextAreaHideCompletionList
class Completion(Option):
def __init__(
self,
prompt: RenderableType,
id: str | None = None, # noqa: A002
disabled: bool = False,
value: str | None = None,
) -> None:
super().__init__(prompt, id, disabled)
self.value = value
class CompletionList(OptionList, can_focus=False, inherit_bindings=False):
COMPONENT_CLASSES = {
"completion-list--type-label",
"completion-list--type-label-highlighted",
}
DEFAULT_CSS = """
CompletionList {
layer: overlay;
padding: 0;
border: none;
width: 40;
max-height: 8;
display: none;
}
CompletionList.open {
display: block;
}
CompletionList .completion-list--type-label {
color: $foreground-muted;
background: transparent;
}
"""
class CompletionsReady(Message, bubble=False):
def __init__(
self,
prefix: str,
items: list[tuple[str, str]] | list[tuple[tuple[str, str], str]],
) -> None:
super().__init__()
self.items = items
self.prefix = prefix
INNER_CONTENT_WIDTH = 37 # should be 3 less than width for scroll bar.
is_open: Reactive[bool] = reactive(False)
cursor_offset: tuple[int, int] = (0, 0)
additional_x_offset: int = 0
def __init__(
self,
*content: NewOptionListContent,
name: str | None = None,
id: str | None = None, # noqa: A002
classes: str | None = None,
disabled: bool = False,
):
super().__init__(
*content, name=name, id=id, classes=classes, disabled=disabled, wrap=False
)
def set_offset(self, x_offset: int, y_offset: int) -> None:
"""The CSS Offset of this widget from its parent."""
self.styles.offset = ScalarOffset.from_offset(
(
x_offset,
y_offset,
)
)
@property
def x_offset(self) -> int:
"""The x-coord of the CSS Offset of this widget from its parent."""
return int(self.styles.offset.x.value)
@property
def y_offset(self) -> int:
"""The y-coord of the CSS Offset of this widget from its parent."""
return int(self.styles.offset.y.value)
@property
def parent_height(self) -> int:
"""
The content size height of the parent widget
"""
return self.parent_size.height
@property
def parent_width(self) -> int:
"""
The content size height of the parent widget
"""
return self.parent_size.width
@property
def parent_size(self) -> Size:
"""
The content size of the parent widget
"""
parent = self.parent
if isinstance(parent, Widget):
return parent.content_size
else:
return self.screen.content_size
@on(CompletionsReady)
def populate_and_position_list(self, event: CompletionsReady) -> None:
event.stop()
self.clear_options()
type_label_style_full = self.get_component_rich_style(
"completion-list--type-label"
)
type_label_fg_style = Style(color=type_label_style_full.color)
prompts = [
Text.assemble(item[0][0], " ", (item[0][1], type_label_fg_style))
if isinstance(item[0], tuple)
else Text.from_markup(item[0])
for item in event.items
]
# if the completions' prompts are wider than the widget,
# we have to trunctate them
max_length = max(map(lambda x: x.cell_len, prompts))
truncate_amount = max(
0,
min(
max_length - self.INNER_CONTENT_WIDTH,
len(event.prefix) - 2,
),
)
if truncate_amount > 0:
additional_x_offset = truncate_amount - 1
items = [
Completion(prompt=f"{prompt[truncate_amount:]}", value=item[1])
for prompt, item in zip(prompts, event.items)
]
else:
additional_x_offset = 0
items = [
Completion(prompt=prompt, value=item[1])
for prompt, item in zip(prompts, event.items)
]
# set x offset if not already open.
if not self.is_open:
try:
x_offset = self._get_x_offset(
prefix_length=len(event.prefix),
additional_x_offset=additional_x_offset,
cursor_x=self.cursor_offset[0],
container_width=self.parent_width,
width=self._width,
)
except ValueError:
x_offset = 0
self.styles.width = self._parent_container_size.width
self.set_offset(x_offset, self.y_offset)
# adjust x offset if we have to due to truncation
elif additional_x_offset != self.additional_x_offset:
self.set_offset(
min(
self.x_offset + (additional_x_offset - self.additional_x_offset),
self.parent_width - self._width,
),
self.y_offset,
)
self.add_options(items=items)
self.action_first()
self.additional_x_offset = additional_x_offset
self.is_open = True
def watch_is_open(self, is_open: bool) -> None:
if not is_open:
self.remove_class("open")
self.additional_x_offset = 0
return
self.add_class("open")
self.styles.max_height = Scalar(
value=8.0, unit=Unit.CELLS, percent_unit=Unit.PERCENT
)
def on_resize(self, event: Resize) -> None:
try:
y_offset = self._get_y_offset(
cursor_y=self.cursor_offset[1],
height=event.size.height,
container_height=self.parent_height,
)
except ValueError:
if self.styles.max_height is not None and self.styles.max_height.value > 1:
self.styles.max_height = Scalar(
value=self.styles.max_height.value - 1,
unit=self.styles.max_height.unit,
percent_unit=self.styles.max_height.percent_unit,
)
else:
self.post_message(TextAreaHideCompletionList())
else:
self.set_offset(self.x_offset, y_offset)
@work(thread=True, exclusive=True, group="completers")
def show_completions(
self,
prefix: str,
completer: Callable[
[str], list[tuple[str, str]] | list[tuple[tuple[str, str], str]]
]
| None,
) -> None:
matches = completer(prefix) if completer is not None else []
if matches:
self.post_message(self.CompletionsReady(prefix=prefix, items=matches))
else:
self.post_message(TextAreaHideCompletionList())
def process_keypress(self, event: Key) -> None:
if event.key in ("tab", "enter", "shift+tab"):
self.action_select()
elif event.key == "up":
self.action_cursor_up()
elif event.key == "down":
self.action_cursor_down()
elif event.key == "pageup":
self.action_page_up()
elif event.key == "pagedown":
self.action_page_down()
@property
def _parent_container_size(self) -> Size:
return getattr(self.parent, "container_size", self.screen.container_size)
@property
def _width(self) -> int:
if self.styles.width and self.styles.width.unit == Unit.CELLS:
return int(self.styles.width.value)
else:
return self.outer_size.width
@staticmethod
def _get_x_offset(
prefix_length: int,
additional_x_offset: int,
cursor_x: int,
container_width: int,
width: int,
) -> int:
x = cursor_x - prefix_length + additional_x_offset
max_x = container_width - width
if max_x < 0:
raise ValueError("doesn't fit")
return min(x, max_x)
@staticmethod
def _get_y_offset(cursor_y: int, height: int, container_height: int) -> int:
fits_above = height < cursor_y + 1
fits_below = height < container_height - cursor_y
if fits_below:
y = cursor_y + 1
elif fits_above:
y = cursor_y - height
else:
raise ValueError("Doesn't fit.")
return y

View file

@ -0,0 +1,19 @@
from textual.binding import Binding
from textual.message import Message
from textual.widgets import Input
class CancellableInput(Input):
BINDINGS = [
Binding("escape", "cancel", "Cancel", show=False),
]
class Cancelled(Message):
"""
Posted when the user presses Esc to cancel the input.
"""
pass
def action_cancel(self) -> None:
self.post_message(self.Cancelled())

View file

@ -0,0 +1,67 @@
from __future__ import annotations
from rich.style import Style
from textual.color import Color
from textual.theme import Theme
from textual.widgets.text_area import TextAreaTheme
def text_area_theme_from_app_theme(
theme_name: str, theme: Theme, css_vars: dict[str, str]
) -> TextAreaTheme:
builtin = TextAreaTheme.get_builtin_theme(theme_name)
if builtin is not None:
return builtin
if "background" in css_vars:
background_color = Color.parse(
css_vars.get("background", "#000000" if theme.dark else "#FFFFFF")
)
foreground_color = Color.parse(
css_vars.get("foreground", background_color.inverse)
)
else:
foreground_color = Color.parse(
css_vars.get("foreground", "#FFFFFF" if theme.dark else "#000000")
)
background_color = foreground_color.inverse
muted = background_color.blend(foreground_color, factor=0.5)
computed_theme = TextAreaTheme(
name=theme_name,
base_style=Style(
color=foreground_color.rich_color, bgcolor=background_color.rich_color
),
syntax_styles={
"comment": muted.hex, # type: ignore
"string": theme.accent, # type: ignore
"string.documentation": muted.hex, # type: ignore
"string.special": theme.accent, # type: ignore
"number": theme.accent, # type: ignore
"float": theme.accent, # type: ignore
"function": theme.secondary, # type: ignore
"function.call": theme.secondary, # type: ignore
"method": theme.secondary, # type: ignore
"method.call": theme.secondary, # type: ignore
"constant": foreground_color.hex, # type: ignore
"constant.builtin": foreground_color.hex, # type: ignore
"boolean": theme.accent, # type: ignore
"class": f"{foreground_color.hex} bold", # type: ignore
"type": f"{foreground_color.hex} bold", # type: ignore
"variable": foreground_color.hex, # type: ignore
"parameter": f"{theme.accent} bold", # type: ignore
"operator": theme.secondary, # type: ignore
"punctuation.bracket": foreground_color.hex, # type: ignore
"punctuation.delimeter": foreground_color.hex, # type: ignore
"keyword": f"{theme.primary} bold", # type: ignore
"keyword.function": theme.secondary, # type: ignore
"keyword.return": theme.primary, # type: ignore
"keyword.operator": f"{theme.primary} bold", # type: ignore
"exception": theme.error, # type: ignore
"heading": theme.primary, # type: ignore
"bold": "bold", # type: ignore
"italic": "italic", # type: ignore
},
)
return computed_theme

View file

@ -0,0 +1,185 @@
INLINE_MARKERS = {
"abap": '"',
"actionscript": "//",
"as": "//",
"actionscript3": "//",
"as3": "//",
"ada": "--",
"ada95": "--",
"ada2005": "--",
"antlr-objc": "//",
"apl": "",
"applescript": "--",
"autohotkey": ";",
"ahk": ";",
"autoit": ";",
"basemake": "#",
"bash": "#",
"sh": "#",
"ksh": "#",
"zsh": "#",
"shell": "#",
"batch": "::",
"bat": "::",
"dosbatch": "::",
"winbatch": "::",
"bbcbasic": "REM",
"blitzbasic": "REM",
"b3d": "REM",
"bplus": "REM",
"boo": "#",
"c": "//",
"csharp": "//",
"c#": "//",
"cs": "//",
"cpp": "//",
"c++": "//",
"cbmbas": "REM",
"clojure": ";",
"clj": ";",
"clojurescript": ";",
"cljs": ";",
"cmake": "#",
"cobol": "*>",
"cobolfree": "*>",
"common-lisp": ";",
"cl": ";",
"lisp": ";",
"d": "//",
"delphi": "//",
"pas": "//",
"pascal": "//",
"objectpascal": "//",
"eiffel": "--",
"elixir": "#",
"ex": "#",
"exs": "#",
"iex": "#",
"elm": "--",
"emacs-lisp": ";",
"elisp": ";",
"emacs": ";",
"erlang": "%",
"erl": "%",
"fsharp": "//",
"f#": "//",
"factor": "!",
"fish": "#",
"fishshell": "#",
"forth": "\\",
"fortran": "!",
"f90": "!",
"fortranfixed": "!",
"go": "//",
"golang": "//",
"haskell": "--",
"hs": "--",
"inform6": "!",
"i6": "!",
"i6t": "!",
"inform7": "!",
"i7": "!",
"j": "NB.",
"java": "//",
"jsp": "//",
"javascript": "//",
"js": "//",
"julia": "#",
"jl": "#",
"jlcon": "#",
"julia-repl": "#",
"kotlin": "//",
"lua": "--",
"make": "#",
"makefile": "#",
"mf": "#",
"bsdmake": "#",
"matlab": "%",
"matlabsession": "%",
"monkey": "'",
"mysql": "#",
"newlisp": ";",
"nimrod": "#",
"nim": "#",
"objective-c": "//",
"objectivec": "//",
"obj-c": "//",
"objc": "//",
"objective-c++": "//",
"objectivec++": "//",
"obj-c++": "//",
"objc++": "//",
"perl": "#",
"pl": "#",
"perl6": "#",
"pl6": "#",
"raku": "#",
"php": "#",
"php3": "#",
"php4": "#",
"php5": "#",
"plpgsql": "--",
"psql": "--",
"postgresql-console": "--",
"postgres-console": "--",
"postgres-explain": "--",
"postgresql": "--",
"postgres": "--",
"postscript": "%",
"postscr": "%",
"powershell": "#",
"pwsh": "#",
"posh": "#",
"ps1": "#",
"psm1": "#",
"pwsh-session": "#",
"ps1con": "#",
"prolog": "%",
"python": "#",
"py": "#",
"sage": "#",
"python3": "#",
"py3": "#",
"python2": "#",
"py2": "#",
"py2tb": "#",
"pycon": "#",
"pytb": "#",
"py3tb": "#",
"py+ul4": "#",
"qbasic": "REM",
"basic": "REM",
"ragel-ruby": "#",
"ragel-rb": "#",
"rebol": ";",
"red": ";",
"red/system": ";",
"ruby": "#",
"rb": "#",
"duby": "#",
"rbcon": "#",
"irb": "#",
"rust": "//",
"rs": "//",
"sass": "//",
"scala": "//",
"scheme": ";",
"scm": ";",
"sql": "--",
"sql+jinja": "--",
"sqlite3": "--",
"swift": "//",
"tex": "%",
"latex": "%",
"tsql": "--",
"t-sql": "--",
"vbscript": "'",
"vhdl": "--",
"wast": ";;",
"wat": ";;",
"yaml": "#",
"yaml+jinja": "#",
"salt": "#",
"sls": "#",
"zig": "//",
}

View file

@ -0,0 +1,54 @@
from typing import Any, Union
from textual.containers import Container, ScrollableContainer
from textual.widget import Widget
class TextContainer(
ScrollableContainer,
inherit_bindings=False,
can_focus=False,
can_focus_children=True,
):
DEFAULT_CSS = """
TextContainer {
height: 1fr;
width: 100%;
layers: main overlay;
}
"""
def scroll_to(
self, x: Union[float, None] = None, y: Union[float, None] = None, **_: Any
) -> None:
return super().scroll_to(x, y, animate=True, duration=0.01)
class FooterContainer(
Container,
inherit_bindings=False,
can_focus=False,
can_focus_children=True,
):
DEFAULT_CSS = """
FooterContainer {
dock: bottom;
height: auto;
width: 100%
}
FooterContainer.hide {
height: 0;
}
"""
def __init__(
self,
*children: Widget,
name: Union[str, None] = None,
id: Union[str, None] = None, # noqa: A002
classes: Union[str, None] = None,
disabled: bool = False,
) -> None:
super().__init__(
*children, name=name, id=id, classes=classes, disabled=disabled
)

View file

@ -0,0 +1,75 @@
from typing import Union
from textual.app import ComposeResult
from textual.containers import Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static
class ErrorModal(ModalScreen):
DEFAULT_CSS = """
ErrorModal {
align: center middle;
padding: 0;
}
#error_modal__outer {
border: round $error;
background: $background;
margin: 5 10;
padding: 1 2;
max-width: 88;
}
#error_modal__header {
dock: top;
color: $text-muted;
margin: 0 0 1 0;
padding: 0 1;
}
#error_modal__inner {
border: round $background;
padding: 1 1 1 2;
}
#error_modal__info {
padding: 0 3 0 0;
}
#error_modal__footer {
dock: bottom;
color: $text-muted;
margin: 1 0 0 0;
padding: 0 1;
}
"""
def __init__(
self,
title: str,
header: str,
error: BaseException,
name: Union[str, None] = None,
id: Union[str, None] = None, # noqa: A002
classes: Union[str, None] = None,
) -> None:
super().__init__(name, id, classes)
self.title = title
self.header = header
self.error = error
def compose(self) -> ComposeResult:
with Vertical(id="error_modal__outer"):
yield Static(self.header, id="error_modal__header")
with Vertical(id="error_modal__inner"):
with VerticalScroll():
yield Static(str(self.error), id="error_modal__info")
yield Static("Press any key to continue.", id="error_modal__footer")
def on_mount(self) -> None:
container = self.query_one("#error_modal__outer")
container.border_title = self.title
def on_key(self) -> None:
self.app.pop_screen()
self.app.action_focus_next()

View file

@ -0,0 +1,77 @@
from __future__ import annotations
from textual import on
from textual.events import Blur, Key
from textual.widgets import Input
from textual_textarea.cancellable_input import CancellableInput
class FindInput(CancellableInput):
def __init__(
self,
value: str = "",
history: list[str] | None = None,
classes: str | None = None,
) -> None:
super().__init__(
value=value,
placeholder="Find; enter for next; ESC to close; ↑↓ for history",
password=False,
type="text",
id="textarea__find_input",
classes=classes,
)
self.history: list[str] = [] if history is None else history
self.history_index: int | None = None
@on(Key)
def handle_special_keys(self, event: Key) -> None:
if event.key not in ("up", "down", "f3"):
self.history_index = None
return
event.stop()
event.prevent_default()
if event.key == "down":
self._handle_down()
elif event.key == "up":
self._handle_up()
elif event.key == "f3":
self.post_message(Input.Submitted(self, self.value))
@on(Blur)
def handle_blur(self) -> None:
if self.value and (not self.history or self.value != self.history[-1]):
self.history.append(self.value)
def _handle_down(self) -> None:
if self.history_index is None:
self.checkpoint()
self.value = ""
elif self.history_index == -1:
self.history_index = None
self.value = ""
else:
self.history_index += 1
self.value = self.history[self.history_index]
self.action_end()
def checkpoint(self) -> bool:
if self.value and (not self.history or self.value != self.history[-1]):
self.history.append(self.value)
return True
return False
def _handle_up(self) -> None:
if not self.history:
if self.value:
self.history.append(self.value)
self.value = ""
return
if self.history_index is None:
self.history_index = -1 if self.checkpoint() else 0
self.history_index = max(-1 * len(self.history), self.history_index - 1)
self.value = self.history[self.history_index]
self.action_end()

View file

@ -0,0 +1,60 @@
from __future__ import annotations
from textual.validation import ValidationResult, Validator
from textual_textarea.cancellable_input import CancellableInput
class GotoLineValidator(Validator):
def __init__(
self,
max_line_number: int,
min_line_number: int = 1,
failure_description: str = "Not a valid line number.",
) -> None:
super().__init__(failure_description)
self.max_line_number = max_line_number
self.min_line_number = min_line_number
def validate(self, value: str) -> ValidationResult:
try:
lno = int(value)
except (ValueError, TypeError):
return self.failure("Not a valid line number.")
if lno < self.min_line_number:
return self.failure(f"Line number must be >= {self.min_line_number}")
elif lno > self.max_line_number:
return self.failure(f"Line number must be <= {self.max_line_number}")
return self.success()
class GotoLineInput(CancellableInput):
def __init__(
self,
*,
max_line_number: int,
id: str | None = None, # noqa: A002
classes: str | None = None,
current_line: int | None = None,
min_line_number: int = 1,
) -> None:
current_line_text = (
f"Current line: {current_line}. " if current_line is not None else ""
)
range_text = (
f"Enter a line number between {min_line_number} and " f"{max_line_number}."
)
placeholder = f"{current_line_text}{range_text} ESC to cancel."
super().__init__(
"",
placeholder=placeholder,
type="integer",
validators=GotoLineValidator(
max_line_number=max_line_number, min_line_number=min_line_number
),
validate_on={"changed"},
id=id,
classes=classes,
)

View file

@ -0,0 +1,38 @@
from pathlib import Path
from typing import Union
from textual.message import Message
class TextAreaClipboardError(Message, bubble=True):
"""
Posted when textarea cannot access the system clipboard
"""
def __init__(self, action: str) -> None:
super().__init__()
self.action = action
class TextAreaThemeError(Message, bubble=True):
"""
Posted when textarea cannot instantiate a theme
"""
def __init__(self, theme: str) -> None:
super().__init__()
self.theme = theme
class TextAreaSaved(Message, bubble=True):
"""
Posted when the textarea saved a file successfully.
"""
def __init__(self, path: Union[Path, str]) -> None:
self.path = str(path)
super().__init__()
class TextAreaHideCompletionList(Message):
pass

View file

@ -0,0 +1,127 @@
from __future__ import annotations
import stat
from pathlib import Path
from rich.highlighter import Highlighter
from textual.binding import Binding
from textual.suggester import Suggester
from textual.validation import ValidationResult, Validator
from textual_textarea.cancellable_input import CancellableInput
def path_completer(prefix: str) -> list[tuple[str, str]]:
try:
original = Path(prefix)
p = original.expanduser()
if p.is_dir():
matches = list(p.iterdir())
else:
matches = list(p.parent.glob(f"{p.name}*"))
if original != p and original.parts and original.parts[0] == "~":
prompts = [str(Path("~") / m.relative_to(Path.home())) for m in matches]
elif not original.is_absolute() and prefix.startswith("./"):
prompts = [f"./{m}" for m in matches]
else:
prompts = [str(m) for m in matches]
return [(p, p) for p in prompts]
except Exception:
return []
class PathSuggester(Suggester):
def __init__(self) -> None:
super().__init__(use_cache=True, case_sensitive=True)
async def get_suggestion(self, value: str) -> str | None:
matches = path_completer(value)
if len(matches) == 1:
return str(matches[0][0])
else:
return None
class PathValidator(Validator):
def __init__(
self,
dir_okay: bool,
file_okay: bool,
must_exist: bool,
failure_description: str = "Not a valid path.",
) -> None:
self.dir_okay = dir_okay
self.file_okay = file_okay
self.must_exist = must_exist
super().__init__(failure_description)
def validate(self, value: str) -> ValidationResult:
if self.dir_okay and self.file_okay and not self.must_exist:
return self.success()
try:
p = Path(value).expanduser().resolve()
except Exception:
return self.failure("Not a valid path.")
try:
st = p.stat()
except FileNotFoundError:
if self.must_exist:
return self.failure("File or directory does not exist.")
return self.success()
if not self.dir_okay and stat.S_ISDIR(st.st_mode):
return self.failure("Path cannot be a directory.")
elif not self.file_okay and stat.S_ISREG(st.st_mode):
return self.failure("Path cannot be a regular file.")
return self.success()
class PathInput(CancellableInput):
BINDINGS = [
Binding("tab", "complete", "Accept Completion", show=False),
]
def __init__(
self,
value: str | None = None,
placeholder: str = "",
highlighter: Highlighter | None = None,
password: bool = False,
*,
name: str | None = None,
id: str | None = None, # noqa: A002
classes: str | None = None,
disabled: bool = False,
dir_okay: bool = True,
file_okay: bool = True,
must_exist: bool = False,
tab_advances_focus: bool = False,
) -> None:
self.tab_advances_focus = tab_advances_focus
super().__init__(
value,
placeholder,
highlighter,
password,
suggester=PathSuggester(),
validators=PathValidator(dir_okay, file_okay, must_exist),
name=name,
id=id,
classes=classes,
disabled=disabled,
)
def action_complete(self) -> None:
if self._suggestion and self._suggestion != self.value:
self.action_cursor_right()
elif self.tab_advances_focus:
self.app.action_focus_next()
def _toggle_cursor(self) -> None:
"""Toggle visibility of cursor."""
if self.app.is_headless:
self._cursor_visible = True
else:
self._cursor_visible = not self._cursor_visible

View file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
from typing import Callable, Tuple
def copy(s: str) -> None: ...
def paste() -> str: ...
def determine_clipboard() -> Tuple[Callable[[str], None], Callable[[], str]]: ...
class PyperclipException(Exception): ...

9
tests/conftest.py Normal file
View file

@ -0,0 +1,9 @@
from pathlib import Path
import pytest
@pytest.fixture
def data_dir() -> Path:
here = Path(__file__)
return here.parent / "data"

View file

View file

@ -0,0 +1,2 @@
def foo(bar: str, baz: int) -> None:
return

View file

View file

View file

View file

@ -0,0 +1,62 @@
from typing import Type, Union
from unittest.mock import MagicMock
import pytest
from textual.app import App, ComposeResult
from textual.driver import Driver
from textual.types import CSSPathType
from textual_textarea.text_editor import TextEditor
class TextEditorApp(App, inherit_bindings=False):
def __init__(
self,
driver_class: Union[Type[Driver], None] = None,
css_path: Union[CSSPathType, None] = None,
watch_css: bool = False,
language: Union[str, None] = None,
use_system_clipboard: bool = True,
):
self.language = language
self.use_system_clipboard = use_system_clipboard
super().__init__(driver_class, css_path, watch_css)
def compose(self) -> ComposeResult:
self.editor = TextEditor(
language=self.language,
use_system_clipboard=self.use_system_clipboard,
id="ta",
)
yield self.editor
def on_mount(self) -> None:
self.editor.focus()
@pytest.fixture
def app() -> App:
app = TextEditorApp(language="python")
return app
@pytest.fixture(
params=[False, True],
ids=["no_sys_clipboard", "default"],
)
def app_all_clipboards(request: pytest.FixtureRequest) -> App:
app = TextEditorApp(use_system_clipboard=request.param)
return app
@pytest.fixture(autouse=True)
def mock_pyperclip(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
mock = MagicMock()
mock.determine_clipboard.return_value = mock.copy, mock.paste
def set_paste(x: str) -> None:
mock.paste.return_value = x
mock.copy.side_effect = set_paste
monkeypatch.setattr("textual_textarea.text_editor.pyperclip", mock)
return mock

View file

@ -0,0 +1,279 @@
from __future__ import annotations
from pathlib import Path
from time import monotonic
from typing import Callable
from unittest.mock import MagicMock
import pytest
from textual.app import App
from textual.message import Message
from textual.widgets.text_area import Selection
from textual_textarea import TextEditor
@pytest.fixture
def word_completer() -> Callable[[str], list[tuple[str, str]]]:
def _completer(prefix: str) -> list[tuple[str, str]]:
words = [
"satisfy",
"season",
"second",
"seldom",
"select",
"self",
"separate",
"set",
"space",
"super",
]
return [(w, w) for w in words if w.startswith(prefix)]
return _completer
@pytest.fixture
def word_completer_with_types() -> Callable[[str], list[tuple[tuple[str, str], str]]]:
def _completer(prefix: str) -> list[tuple[tuple[str, str], str]]:
words = [
"satisfy",
"season",
"second",
"seldom",
"select",
"self",
"separate",
"set",
"space",
"super",
]
return [((w, "word"), w) for w in words if w.startswith(prefix)]
return _completer
@pytest.fixture
def member_completer() -> Callable[[str], list[tuple[str, str]]]:
mock = MagicMock()
mock.return_value = [("completion", "completion")]
return mock
@pytest.mark.asyncio
async def test_autocomplete(
app: App, word_completer: Callable[[str], list[tuple[str, str]]]
) -> None:
messages: list[Message] = []
async with app.run_test(message_hook=messages.append) as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.word_completer = word_completer
ta.focus()
while ta.word_completer is None:
await pilot.pause()
start_time = monotonic()
await pilot.press("s")
while ta.completion_list.is_open is False:
if monotonic() - start_time > 10:
print("MESSAGES:")
print("\n".join([str(m) for m in messages]))
break
await pilot.pause()
assert ta.text_input
assert ta.text_input.completer_active == "word"
assert ta.completion_list.is_open is True
assert ta.completion_list.option_count == 10
first_offset = ta.completion_list.styles.offset
await pilot.press("e")
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active == "word"
assert ta.completion_list.is_open is True
assert ta.completion_list.option_count == 7
assert ta.completion_list.styles.offset == first_offset
await pilot.press("z") # sez, no matches
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active is None
assert ta.completion_list.is_open is False
# backspace when the list is not open doesn't re-open it
await pilot.press("backspace")
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active is None
assert ta.completion_list.is_open is False
await pilot.press("l") # sel
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active == "word"
assert ta.completion_list.is_open is True
assert ta.completion_list.option_count == 3
assert ta.completion_list.styles.offset == first_offset
await pilot.press("backspace") # se
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active == "word"
assert ta.completion_list.is_open is True
assert ta.completion_list.option_count == 7
assert ta.completion_list.styles.offset == first_offset
await pilot.press("enter")
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active is None
assert ta.completion_list.is_open is False
assert ta.text == "season"
assert ta.selection.end[1] == 6
@pytest.mark.asyncio
async def test_autocomplete_with_types(
app: App,
word_completer_with_types: Callable[[str], list[tuple[tuple[str, str], str]]],
) -> None:
messages: list[Message] = []
word_completer = word_completer_with_types
async with app.run_test(message_hook=messages.append) as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.word_completer = word_completer
ta.focus()
while ta.word_completer is None:
await pilot.pause()
start_time = monotonic()
await pilot.press("s")
while ta.completion_list.is_open is False:
if monotonic() - start_time > 10:
print("MESSAGES:")
print("\n".join([str(m) for m in messages]))
break
await pilot.pause()
assert ta.text_input
assert ta.text_input.completer_active == "word"
assert ta.completion_list.is_open is True
assert ta.completion_list.option_count == 10
first_offset = ta.completion_list.styles.offset
await pilot.press("e")
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active == "word"
assert ta.completion_list.is_open is True
assert ta.completion_list.option_count == 7
assert ta.completion_list.styles.offset == first_offset
await pilot.press("z") # sez, no matches
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active is None
assert ta.completion_list.is_open is False
# backspace when the list is not open doesn't re-open it
await pilot.press("backspace")
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active is None
assert ta.completion_list.is_open is False
await pilot.press("l") # sel
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active == "word"
assert ta.completion_list.is_open is True
assert ta.completion_list.option_count == 3
assert ta.completion_list.styles.offset == first_offset
await pilot.press("backspace") # se
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active == "word"
assert ta.completion_list.is_open is True
assert ta.completion_list.option_count == 7
assert ta.completion_list.styles.offset == first_offset
await pilot.press("enter")
await app.workers.wait_for_complete()
await pilot.pause()
assert ta.text_input.completer_active is None
assert ta.completion_list.is_open is False
assert ta.text == "season"
assert ta.selection.end[1] == 6
@pytest.mark.asyncio
async def test_autocomplete_paths(app: App, data_dir: Path) -> None:
messages: list[Message] = []
async with app.run_test(message_hook=messages.append) as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.focus()
test_path = str(data_dir / "test_validator")
ta.text = test_path
await pilot.pause()
ta.selection = Selection((0, len(test_path)), (0, len(test_path)))
start_time = monotonic()
await pilot.press("slash")
while ta.completion_list.is_open is False:
if monotonic() - start_time > 10:
print("MESSAGES:")
print("\n".join([str(m) for m in messages]))
break
await pilot.pause()
assert ta.text_input
assert ta.text_input.completer_active == "path"
assert ta.completion_list.is_open is True
assert ta.completion_list.option_count == 2
@pytest.mark.parametrize(
"text,keys,expected_prefix",
[
("foo bar", ["full_stop"], "bar."),
("foo 'bar'", ["full_stop"], "'bar'."),
("foo `bar`", ["full_stop"], "`bar`."),
('foo "bar"', ["full_stop"], '"bar".'),
("foo bar", ["colon"], "bar:"),
("foo bar", ["colon", "colon"], "bar::"),
('foo "bar"', ["colon", "colon"], '"bar"::'),
("foo bar", ["full_stop", "quotation_mark"], 'bar."'),
('foo "bar"', ["full_stop", "quotation_mark"], '"bar"."'),
],
)
@pytest.mark.asyncio
async def test_autocomplete_members(
app: App,
member_completer: MagicMock,
text: str,
keys: list[str],
expected_prefix: str,
) -> None:
messages: list[Message] = []
async with app.run_test(message_hook=messages.append) as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.member_completer = member_completer
ta.focus()
while ta.member_completer is None:
await pilot.pause()
ta.text = text
ta.selection = Selection((0, len(text)), (0, len(text)))
await pilot.pause()
for key in keys:
await pilot.press(key)
start_time = monotonic()
while ta.completion_list.is_open is False:
if monotonic() - start_time > 10:
print("MESSAGES:")
print("\n".join([str(m) for m in messages]))
break
await pilot.pause()
member_completer.assert_called_with(expected_prefix)
assert ta.text_input is not None
assert ta.text_input.completer_active == "member"
assert ta.completion_list.is_open is True

View file

@ -0,0 +1,29 @@
import pytest
from textual.app import App
from textual.widgets.text_area import Selection
from textual_textarea import TextEditor
@pytest.mark.parametrize(
"language,expected_marker",
[
("python", "# "),
("sql", "-- "),
# ("mysql", "# "),
# ("c", "// "),
],
)
@pytest.mark.asyncio
async def test_comments(app: App, language: str, expected_marker: str) -> None:
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.language = language
original_text = "foo bar baz"
ta.text = original_text
ta.selection = Selection((0, 0), (0, 0))
await pilot.press("ctrl+underscore") # alias for ctrl+/
assert ta.text == f"{expected_marker}{original_text}"
await pilot.press("ctrl+underscore") # alias for ctrl+/
assert ta.text == f"{original_text}"

View file

@ -0,0 +1,143 @@
import pytest
from textual.app import App
from textual_textarea import TextEditor
from textual_textarea.find_input import FindInput
@pytest.mark.asyncio
async def test_find(app: App) -> None:
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.text = "foo bar\n" * 50
await pilot.pause()
assert ta.selection.start == ta.selection.end == (0, 0)
await pilot.press("ctrl+f")
find_input = app.query_one(FindInput)
assert find_input
assert find_input.has_focus
assert "Find" in find_input.placeholder
await pilot.press("b")
assert find_input.has_focus
assert ta.selection.start == (0, 4)
assert ta.selection.end == (0, 5)
await pilot.press("a")
assert find_input.has_focus
assert ta.selection.start == (0, 4)
assert ta.selection.end == (0, 6)
await pilot.press("enter")
assert find_input.has_focus
assert ta.selection.start == (1, 4)
assert ta.selection.end == (1, 6)
await pilot.press("escape")
assert ta.text_input
assert ta.text_input.has_focus
assert ta.selection.start == (1, 4)
assert ta.selection.end == (1, 6)
await pilot.press("ctrl+f")
find_input = app.query_one(FindInput)
await pilot.press("f")
assert find_input.has_focus
assert ta.selection.start == (2, 0)
assert ta.selection.end == (2, 1)
@pytest.mark.asyncio
async def test_find_history(app: App) -> None:
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.text = "foo bar\n" * 50
await pilot.pause()
# add an item to the history by pressing enter
await pilot.press("ctrl+f")
await pilot.press("a")
await pilot.press("enter")
await pilot.press("escape")
# re-open the find input and navigate the one-item
# history
await pilot.press("ctrl+f")
find_input = app.query_one(FindInput)
assert find_input.value == ""
await pilot.press("up")
assert find_input.value == "a"
await pilot.press("down")
assert find_input.value == ""
await pilot.press("up")
await pilot.press("up")
assert find_input.value == "a"
await pilot.press("down")
assert find_input.value == ""
# add an item to the history by closing the find input
await pilot.press("b")
await pilot.press("escape")
# navigate the two-item history
await pilot.press("ctrl+f")
find_input = app.query_one(FindInput)
assert find_input.value == ""
await pilot.press("up")
assert find_input.value == "b"
await pilot.press("down")
assert find_input.value == ""
await pilot.press("up")
assert find_input.value == "b"
await pilot.press("up")
assert find_input.value == "a"
await pilot.press("up")
assert find_input.value == "a"
await pilot.press("down")
assert find_input.value == "b"
await pilot.press("down")
assert find_input.value == ""
@pytest.mark.asyncio
async def test_find_with_f3(app: App) -> None:
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.text = "foo bar\n" * 50
await pilot.pause()
assert ta.selection.start == ta.selection.end == (0, 0)
# pressing f3 with no history brings up an empty find box
await pilot.press("f3")
find_input = app.query_one(FindInput)
assert find_input
assert find_input.has_focus
assert find_input.value == ""
await pilot.press("b")
assert find_input.has_focus
assert ta.selection.start == (0, 4)
assert ta.selection.end == (0, 5)
# pressing f3 from the find input finds the next match
await pilot.press("f3")
assert find_input.has_focus
assert ta.selection.start == (1, 4)
assert ta.selection.end == (1, 5)
# close the find input and navigate up one line
await pilot.press("escape")
await pilot.press("up")
# pressing f3 with history prepopulates the find input
await pilot.press("f3")
find_input = app.query_one(FindInput)
assert find_input.value == "b"
assert ta.selection.start == (1, 4)
assert ta.selection.end == (1, 5)
# pressing again advances to the next match
await pilot.press("f3")
assert ta.selection.start == (2, 4)
assert ta.selection.end == (2, 5)

View file

@ -0,0 +1,36 @@
import pytest
from textual.app import App
from textual_textarea import TextEditor
from textual_textarea.goto_input import GotoLineInput
@pytest.mark.asyncio
async def test_goto_line(app: App) -> None:
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.text = "\n" * 50
await pilot.pause()
assert ta.selection.start == ta.selection.end == (0, 0)
await pilot.press("ctrl+g")
goto_input = app.query_one(GotoLineInput)
assert goto_input
assert goto_input.has_focus
assert "51" in goto_input.placeholder
await pilot.press("1")
await pilot.press("2")
await pilot.press("enter")
assert ta.text_input
assert ta.text_input.has_focus
assert ta.selection.start == ta.selection.end == (11, 0)
# ensure pressing ctrl+g twice doesn't crash
await pilot.press("ctrl+g")
goto_input = app.query_one(GotoLineInput)
assert goto_input.has_focus
await pilot.press("2")
await pilot.press("ctrl+g")

View file

@ -0,0 +1,70 @@
from pathlib import Path
from typing import List
import pytest
from textual.app import App
from textual.message import Message
from textual.widgets import Input
from textual_textarea import TextAreaSaved, TextEditor
@pytest.mark.parametrize("filename", ["foo.py", "empty.py"])
@pytest.mark.asyncio
async def test_open(data_dir: Path, app: App, filename: str) -> None:
p = data_dir / "test_open" / filename
with open(p, "r") as f:
contents = f.read()
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
assert ta.text == ""
starting_text = "123"
for key in starting_text:
await pilot.press(key)
assert ta.text == starting_text
await pilot.press("ctrl+o")
open_input = ta.query_one(Input)
assert open_input.id and "open" in open_input.id
assert open_input.has_focus
for key in str(p):
await pilot.press(key)
await pilot.press("enter")
assert ta.text == contents
assert ta.text_input is not None
assert ta.text_input.has_focus
# make sure the end of the buffer is formatted properly.
# these previously caused a crash.
await pilot.press("ctrl+end")
assert ta.selection.end[1] >= 0
await pilot.press("enter")
@pytest.mark.asyncio
async def test_save(app: App, tmp_path: Path) -> None:
TEXT = "select\n 1 as a,\n 2 as b,\n 'c' as c"
p = tmp_path / "text.sql"
print(p)
messages: List[Message] = []
async with app.run_test(message_hook=messages.append) as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.text = TEXT
await pilot.press("ctrl+s")
save_input = ta.query_one(Input)
assert save_input.id and "save" in save_input.id
assert save_input.has_focus
save_input.value = str(p)
await pilot.press("enter")
await pilot.pause()
assert len(messages) > 1
assert Input.Submitted in [msg.__class__ for msg in messages]
assert TextAreaSaved in [msg.__class__ for msg in messages]
with open(p, "r") as f:
saved_text = f.read()
assert saved_text == TEXT

View file

@ -0,0 +1,462 @@
from __future__ import annotations
from typing import List
import pytest
from textual.app import App
from textual.widgets.text_area import Selection
from textual_textarea import TextEditor
@pytest.mark.parametrize(
"keys,text,selection,expected_text,expected_selection",
[
(
["ctrl+a"],
"select\n foo",
Selection(start=(1, 2), end=(1, 2)),
"select\n foo",
Selection(start=(0, 0), end=(1, 4)),
),
(
["ctrl+shift+right"],
"select\n foo",
Selection(start=(0, 0), end=(0, 0)),
"select\n foo",
Selection(start=(0, 0), end=(0, 6)),
),
(
["right"],
"select\n foo",
Selection(start=(0, 0), end=(0, 6)),
"select\n foo",
Selection(start=(1, 0), end=(1, 0)),
),
(
["a"],
"select\n foo",
Selection(start=(1, 4), end=(1, 4)),
"select\n fooa",
Selection(start=(1, 5), end=(1, 5)),
),
(
["a"],
"select\n foo",
Selection(start=(1, 0), end=(1, 4)),
"select\na",
Selection(start=(1, 1), end=(1, 1)),
),
(
["enter"],
"a\na",
Selection(start=(1, 0), end=(1, 0)),
"a\n\na",
Selection(start=(2, 0), end=(2, 0)),
),
(
["enter"],
"a\na",
Selection(start=(1, 1), end=(1, 1)),
"a\na\n",
Selection(start=(2, 0), end=(2, 0)),
),
(
["enter", "b"],
"a()",
Selection(start=(0, 2), end=(0, 2)),
"a(\n b\n)",
Selection(start=(1, 5), end=(1, 5)),
),
(
["enter", "b"],
" a()",
Selection(start=(0, 3), end=(0, 3)),
" a(\n b\n )",
Selection(start=(1, 5), end=(1, 5)),
),
(
["delete"],
"0\n1\n2\n3",
Selection(start=(2, 1), end=(2, 1)),
"0\n1\n23",
Selection(start=(2, 1), end=(2, 1)),
),
(
["shift+delete"],
"0\n1\n2\n3",
Selection(start=(2, 1), end=(2, 1)),
"0\n1\n3",
Selection(start=(2, 0), end=(2, 0)),
),
(
["shift+delete"],
"0\n1\n2\n3",
Selection(start=(2, 0), end=(2, 1)),
"0\n1\n\n3",
Selection(start=(2, 0), end=(2, 0)),
),
(
["shift+delete"],
"0\n1\n2\n3",
Selection(start=(3, 1), end=(3, 1)),
"0\n1\n2",
Selection(start=(2, 1), end=(2, 1)),
),
(
["shift+delete"],
"foo",
Selection(start=(3, 1), end=(3, 1)),
"",
Selection(start=(0, 0), end=(0, 0)),
),
(
["ctrl+home"],
"foo\nbar",
Selection(start=(1, 2), end=(1, 2)),
"foo\nbar",
Selection(start=(0, 0), end=(0, 0)),
),
(
["ctrl+end"],
"foo\nbar",
Selection(start=(0, 1), end=(0, 1)),
"foo\nbar",
Selection(start=(1, 3), end=(1, 3)),
),
(
["("],
"foo",
Selection(start=(0, 3), end=(0, 3)),
"foo()",
Selection(start=(0, 4), end=(0, 4)),
),
(
["("],
"foo",
Selection(start=(0, 2), end=(0, 2)),
"fo(o",
Selection(start=(0, 3), end=(0, 3)),
),
(
["("],
"foo.",
Selection(start=(0, 3), end=(0, 3)),
"foo().",
Selection(start=(0, 4), end=(0, 4)),
),
(
["("],
"foo-",
Selection(start=(0, 3), end=(0, 3)),
"foo(-",
Selection(start=(0, 4), end=(0, 4)),
),
(
["'"],
"foo",
Selection(start=(0, 3), end=(0, 3)),
"foo'",
Selection(start=(0, 4), end=(0, 4)),
),
(
["'"],
"ba r",
Selection(start=(0, 3), end=(0, 3)),
"ba '' r",
Selection(start=(0, 4), end=(0, 4)),
),
(
["'"],
"foo-",
Selection(start=(0, 3), end=(0, 3)),
"foo'-",
Selection(start=(0, 4), end=(0, 4)),
),
(
["'"],
"fo--",
Selection(start=(0, 3), end=(0, 3)),
"fo-'-",
Selection(start=(0, 4), end=(0, 4)),
),
(
["'"],
"fo-.",
Selection(start=(0, 3), end=(0, 3)),
"fo-''.",
Selection(start=(0, 4), end=(0, 4)),
),
(
["'"],
"fo()",
Selection(start=(0, 3), end=(0, 3)),
"fo('')",
Selection(start=(0, 4), end=(0, 4)),
),
(
["tab"],
"bar",
Selection(start=(0, 1), end=(0, 1)),
"b ar",
Selection(start=(0, 4), end=(0, 4)),
),
(
["tab"],
"bar",
Selection(start=(0, 0), end=(0, 0)),
" bar",
Selection(start=(0, 4), end=(0, 4)),
),
(
["shift+tab"],
"bar",
Selection(start=(0, 0), end=(0, 0)),
"bar",
Selection(start=(0, 0), end=(0, 0)),
),
(
["shift+tab"],
" bar",
Selection(start=(0, 7), end=(0, 7)),
"bar",
Selection(start=(0, 3), end=(0, 3)),
),
(
["tab"],
"bar\n baz",
Selection(start=(0, 2), end=(1, 1)),
" bar\n baz",
Selection(start=(0, 6), end=(1, 4)),
),
(
["tab"],
"bar\n baz",
Selection(start=(0, 0), end=(1, 1)),
" bar\n baz",
Selection(start=(0, 0), end=(1, 4)),
),
(
["shift+tab"],
" bar\n baz",
Selection(start=(0, 0), end=(1, 1)),
"bar\nbaz",
Selection(start=(0, 0), end=(1, 0)),
),
],
)
@pytest.mark.asyncio
async def test_keys(
app: App,
keys: List[str],
text: str,
selection: Selection,
expected_text: str,
expected_selection: Selection,
) -> None:
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.text = text
ta.selection = selection
for key in keys:
await pilot.press(key)
assert ta.text == expected_text
assert ta.selection == expected_selection
@pytest.mark.parametrize(
"starting_selection,expected_clipboard,expected_paste_loc",
[
(Selection((0, 5), (1, 5)), "56789\n01234", (1, 5)),
(Selection((0, 0), (1, 0)), "0123456789\n", (1, 0)),
],
)
@pytest.mark.asyncio
async def test_copy_paste(
app_all_clipboards: App,
starting_selection: Selection,
expected_clipboard: str,
expected_paste_loc: tuple[int, int],
) -> None:
original_text = "0123456789\n0123456789\n0123456789"
def eq(a: str, b: str) -> bool:
return a.replace("\r\n", "\n") == b.replace("\r\n", "\n")
async with app_all_clipboards.run_test() as pilot:
ta = app_all_clipboards.query_one("#ta", expect_type=TextEditor)
while ta.text_input is None:
await pilot.pause(0.1)
ti = ta.text_input
assert ti is not None
ta.text = original_text
ta.selection = starting_selection
await pilot.press("ctrl+c")
await pilot.pause()
assert eq(ti.clipboard, expected_clipboard)
assert ta.selection == starting_selection
assert ta.text == original_text
await pilot.press("ctrl+u")
await pilot.pause()
assert eq(ti.clipboard, expected_clipboard)
assert ta.selection == Selection(starting_selection.end, starting_selection.end)
assert ta.text == original_text
await pilot.press("ctrl+a")
assert ta.selection == Selection(
(0, 0),
(len(original_text.splitlines()) - 1, len(original_text.splitlines()[-1])),
)
assert eq(ti.clipboard, expected_clipboard)
assert ta.text == original_text
await pilot.press("ctrl+u")
await pilot.pause()
assert ta.selection == Selection(expected_paste_loc, expected_paste_loc)
assert eq(ti.clipboard, expected_clipboard)
assert ta.text == expected_clipboard
await pilot.press("ctrl+a")
await pilot.press("ctrl+x")
await pilot.pause()
assert ta.selection == Selection((0, 0), (0, 0))
assert eq(ti.clipboard, expected_clipboard)
assert ta.text == ""
await pilot.press("ctrl+v")
await pilot.pause()
assert eq(ti.clipboard, expected_clipboard)
assert ta.text == expected_clipboard
assert ta.selection == Selection(expected_paste_loc, expected_paste_loc)
@pytest.mark.asyncio
async def test_undo_redo(app: App) -> None:
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ti = ta.text_input
assert ti
assert ti.has_focus
for char in "foo":
await pilot.press(char)
await pilot.pause(0.6)
await pilot.press("enter")
for char in "bar":
await pilot.press(char)
await pilot.pause(0.6)
await pilot.press("ctrl+z")
assert ta.text == "foo\n"
assert ta.selection == Selection((1, 0), (1, 0))
await pilot.press("ctrl+z")
assert ta.text == "foo"
assert ta.selection == Selection((0, 3), (0, 3))
await pilot.press("ctrl+z")
assert ta.text == ""
assert ta.selection == Selection((0, 0), (0, 0))
await pilot.press("ctrl+y")
assert ta.text == "foo"
assert ta.selection == Selection((0, 3), (0, 3))
await pilot.press("z")
assert ta.text == "fooz"
@pytest.mark.parametrize(
"start_text,insert_text,selection,expected_text",
[
(
"select ",
'"main"."drivers"."driverId"',
Selection((0, 7), (0, 7)),
'select "main"."drivers"."driverId"',
),
(
"select , foo",
'"main"."drivers"."driverId"',
Selection((0, 7), (0, 7)),
'select "main"."drivers"."driverId", foo',
),
(
"aaa\naaa\naaa\naaa",
"bb",
Selection((2, 2), (2, 2)),
"aaa\naaa\naabba\naaa",
),
(
"aaa\naaa\naaa\naaa",
"bb",
Selection((2, 2), (1, 1)),
"aaa\nabba\naaa",
),
(
"01234",
"\nabc\n",
Selection((0, 2), (0, 2)),
"01\nabc\n234",
),
],
)
@pytest.mark.asyncio
async def test_insert_text(
app: App,
start_text: str,
insert_text: str,
selection: Selection,
expected_text: str,
) -> None:
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.text = start_text
ta.selection = selection
await pilot.pause()
ta.insert_text_at_selection(insert_text)
await pilot.pause()
assert ta.text == expected_text
@pytest.mark.asyncio
async def test_toggle_comment(app: App) -> None:
async with app.run_test() as pilot:
ta = app.query_one("#ta", expect_type=TextEditor)
ta.text = "one\ntwo\n\nthree"
ta.selection = Selection((0, 0), (0, 0))
await pilot.pause()
await pilot.press("ctrl+underscore")
assert ta.text == "# one\ntwo\n\nthree"
await pilot.press("down")
await pilot.press("ctrl+underscore")
assert ta.text == "# one\n# two\n\nthree"
await pilot.press("ctrl+a")
await pilot.press("ctrl+underscore")
assert ta.text == "# # one\n# # two\n\n# three"
await pilot.press("ctrl+underscore")
assert ta.text == "# one\n# two\n\nthree"
await pilot.press("up")
await pilot.press("up")
await pilot.press("ctrl+underscore")
assert ta.text == "# one\ntwo\n\nthree"
await pilot.press("shift+down")
await pilot.press("shift+down")
await pilot.press("ctrl+underscore")
assert ta.text == "# one\n# two\n\n# three"
await pilot.press("ctrl+a")
await pilot.press("ctrl+underscore")
assert ta.text == "one\ntwo\n\nthree"

View file

@ -0,0 +1,84 @@
from __future__ import annotations
from pathlib import Path
import pytest
from textual_textarea.path_input import PathValidator, path_completer
@pytest.mark.parametrize(
"relpath,expected_matches",
[
("", ["foo", "bar"]),
("f", ["foo"]),
("fo", ["foo"]),
("foo", ["baz.txt"]),
("foo/", ["baz.txt"]),
("b", ["bar"]),
("c", []),
],
)
def test_path_completer(
data_dir: Path,
relpath: str,
expected_matches: list[str],
) -> None:
test_path = data_dir / "test_validator" / relpath
test_dir = test_path if test_path.is_dir() else test_path.parent
prefix = str(test_path)
print(prefix)
matches = path_completer(prefix)
assert sorted(matches) == sorted(
[(str(test_dir / m), str(test_dir / m)) for m in expected_matches]
)
@pytest.mark.parametrize(
"relpath,dir_okay,file_okay,must_exist,expected_result",
[
("foo", True, True, True, True),
("foo", True, True, False, True),
("foo", True, False, True, True),
("foo", True, False, False, True),
("foo", False, True, True, False),
("foo", False, True, False, False),
("foo", False, False, True, False),
("foo", False, False, False, False),
("bar", True, True, True, True),
("bar", True, True, False, True),
("bar", True, False, True, True),
("bar", True, False, False, True),
("bar", False, True, True, False),
("bar", False, True, False, False),
("bar", False, False, True, False),
("bar", False, False, False, False),
("baz", True, True, True, False),
("baz", True, True, False, True),
("baz", True, False, True, False),
("baz", True, False, False, True),
("baz", False, True, True, False),
("baz", False, True, False, True),
("baz", False, False, True, False),
("baz", False, False, False, True),
("foo/baz.txt", True, True, True, True),
("foo/baz.txt", True, True, False, True),
("foo/baz.txt", True, False, True, False),
("foo/baz.txt", True, False, False, False),
("foo/baz.txt", False, True, True, True),
("foo/baz.txt", False, True, False, True),
("foo/baz.txt", False, False, True, False),
("foo/baz.txt", False, False, False, False),
],
)
def test_path_validator(
data_dir: Path,
relpath: str,
dir_okay: bool,
file_okay: bool,
must_exist: bool,
expected_result: bool,
) -> None:
p = data_dir / "test_validator" / relpath
validator = PathValidator(dir_okay, file_okay, must_exist)
result = validator.validate(str(p))
assert result.is_valid == expected_result

157
textarea.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB