Adding upstream version 0.15.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
0184169650
commit
c6da052ee9
47 changed files with 6799 additions and 0 deletions
15
.github/dependabot.yml
vendored
Normal file
15
.github/dependabot.yml
vendored
Normal 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
47
.github/workflows/publish.yml
vendored
Normal 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
56
.github/workflows/release.yml
vendored
Normal 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
46
.github/workflows/static.yml
vendored
Normal 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
92
.github/workflows/test.yml
vendored
Normal 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
166
.gitignore
vendored
Normal 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
24
.pre-commit-config.yaml
Normal 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
325
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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
21
Makefile
Normal 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
150
README.md
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
# Textual Textarea
|
||||||
|

|
||||||
|
|
||||||
|
## 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
1816
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
67
pyproject.toml
Normal file
67
pyproject.toml
Normal 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
0
src/scripts/__init__.py
Normal file
23
src/scripts/profile_startup.py
Normal file
23
src/scripts/profile_startup.py
Normal 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()
|
20
src/scripts/sample_code.py
Normal file
20
src/scripts/sample_code.py
Normal 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
32
src/scripts/screenshot.py
Normal 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())
|
15
src/textual_textarea/__init__.py
Normal file
15
src/textual_textarea/__init__.py
Normal 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",
|
||||||
|
]
|
67
src/textual_textarea/__main__.py
Normal file
67
src/textual_textarea/__main__.py
Normal 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()
|
289
src/textual_textarea/autocomplete.py
Normal file
289
src/textual_textarea/autocomplete.py
Normal 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
|
19
src/textual_textarea/cancellable_input.py
Normal file
19
src/textual_textarea/cancellable_input.py
Normal 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())
|
67
src/textual_textarea/colors.py
Normal file
67
src/textual_textarea/colors.py
Normal 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
|
185
src/textual_textarea/comments.py
Normal file
185
src/textual_textarea/comments.py
Normal 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": "//",
|
||||||
|
}
|
54
src/textual_textarea/containers.py
Normal file
54
src/textual_textarea/containers.py
Normal 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
|
||||||
|
)
|
75
src/textual_textarea/error_modal.py
Normal file
75
src/textual_textarea/error_modal.py
Normal 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()
|
77
src/textual_textarea/find_input.py
Normal file
77
src/textual_textarea/find_input.py
Normal 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()
|
60
src/textual_textarea/goto_input.py
Normal file
60
src/textual_textarea/goto_input.py
Normal 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,
|
||||||
|
)
|
38
src/textual_textarea/messages.py
Normal file
38
src/textual_textarea/messages.py
Normal 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
|
127
src/textual_textarea/path_input.py
Normal file
127
src/textual_textarea/path_input.py
Normal 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
|
0
src/textual_textarea/py.typed
Normal file
0
src/textual_textarea/py.typed
Normal file
1465
src/textual_textarea/text_editor.py
Normal file
1465
src/textual_textarea/text_editor.py
Normal file
File diff suppressed because it is too large
Load diff
7
stubs/pyperclip/__init__.pyi
Normal file
7
stubs/pyperclip/__init__.pyi
Normal 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
9
tests/conftest.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def data_dir() -> Path:
|
||||||
|
here = Path(__file__)
|
||||||
|
return here.parent / "data"
|
0
tests/data/test_open/empty.py
Normal file
0
tests/data/test_open/empty.py
Normal file
2
tests/data/test_open/foo.py
Normal file
2
tests/data/test_open/foo.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
def foo(bar: str, baz: int) -> None:
|
||||||
|
return
|
0
tests/data/test_validator/bar/.gitkeep
Normal file
0
tests/data/test_validator/bar/.gitkeep
Normal file
0
tests/data/test_validator/foo/baz.txt
Normal file
0
tests/data/test_validator/foo/baz.txt
Normal file
0
tests/functional_tests/__init__.py
Normal file
0
tests/functional_tests/__init__.py
Normal file
62
tests/functional_tests/conftest.py
Normal file
62
tests/functional_tests/conftest.py
Normal 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
|
279
tests/functional_tests/test_autocomplete.py
Normal file
279
tests/functional_tests/test_autocomplete.py
Normal 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
|
29
tests/functional_tests/test_comments.py
Normal file
29
tests/functional_tests/test_comments.py
Normal 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}"
|
143
tests/functional_tests/test_find.py
Normal file
143
tests/functional_tests/test_find.py
Normal 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)
|
36
tests/functional_tests/test_goto.py
Normal file
36
tests/functional_tests/test_goto.py
Normal 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")
|
70
tests/functional_tests/test_open.py
Normal file
70
tests/functional_tests/test_open.py
Normal 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
|
462
tests/functional_tests/test_textarea.py
Normal file
462
tests/functional_tests/test_textarea.py
Normal 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"
|
84
tests/unit_tests/test_path_validator.py
Normal file
84
tests/unit_tests/test_path_validator.py
Normal 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
157
textarea.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 35 KiB |
Loading…
Add table
Reference in a new issue