├── .github └── workflows │ ├── build-and-test.yml │ └── publish.yml ├── .gitignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── pyproject.toml ├── src └── words_tui │ ├── __about__.py │ ├── __init__.py │ ├── __main__.py │ ├── cli │ └── __init__.py │ └── tui │ ├── __init__.py │ ├── app.css │ ├── app.py │ ├── db.py │ ├── settings.css │ └── text_editor.py └── tests ├── __init__.py └── test_tui.py /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Build and Test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python 3.12 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: "3.12" 25 | cache: "pip" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install hatch 30 | - name: Cache Hatch 31 | id: cache-hatch 32 | uses: actions/cache@v3 33 | with: 34 | path: /home/runner/.local/share/hatch/env/virtual/ 35 | key: ${{ runner.os }}-hatch 36 | - name: Build 37 | run: hatch build 38 | - name: Test 39 | run: hatch run test 40 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | environment: release 16 | permissions: 17 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: '3.12' 25 | cache: 'pip' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install hatch 30 | - name: Build package 31 | run: hatch build 32 | - name: Test package 33 | run: hatch run test 34 | - name: Publish package distributions to PyPI 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | write_tui.egg-info 3 | dist 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: words-tui", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "src/words_tui/__main__.py", 12 | "console": "integratedTerminal", 13 | "justMyCode": true 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Calendar Versioning](https://calver.org). 7 | 8 | ## [23.6] - 2023-12-04 9 | 10 | Add Python 3.12 support. 11 | 12 | ## [23.5] - 2023-08-19 13 | 14 | Test release to try out Trusted Publishers. 15 | 16 | ## [23.4] - 2023-08-18 17 | 18 | We now have a WPM counter! 19 | 20 | ### Added 21 | * Words Per Minute (WPM) counter 22 | 23 | ## [23.3] - 2023-08-11 24 | 25 | You can now configure the daily words goal setting. 26 | 27 | ### Added 28 | * Ability to configure the daily words goal 29 | 30 | ## Fixed 31 | * Show missed days in sidebar 32 | 33 | ## [23.2] - 2023-08-04 34 | 35 | A small bug fix release. 36 | 37 | ### Fixed 38 | * Fix saving of posts 39 | 40 | ## [23.1] - 2023-08-04 41 | 42 | A small update to fix a few bugs and add a changelog link to the PyPI page. 43 | 44 | ### Fixed 45 | * Name for db file 46 | * New day not starting out empty 47 | * Limit on sidebar posts 48 | 49 | ### Added 50 | * Changelog link to PyPI page 51 | 52 | ## [23.0] - 2023-08-02 53 | 54 | Initial Release! 55 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-present Anže Pečar 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![Screenshot 2023-08-24 at 08 13 47](https://github.com/anze3db/words-tui/assets/513444/021d5cd8-da5d-43b6-8747-ff1c1cb31d6e) 3 | 4 | # words-tui 5 | 6 | `words-tui` is an app for daily writing in your terminal, built with [Textual](https://github.com/Textualize/textual). 7 | 8 | [![PyPI - Version](https://img.shields.io/pypi/v/words-tui.svg)](https://pypi.org/project/words-tui) 9 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/words-tui.svg)](https://pypi.org/project/words-tui) 10 | [![Build and Test](https://github.com/anze3db/words-tui/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/anze3db/words-tui/actions/workflows/build-and-test.yml) 11 | 12 | ----- 13 | 14 | **Table of Contents** 15 | 16 | - [Demo](#demo) 17 | - [Installation](#installation) 18 | - [Running](#running) 19 | - [License](#license) 20 | 21 | ## 🎬 Demo 22 | 23 | https://github.com/anze3db/words-tui/assets/513444/5f064606-384f-471d-8990-f4681dfff29c 24 | 25 | ## Installation 26 | 27 | The easiest way to install `words-tui` is with [pipx](https://pypa.github.io/pipx/). 28 | 29 | ```console 30 | pipx install words-tui 31 | ``` 32 | 33 | Alternatively, you can install it with `pip`: 34 | 35 | ```console 36 | pip install words-tui 37 | ``` 38 | 39 | ## Running 40 | 41 | To run `words-tui`, simply run the following command: 42 | 43 | ```console 44 | words-tui 45 | ``` 46 | 47 | It stores all of your writing in ~/.words-tui.db by default, but you can override this with the `WORDS_TUI_DB` environment variable or the `--db` flag. 48 | 49 | ```console 50 | words-tui --db /path/to/db 51 | ``` 52 | 53 | ## License 54 | 55 | `words-tui` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "words-tui" 7 | dynamic = ["version"] 8 | description = 'A TUI (Text User Interface) app for daily writing.' 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = "MIT" 12 | keywords = [] 13 | authors = [ 14 | { name = "Anže Pečar", email = "anze@pecar.me" }, 15 | ] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: Implementation :: CPython", 25 | "Programming Language :: Python :: Implementation :: PyPy", 26 | ] 27 | dependencies = [ 28 | "click", "textual", "peewee", "tree-sitter" 29 | ] 30 | 31 | [project.urls] 32 | Documentation = "https://github.com/anze3db/words-tui#readme" 33 | Issues = "https://github.com/anze3db/words-tui/issues" 34 | Source = "https://github.com/anze3db/words-tui" 35 | Changelog = "https://github.com/anze3db/words-tui/blob/main/CHANGELOG.md" 36 | 37 | [project.scripts] 38 | words-tui = "words_tui.cli:words_tui" 39 | 40 | [tool.hatch.version] 41 | path = "src/words_tui/__about__.py" 42 | 43 | [tool.hatch.envs.default] 44 | python = "3.12" 45 | dependencies = [ 46 | "coverage[toml]>=6.5", 47 | "pytest", 48 | "pytest-watch", 49 | "textual-dev", 50 | "types-peewee" 51 | ] 52 | [tool.hatch.envs.default.scripts] 53 | test = "pytest {args:tests}" 54 | test-cov = "coverage run -m pytest {args:tests}" 55 | cov-report = [ 56 | "- coverage combine", 57 | "coverage report", 58 | ] 59 | cov = [ 60 | "test-cov", 61 | "cov-report", 62 | ] 63 | 64 | [[tool.hatch.envs.all.matrix]] 65 | python = ["3.8", "3.9", "3.10", "3.11", "3.12"] 66 | 67 | [tool.hatch.envs.lint] 68 | extra-dependencies = [ 69 | "mypy>=1.0.0", 70 | "ruff>=0.1.6", 71 | ] 72 | [tool.hatch.envs.lint.scripts] 73 | typing = "mypy --install-types --non-interactive {args:src/words_tui tests}" 74 | style = [ 75 | "ruff {args:.}", 76 | "ruff format {args:.}", 77 | ] 78 | fmt = [ 79 | "ruff {args:.}", 80 | "ruff --fix {args:.}", 81 | "style", 82 | ] 83 | all = [ 84 | "style", 85 | "typing", 86 | ] 87 | 88 | [tool.ruff] 89 | target-version = "py38" 90 | line-length = 120 91 | select = [ 92 | "A", 93 | "ARG", 94 | "B", 95 | "C", 96 | "DTZ", 97 | "E", 98 | "EM", 99 | "F", 100 | "FBT", 101 | "I", 102 | "ICN", 103 | "ISC", 104 | "N", 105 | "PLC", 106 | "PLE", 107 | "PLR", 108 | "PLW", 109 | "Q", 110 | "RUF", 111 | "S", 112 | "T", 113 | "TID", 114 | "UP", 115 | "W", 116 | "YTT", 117 | ] 118 | ignore = [ 119 | # Allow non-abstract empty methods in abstract base classes 120 | "B027", 121 | # Allow boolean positional values in function calls, like `dict.get(... True)` 122 | "FBT003", 123 | # Ignore checks for possible passwords 124 | "S105", "S106", "S107", 125 | # Ignore complexity 126 | "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", 127 | ] 128 | unfixable = [ 129 | # Don't touch unused imports 130 | "F401", 131 | ] 132 | 133 | [tool.ruff.isort] 134 | known-first-party = ["words_tui"] 135 | 136 | [tool.ruff.flake8-tidy-imports] 137 | ban-relative-imports = "all" 138 | 139 | [tool.ruff.per-file-ignores] 140 | # Tests can use magic values, assertions, and relative imports 141 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 142 | 143 | [tool.coverage.run] 144 | source_pkgs = ["words_tui", "tests"] 145 | branch = true 146 | parallel = true 147 | omit = [ 148 | "src/words_tui/__about__.py", 149 | ] 150 | 151 | [tool.coverage.paths] 152 | words_tui = ["src/words_tui", "*/words-tui/src/words_tui"] 153 | tests = ["tests", "*/words-tui/tests"] 154 | 155 | [tool.coverage.report] 156 | exclude_lines = [ 157 | "no cov", 158 | "if __name__ == .__main__.:", 159 | "if TYPE_CHECKING:", 160 | ] 161 | -------------------------------------------------------------------------------- /src/words_tui/__about__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023-present Anže Pečar 2 | # 3 | # SPDX-License-Identifier: MIT 4 | __version__ = "23.6" 5 | -------------------------------------------------------------------------------- /src/words_tui/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023-present Anže Pečar 2 | # 3 | # SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /src/words_tui/__main__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023-present Anže Pečar 2 | # 3 | # SPDX-License-Identifier: MIT 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | from words_tui.cli import words_tui 8 | 9 | sys.exit(words_tui()) 10 | -------------------------------------------------------------------------------- /src/words_tui/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023-present Anže Pečar 2 | # 3 | # SPDX-License-Identifier: MIT 4 | from pathlib import Path 5 | 6 | import click 7 | 8 | from words_tui.__about__ import __version__ 9 | from words_tui.tui.app import WordsTui 10 | from words_tui.tui.db import init_db 11 | 12 | 13 | @click.group(context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True) 14 | @click.version_option(version=__version__, prog_name="words-tui") 15 | @click.option("--db", "-d", envvar="WORDS_TUI_DB", default=Path.home() / ".words-tui.db", help="Database file to use") 16 | def words_tui(db: str): 17 | init_db(db) 18 | WordsTui().run() 19 | -------------------------------------------------------------------------------- /src/words_tui/tui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anze3db/words-tui/deaae32facf2e06bbaa1fdb99db5f6cef8e17d79/src/words_tui/tui/__init__.py -------------------------------------------------------------------------------- /src/words_tui/tui/app.css: -------------------------------------------------------------------------------- 1 | #sidebar { 2 | dock: left; 3 | width: 24; 4 | height: 100%; 5 | color: white; 6 | background: black; 7 | } 8 | -------------------------------------------------------------------------------- /src/words_tui/tui/app.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import time 3 | 4 | from textual import on 5 | from textual.app import App, ComposeResult 6 | from textual.containers import Vertical 7 | from textual.css.query import NoMatches 8 | from textual.screen import ModalScreen 9 | from textual.validation import Number 10 | from textual.widgets import Footer, Input, Label, Static 11 | 12 | from words_tui.tui.db import Post, PostStats, get_posts, get_settings 13 | from words_tui.tui.text_editor import TextEditor 14 | 15 | 16 | def get_post_icon(post: Post, words_per_day: str) -> str: 17 | if len(post.content.split()) >= int(words_per_day): 18 | return "✅" 19 | elif post.created_date.date() == dt.date.today(): 20 | return "📝" 21 | return "❌" 22 | 23 | 24 | def get_post_summary(post: Post, words_per_day: str) -> str: 25 | return " ".join( 26 | map( 27 | str, 28 | [ 29 | get_post_icon(post, words_per_day), 30 | post.created_date.strftime("%Y-%m-%d"), 31 | f"{len(post.content.split()):5}/{words_per_day}", 32 | ], 33 | ) 34 | ) 35 | 36 | 37 | def get_sidebar_text(words_per_day: str) -> str: 38 | posts = get_posts() 39 | return "[bold] # Date Words/Goal[/bold]\n" + "\n".join(get_post_summary(post, words_per_day) for post in posts) 40 | 41 | 42 | class SettingsScreen(ModalScreen): 43 | CSS_PATH = "settings.css" 44 | BINDINGS = [ 45 | ("escape", "dismiss", "Back"), 46 | ("ctrl+c", "quit", "Quit"), 47 | ] 48 | 49 | def action_dismiss(self) -> None: 50 | self.dismiss(get_settings()) 51 | 52 | def compose(self) -> ComposeResult: 53 | words_per_day = get_settings() 54 | with Vertical(id="grid"): 55 | yield Label( 56 | "Settings", 57 | id="settings_label", 58 | ) 59 | yield Label( 60 | "Number of words per day", 61 | id="per_day_label", 62 | ) 63 | yield Input( 64 | words_per_day.value, 65 | id="per_day_input", 66 | validators=[ 67 | Number(minimum=1, maximum=9999), 68 | ], 69 | ) 70 | yield Footer() 71 | 72 | @on(Input.Changed) 73 | def show_invalid_reasons(self, event: Input.Changed) -> None: 74 | # Updating the UI to show the reasons why validation failed 75 | if not event.validation_result.is_valid: 76 | return 77 | 78 | words_per_day = get_settings() 79 | words_per_day.value = event.input.value 80 | words_per_day.save() 81 | 82 | 83 | class WordsPerMinuteCounter: 84 | def __init__(self, post: Post, words_per_day: int): 85 | self.paused = True 86 | self.words_per_day = words_per_day 87 | self.last_char_written = 0 88 | 89 | self.post = post 90 | self.stats = post.stats.first() 91 | if not self.post.stats: 92 | self.stats = PostStats(post=self.post) 93 | 94 | self.total_words = self._get_num_words() 95 | 96 | self.words_written = self.stats.words_written 97 | self.words_deleted = self.stats.words_deleted 98 | self.pauses = self.stats.pauses 99 | self.time_writing = self.stats.time_writing 100 | 101 | def _get_num_words(self): 102 | return len(self.post.content.split()) 103 | 104 | def type_character(self): 105 | if self.paused: 106 | self.last_char_written = time.monotonic() 107 | self.paused = False 108 | else: 109 | current_time = time.monotonic() 110 | self.time_writing += current_time - self.last_char_written 111 | self.last_char_written = current_time 112 | 113 | words = self._get_num_words() 114 | diff = words - self.total_words 115 | self.total_words += diff 116 | if diff > 0: 117 | self.words_written += diff 118 | if diff < 0: 119 | self.words_deleted += abs(diff) 120 | self.total_words = words 121 | 122 | def update_words(self): 123 | if self.paused: 124 | return 125 | since_last_char = time.monotonic() - self.last_char_written 126 | if since_last_char > 2: 127 | self.paused = True 128 | self.pauses += 1 129 | return 130 | 131 | checkpoint_index = str(int(self.time_writing) // 60) 132 | 133 | self.stats.per_minute[checkpoint_index] = { 134 | "words_written": self.words_written, 135 | "words_deleted": self.words_deleted, 136 | "pauses": self.pauses, 137 | } 138 | self.stats.words_written = self.words_written 139 | self.stats.words_deleted = self.words_deleted 140 | self.stats.pauses = self.pauses 141 | self.stats.time_writing = self.time_writing 142 | 143 | if self.stats.writing_time_until_goal is None and self.words_written - self.words_deleted >= int( 144 | self.words_per_day.value 145 | ): 146 | self.stats.writing_time_until_goal = self.time_writing 147 | 148 | self.stats.save() 149 | 150 | def get_wpm(self) -> str: 151 | if self.last_char_written == 0: 152 | return "Not started" 153 | if self.paused or not self.stats: 154 | return "Paused" 155 | if self.time_writing < 10: 156 | return "~" 157 | 158 | return f"{self.words_written / self.time_writing * 60:.2f}" 159 | 160 | 161 | class WordsTui(App): 162 | """A Textual app for writing.""" 163 | 164 | BINDINGS = [("ctrl+c", "quit", "Quit"), ("ctrl+s", "open_settings", "Settings")] 165 | SCREENS = {"settings": SettingsScreen()} 166 | 167 | CSS_PATH = "app.css" 168 | 169 | def __init__(self, *args, **kwargs) -> None: 170 | super().__init__(*args, **kwargs) 171 | # TODO: Find a better place for this 172 | self.posts = get_posts() 173 | self.words_per_day = get_settings() 174 | 175 | if not self.posts: 176 | # When we first initialize the app there are no posts in the database, so create the first one: 177 | self.posts.insert(0, Post.create(content="", created_date=dt.datetime.now())) 178 | else: 179 | latest_post = self.posts[0] 180 | missing_days = (dt.date.today() - latest_post.created_date.date()).days 181 | start_date = latest_post.created_date.replace(hour=0, minute=0, second=0, microsecond=0) 182 | 183 | for day in range(missing_days): 184 | self.posts.insert(0, Post.create(content="", created_date=start_date + dt.timedelta(days=day + 1))) 185 | 186 | current_post = [post for post in self.posts if post.created_date.date() == dt.date.today()] 187 | self.current_post: Post = current_post[0] 188 | 189 | self.words_per_minute = WordsPerMinuteCounter(self.current_post, words_per_day=self.words_per_day) 190 | self.editor = TextEditor(id="editor") 191 | self.editor.show_line_numbers = False 192 | self.editor.load_text(self.current_post.content) 193 | 194 | def on_mount(self) -> None: 195 | self.set_interval(1, self.update_wpm) 196 | 197 | def update_wpm(self) -> None: 198 | self.words_per_minute.update_words() 199 | try: 200 | wpm = self.query_one("#wpm") 201 | except NoMatches: 202 | return 203 | wpm.update(f"WPM: {self.words_per_minute.get_wpm()}") 204 | 205 | def on_text_editor_changed(self, _: TextEditor.Changed) -> None: 206 | self.update_word_count() 207 | 208 | def update_word_count(self) -> None: 209 | sidebar = self.query_one("#sidebar") 210 | text_editor = self.query_one(TextEditor) 211 | 212 | text = "\n".join(text_editor.document_lines) 213 | self.current_post.content = text 214 | self.current_post.save() 215 | 216 | self.words_per_minute.type_character() 217 | sidebar.update(get_sidebar_text(self.words_per_day.value)) 218 | 219 | def action_open_settings(self) -> None: 220 | def after_quit(words_per_day) -> None: 221 | self.editor.focus() 222 | self.words_per_day = words_per_day 223 | self.words_per_minute.words_per_day = words_per_day 224 | self.update_word_count() 225 | 226 | self.push_screen(SettingsScreen(), after_quit) 227 | 228 | def compose(self) -> ComposeResult: 229 | """Create child widgets for the app.""" 230 | 231 | yield Static(get_sidebar_text(self.words_per_day.value), id="sidebar") 232 | yield Static( 233 | f"WPM: Not started", 234 | id="wpm", 235 | ) 236 | yield self.editor 237 | yield Footer() 238 | 239 | 240 | if __name__ == "__main__": 241 | app = WordsTui() 242 | app.run() 243 | -------------------------------------------------------------------------------- /src/words_tui/tui/db.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import time 4 | 5 | from peewee import ( 6 | DatabaseProxy, 7 | DateTimeField, 8 | DoubleField, 9 | ForeignKeyField, 10 | IntegerField, 11 | Model, 12 | SqliteDatabase, 13 | TextField, 14 | ) 15 | 16 | database_proxy = DatabaseProxy() 17 | 18 | 19 | class BaseModel(Model): 20 | class Meta: 21 | database = database_proxy 22 | 23 | 24 | class JSONField(TextField): 25 | def db_value(self, value): 26 | return json.dumps(value) 27 | 28 | def python_value(self, value): 29 | if value is not None: 30 | return json.loads(value) 31 | 32 | 33 | class Post(BaseModel): 34 | content = TextField() 35 | created_date = DateTimeField(default=datetime.datetime.now) 36 | 37 | 38 | class PostStats(BaseModel): 39 | post = ForeignKeyField(Post, backref="stats") 40 | 41 | words_written = IntegerField(default=0) 42 | words_deleted = IntegerField(default=0) 43 | pauses = IntegerField(default=0) 44 | time_writing = DoubleField(default=0) 45 | 46 | writing_time_until_goal = DoubleField(null=True) 47 | 48 | per_minute = JSONField( 49 | default=lambda: { 50 | "0": { 51 | "words_written": 0, 52 | "words_deleted": 0, 53 | "pauses": 0, 54 | } 55 | } 56 | ) 57 | 58 | 59 | class Settings(BaseModel): 60 | key = TextField() 61 | value = TextField() 62 | 63 | 64 | def get_posts() -> list[Post]: 65 | return list(Post.select().order_by(Post.created_date.desc())) 66 | 67 | 68 | def get_settings() -> Settings: 69 | return Settings.get(Settings.key == "words_per_day") 70 | 71 | 72 | def init_db(db_path: str): 73 | db = SqliteDatabase(db_path) 74 | database_proxy.initialize(db) 75 | 76 | db.connect() 77 | db.create_tables([Post, PostStats, Settings]) 78 | 79 | # Initialize settings: 80 | Settings.get_or_create(key="words_per_day", defaults={"value": "300"}) 81 | -------------------------------------------------------------------------------- /src/words_tui/tui/settings.css: -------------------------------------------------------------------------------- 1 | #dialog { 2 | grid-size: 1; 3 | grid-gutter: 1 1; 4 | grid-rows: 1fr 3; 5 | padding: 0 1; 6 | 7 | border: thick $background 80%; 8 | 9 | } 10 | 11 | #settings_label { 12 | height: 5; 13 | width: 1fr; 14 | content-align: center top; 15 | } 16 | 17 | Input.-valid { 18 | border: tall $success 60%; 19 | } 20 | 21 | Input.-valid:focus { 22 | border: tall $success; 23 | } 24 | 25 | Input { 26 | margin: 1 1; 27 | } 28 | 29 | Label { 30 | margin: 1 2; 31 | } 32 | -------------------------------------------------------------------------------- /src/words_tui/tui/text_editor.py: -------------------------------------------------------------------------------- 1 | # Copy/Pasted from https://github.com/Textualize/textual/pull/2931 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from collections import defaultdict 7 | from dataclasses import dataclass 8 | from pathlib import Path 9 | from typing import ClassVar, NamedTuple, Optional 10 | 11 | from rich.cells import get_character_cell_size 12 | from rich.style import Style 13 | from rich.text import Text 14 | from textual import events, log 15 | from textual._cells import cell_len 16 | from textual._types import Protocol 17 | from textual.binding import Binding 18 | from textual.geometry import Offset, Region, Size, Spacing, clamp 19 | from textual.message import Message 20 | from textual.reactive import Reactive, reactive 21 | from textual.scroll_view import ScrollView 22 | from textual.strip import Strip 23 | from tree_sitter import Language, Parser, Tree 24 | from tree_sitter.binding import Query 25 | 26 | TREE_SITTER_PATH = Path(__file__) / "../../../../tree-sitter/" 27 | LANGUAGES_PATH = TREE_SITTER_PATH / "textual-languages.so" 28 | 29 | # TODO - remove hardcoded python.scm highlight query file 30 | HIGHLIGHTS_PATH = TREE_SITTER_PATH / "highlights/python.scm" 31 | 32 | # TODO - temporary proof of concept approach 33 | HIGHLIGHT_STYLES = { 34 | "string": Style(color="#E6DB74"), 35 | "string.documentation": Style(color="yellow"), 36 | "comment": Style(color="#75715E"), 37 | "keyword": Style(color="#F92672"), 38 | "include": Style(color="#F92672"), 39 | "keyword.function": Style(color="#F92672"), 40 | "keyword.return": Style(color="#F92672"), 41 | "conditional": Style(color="#F92672"), 42 | "number": Style(color="#AE81FF"), 43 | "class": Style(color="#A6E22E"), 44 | "function": Style(color="#A6E22E"), 45 | "function.call": Style(color="#A6E22E"), 46 | "method": Style(color="#A6E22E"), 47 | "method.call": Style(color="#A6E22E"), 48 | # "constant": Style(color="#AE81FF"), 49 | "variable": Style(color="white"), 50 | "parameter": Style(color="cyan"), 51 | "type": Style(color="cyan"), 52 | "escape": Style(bgcolor="magenta"), 53 | } 54 | 55 | 56 | class Highlight(NamedTuple): 57 | """A range to highlight within a single line""" 58 | 59 | start_column: int | None 60 | end_column: int | None 61 | highlight_name: str | None 62 | 63 | 64 | class Selection(NamedTuple): 65 | """A range of characters within a document from a start point to the end point. 66 | The position of the cursor is always considered to be the `end` point of the selection. 67 | """ 68 | 69 | start: tuple[int, int] = (0, 0) 70 | end: tuple[int, int] = (0, 0) 71 | 72 | @classmethod 73 | def cursor(cls, position: tuple[int, int]) -> Selection: 74 | """Create a Selection with the same start and end point.""" 75 | return cls(position, position) 76 | 77 | @property 78 | def is_cursor(self) -> bool: 79 | """Return True if the selection has 0 width, i.e. it's just a cursor.""" 80 | start, end = self 81 | return start == end 82 | 83 | 84 | class Edit(Protocol): 85 | """Protocol for actions performed in the text editor that can be done and undone.""" 86 | 87 | def do(self, editor: TextEditor) -> object | None: 88 | """Do the action.""" 89 | 90 | def undo(self, editor: TextEditor) -> object | None: 91 | """Undo the action.""" 92 | 93 | 94 | class Insert(NamedTuple): 95 | """Implements the Edit protocol for inserting text at some position.""" 96 | 97 | text: str 98 | from_position: tuple[int, int] 99 | to_position: tuple[int, int] 100 | move_cursor: bool = True 101 | 102 | def do(self, editor: TextEditor) -> None: 103 | if self.text: 104 | editor._insert_text_range(self.text, self.from_position, self.to_position, self.move_cursor) 105 | 106 | def undo(self, editor: TextEditor) -> None: 107 | """Undo the action.""" 108 | 109 | 110 | @dataclass 111 | class Delete: 112 | from_position: tuple[int, int] 113 | to_position: tuple[int, int] 114 | cursor_destination: tuple[int, int] | None = None 115 | 116 | def do(self, editor: TextEditor) -> None: 117 | """Do the action.""" 118 | self.deleted_text = editor._delete_range(self.from_position, self.to_position, self.cursor_destination) 119 | return self.deleted_text 120 | 121 | def undo(self, editor: TextEditor) -> None: 122 | """Undo the action.""" 123 | 124 | def __rich_repr__(self): 125 | yield "from_position", self.from_position 126 | yield "to_position", self.to_position 127 | if hasattr(self, "deleted_text"): 128 | yield "deleted_text", self.deleted_text 129 | 130 | 131 | class TextEditor(ScrollView, can_focus=True): 132 | DEFAULT_CSS = """\ 133 | $editor-active-line-bg: white 8%; 134 | TextEditor { 135 | background: $panel; 136 | } 137 | TextEditor > .text-editor--active-line { 138 | background: $editor-active-line-bg; 139 | } 140 | TextEditor > .text-editor--active-line-gutter { 141 | color: $text; 142 | background: $editor-active-line-bg; 143 | } 144 | TextEditor > .text-editor--gutter { 145 | color: $text-muted 40%; 146 | } 147 | TextEditor > .text-editor--cursor { 148 | color: $text; 149 | background: white 80%; 150 | } 151 | 152 | TextEditor > .text-editor--selection { 153 | background: $primary; 154 | } 155 | """ 156 | 157 | COMPONENT_CLASSES: ClassVar[set[str]] = { 158 | "text-editor--active-line", 159 | "text-editor--active-line-gutter", 160 | "text-editor--gutter", 161 | "text-editor--cursor", 162 | "text-editor--selection", 163 | } 164 | 165 | BINDINGS = [ 166 | # Cursor movement 167 | Binding("up", "cursor_up", "cursor up", show=False), 168 | Binding("shift+up", "cursor_up_select", "cursor up select", show=False), 169 | Binding("down", "cursor_down", "cursor down", show=False), 170 | Binding("shift+down", "cursor_down_select", "cursor down select", show=False), 171 | Binding("left", "cursor_left", "cursor left", show=False), 172 | Binding("shift+left", "cursor_left_select", "cursor left select", show=False), 173 | Binding("ctrl+left", "cursor_left_word", "cursor left word", show=False), 174 | Binding("right", "cursor_right", "cursor right", show=False), 175 | Binding("shift+right", "cursor_right_select", "cursor right select", show=False), 176 | Binding("ctrl+right", "cursor_right_word", "cursor right word", show=False), 177 | Binding("home,ctrl+a", "cursor_line_start", "cursor line start", show=False), 178 | Binding("end,ctrl+e", "cursor_line_end", "cursor line end", show=False), 179 | Binding("backspace", "delete_left", "delete left", show=False), 180 | Binding("ctrl+w", "delete_word_left", "delete left to start of word", show=False), 181 | Binding("ctrl+d", "delete_right", "delete right", show=False), 182 | Binding("ctrl+f", "delete_word_right", "delete right to start of word", show=False), 183 | Binding("ctrl+x", "delete_line", "delete line", show=False), 184 | Binding("ctrl+u", "delete_to_start_of_line", "delete to line start", show=False), 185 | Binding("ctrl+k", "delete_to_end_of_line", "delete to line end", show=False), 186 | ] 187 | 188 | language: Reactive[str | None] = reactive(None) 189 | """The language to use for syntax highlighting (via tree-sitter).""" 190 | selection: Reactive[Selection] = reactive(Selection(), always_update=True) 191 | """The cursor position (zero-based line_index, offset).""" 192 | show_line_numbers: Reactive[bool] = reactive(True) 193 | """True to show line number gutter, otherwise False.""" 194 | _document_size: Reactive[Size] = reactive(Size(), init=False) 195 | """Tracks the width of the document. Used to update virtual size. Do not 196 | update virtual size directly.""" 197 | 198 | @dataclass 199 | class Changed(Message): 200 | """Posted when the value changes. 201 | 202 | Can be handled using `on_input_changed` in a subclass of `Input` or in a parent 203 | widget in the DOM. 204 | """ 205 | 206 | text_editor: TextEditor 207 | """The `Input` widget that was changed.""" 208 | 209 | value: str 210 | """The value that the input was changed to.""" 211 | 212 | @property 213 | def control(self) -> TextEditor: 214 | """Alias for self.input.""" 215 | return self.text_editor 216 | 217 | def __init__( 218 | self, 219 | name: str | None = None, 220 | id: str | None = None, 221 | classes: str | None = None, 222 | disabled: bool = False, 223 | ) -> None: 224 | super().__init__(name=name, id=id, classes=classes, disabled=disabled) 225 | 226 | # --- Core editor data 227 | self.document_lines: list[str] = [] 228 | """Each string in this list represents a line in the document. Includes new line characters.""" 229 | 230 | self._highlights: dict[int, list[Highlight]] = defaultdict(list) 231 | """Mapping line numbers to the set of cached highlights for that line.""" 232 | 233 | self._highlights_query: str | None = None 234 | """The string containing the tree-sitter AST query used for syntax highlighting.""" 235 | 236 | self._last_intentional_cell_width: int = 0 237 | """Tracks the last column (measured in terms of cell length, since we care here about where 238 | the cursor visually moves more than the logical characters) the user explicitly navigated to so that we can reset 239 | to it whenever possible.""" 240 | 241 | self._word_pattern = re.compile(r"(?<=\W)(?=\w)|(?<=\w)(?=\W)") 242 | """Compiled regular expression for what we consider to be a 'word'.""" 243 | 244 | self._undo_stack: list[Edit] = [] 245 | """A stack (the end of the list is the top of the stack) for tracking edits.""" 246 | 247 | self._selecting = False 248 | """True if we're currently selecting text, otherwise False.""" 249 | 250 | # --- Abstract syntax tree and related parsing machinery 251 | self._language: Language | None = None 252 | self._parser: Parser | None = None 253 | """The tree-sitter parser which extracts the syntax tree from the document.""" 254 | self._syntax_tree: Tree | None = None 255 | """The tree-sitter Tree (AST) built from the document.""" 256 | 257 | def watch_language(self, new_language: str | None) -> None: 258 | """Update the language used in AST parsing. 259 | 260 | When the language reactive string is updated, fetch the Language definition 261 | from our tree-sitter library file. If the language reactive is set to None, 262 | then the no parser is used.""" 263 | log.debug(f"updating editor language to {new_language!r}") 264 | if new_language: 265 | self._language = Language(LANGUAGES_PATH.resolve(), new_language) 266 | parser = Parser() 267 | self._parser = parser 268 | self._parser.set_language(self._language) 269 | self._syntax_tree = self._build_ast(parser) 270 | self._highlights_query = Path(HIGHLIGHTS_PATH.resolve()).read_text() 271 | 272 | log.debug(f"parser set to {self._parser}") 273 | 274 | def watch__document_size(self, size: Size) -> None: 275 | log.debug(f"document size set to {size!r} ") 276 | document_width, document_height = size 277 | self.virtual_size = Size(document_width + self.gutter_width, document_height) 278 | 279 | def _build_ast( 280 | self, 281 | parser: Parser, 282 | ) -> Tree | None: 283 | """Fully parse the document and build the abstract syntax tree for it. 284 | 285 | Returns None if there's no parser available (e.g. when no language is selected). 286 | """ 287 | if parser: 288 | return parser.parse(self._read_callable) 289 | else: 290 | return None 291 | 292 | def _read_callable(self, byte_offset: int, point: tuple[int, int]) -> str: 293 | row, column = point 294 | lines = self.document_lines 295 | row_out_of_bounds = row >= len(lines) 296 | column_out_of_bounds = not row_out_of_bounds and column > len(lines[row]) 297 | if row_out_of_bounds or column_out_of_bounds: 298 | return_value = None 299 | elif column == len(lines[row]) and row < len(lines): 300 | return_value = b"\n" 301 | else: 302 | return_value = lines[row][column].encode("utf8") 303 | # print(f"(point={point!r}) (offset={byte_offset!r}) {return_value!r}") 304 | return return_value 305 | 306 | def load_text(self, text: str) -> None: 307 | """Load text from a string into the editor.""" 308 | lines = text.splitlines(keepends=False) 309 | if not text: 310 | text = "\n" 311 | if text[-1] == "\n": 312 | lines.append("") 313 | self.load_lines(lines) 314 | 315 | def load_lines(self, lines: list[str]) -> None: 316 | """Load text from a list of lines into the editor. 317 | 318 | This will replace any previously loaded lines.""" 319 | self.document_lines = lines 320 | self._document_size = self._get_document_size(lines) 321 | if self._parser is not None: 322 | self._syntax_tree = self._build_ast(self._parser) 323 | self._prepare_highlights() 324 | 325 | log.debug(f"loaded text. parser = {self._parser} ast = {self._syntax_tree}") 326 | 327 | def clear(self) -> None: 328 | self.load_text("") 329 | 330 | # --- Methods for measuring things (e.g. virtual sizes) 331 | def _get_document_size(self, document_lines: list[str]) -> Size: 332 | """Return the virtual size of the document - the document only 333 | refers to the area in which the cursor can move. It does not, for 334 | example, include the width of the gutter.""" 335 | text_width = max(cell_len(line) for line in document_lines) 336 | height = len(document_lines) 337 | # We add one to the text width to leave a space for the cursor, since it 338 | # can rest at the end of a line where there isn't yet any character. 339 | # Similarly, the cursor can rest below the bottom line of text, where 340 | # a line doesn't currently exist. 341 | return Size(text_width + 1, height) 342 | 343 | def _refresh_size(self) -> None: 344 | self._document_size = self._get_document_size(self.document_lines) 345 | 346 | def render_line(self, widget_y: int) -> Strip: 347 | document_lines = self.document_lines 348 | 349 | document_y = round(self.scroll_y + widget_y) 350 | out_of_bounds = document_y >= len(document_lines) 351 | if out_of_bounds: 352 | return Strip.blank(self.size.width) 353 | 354 | line_string = document_lines[document_y].replace("\n", "").replace("\r", "") 355 | line_text = Text(f"{line_string} ", end="", tab_size=4) 356 | line_text.set_length(self.virtual_size.width) 357 | 358 | # Apply highlighting 359 | null_style = Style.null() 360 | if self._highlights: 361 | highlights = self._highlights[document_y] 362 | for start, end, highlight_name in highlights: 363 | node_style = HIGHLIGHT_STYLES.get(highlight_name, null_style) 364 | line_text.stylize(node_style, start, end) 365 | 366 | start, end = self.selection 367 | end_row, end_column = end 368 | 369 | selection_style = self.get_component_rich_style("text-editor--selection") 370 | 371 | # Start and end can be before or after each other, depending on the direction 372 | # you move the cursor during selecting text, but the "top" of the selection 373 | # is always before the "bottom" of the selection. 374 | selection_top = min(start, end) 375 | selection_bottom = max(start, end) 376 | selection_top_row, selection_top_column = selection_top 377 | selection_bottom_row, selection_bottom_column = selection_bottom 378 | 379 | if start != end and selection_top_row <= document_y <= selection_bottom_row: 380 | # If this row is part of the selection 381 | if document_y == selection_top_row == selection_bottom_row: 382 | # Selection within a single line 383 | line_text.stylize_before( 384 | selection_style, 385 | start=selection_top_column, 386 | end=selection_bottom_column, 387 | ) 388 | else: 389 | # Selection spanning multiple lines 390 | if document_y == selection_top_row: 391 | line_text.stylize_before( 392 | selection_style, 393 | start=selection_top_column, 394 | end=len(line_string), 395 | ) 396 | elif document_y == selection_bottom_row: 397 | line_text.stylize_before(selection_style, end=selection_bottom_column) 398 | else: 399 | line_text.stylize_before(selection_style, end=len(line_string)) 400 | 401 | # Show the cursor and the selection 402 | if end_row == document_y: 403 | cursor_style = self.get_component_rich_style("text-editor--cursor") 404 | line_text.stylize(cursor_style, end_column, end_column + 1) 405 | active_line_style = self.get_component_rich_style("text-editor--active-line") 406 | line_text.stylize_before(active_line_style) 407 | 408 | # Show the gutter 409 | if self.show_line_numbers: 410 | if end_row == document_y: 411 | gutter_style = self.get_component_rich_style("text-editor--active-line-gutter") 412 | else: 413 | gutter_style = self.get_component_rich_style("text-editor--gutter") 414 | 415 | gutter_width_no_margin = self.gutter_width - 2 416 | gutter = Text( 417 | f"{document_y + 1:>{gutter_width_no_margin}} ", 418 | style=gutter_style, 419 | end="", 420 | ) 421 | else: 422 | gutter = Text("", end="") 423 | 424 | gutter_segments = self.app.console.render(gutter) 425 | text_segments = self.app.console.render( 426 | line_text, self.app.console.options.update_width(self.virtual_size.width) 427 | ) 428 | 429 | virtual_width, virtual_height = self.virtual_size 430 | text_crop_start = int(self.scroll_x) 431 | text_crop_end = text_crop_start + virtual_width 432 | 433 | gutter_strip = Strip(gutter_segments) 434 | text_strip = Strip(text_segments).crop(text_crop_start, text_crop_end) 435 | 436 | strip = Strip.join([gutter_strip, text_strip]).simplify() 437 | 438 | return strip 439 | 440 | @property 441 | def gutter_width(self) -> int: 442 | # The longest number in the gutter plus two extra characters: `│ `. 443 | gutter_margin = 2 444 | gutter_longest_number = len(str(len(self.document_lines) + 1)) + gutter_margin if self.show_line_numbers else 0 445 | return gutter_longest_number 446 | 447 | # --- Syntax highlighting 448 | def _prepare_highlights( 449 | self, 450 | start_point: tuple[int, int] | None = None, 451 | end_point: tuple[int, int] | None = None, 452 | ) -> None: 453 | # TODO - we're ignoring get changed ranges for now. Either I'm misunderstanding 454 | # it or I've made a mistake somewhere with AST editing. 455 | 456 | highlights = self._highlights 457 | query: Query = self._language.query(self._highlights_query) 458 | 459 | log.debug(f"capturing nodes in range {start_point!r} -> {end_point!r}") 460 | 461 | captures_kwargs = {} 462 | if start_point is not None: 463 | captures_kwargs["start_point"] = start_point 464 | if end_point is not None: 465 | captures_kwargs["end_point"] = end_point 466 | 467 | captures = query.captures(self._syntax_tree.root_node, **captures_kwargs) 468 | 469 | highlight_updates: dict[int, list[Highlight]] = defaultdict(list) 470 | for capture in captures: 471 | node, highlight_name = capture 472 | node_start_row, node_start_column = node.start_point 473 | node_end_row, node_end_column = node.end_point 474 | 475 | if node_start_row == node_end_row: 476 | highlight = Highlight(node_start_column, node_end_column, highlight_name) 477 | highlight_updates[node_start_row].append(highlight) 478 | else: 479 | # Add the first line 480 | highlight_updates[node_start_row].append(Highlight(node_start_column, None, highlight_name)) 481 | # Add the middle lines - entire row of this node is highlighted 482 | for node_row in range(node_start_row + 1, node_end_row): 483 | highlight_updates[node_row].append(Highlight(0, None, highlight_name)) 484 | 485 | # Add the last line 486 | highlight_updates[node_end_row].append(Highlight(0, node_end_column, highlight_name)) 487 | 488 | for line_index, updated_highlights in highlight_updates.items(): 489 | highlights[line_index] = updated_highlights 490 | 491 | def edit(self, edit: Edit) -> object | None: 492 | log.debug(f"performing edit {edit!r}") 493 | result = edit.do(self) 494 | self._undo_stack.append(edit) 495 | 496 | # TODO: Think about this... 497 | self._undo_stack = self._undo_stack[-20:] 498 | 499 | return result 500 | 501 | def undo(self) -> None: 502 | if self._undo_stack: 503 | action = self._undo_stack.pop() 504 | action.undo(self) 505 | 506 | # --- Lower level event/key handling 507 | def _on_key(self, event: events.Key) -> None: 508 | log.debug(f"{event!r}") 509 | key = event.key 510 | if event.is_printable or key == "tab" or key == "enter": 511 | if key == "tab": 512 | insert = " " 513 | elif key == "enter": 514 | insert = "\n" 515 | else: 516 | insert = event.character 517 | event.stop() 518 | assert event.character is not None 519 | start, end = self.selection 520 | self.insert_text_range(insert, start, end) 521 | event.prevent_default() 522 | elif key == "shift+tab": 523 | self.dedent_line() 524 | event.stop() 525 | 526 | def get_target_document_location(self, offset: Offset) -> tuple[int, int]: 527 | if offset is None: 528 | return 529 | 530 | target_x = max(offset.x - self.gutter_width + int(self.scroll_x), 0) 531 | target_row = clamp(offset.y + int(self.scroll_y), 0, len(self.document_lines) - 1) 532 | target_column = self.cell_width_to_column_index(target_x, target_row) 533 | 534 | return target_row, target_column 535 | 536 | def _fix_direction(self, start: tuple[int, int], end: tuple[int, int]) -> tuple[tuple[int, int], tuple[int, int]]: 537 | """Given a range, return a new range (x, y) such that x <= y which covers the same characters.""" 538 | if start > end: 539 | return end, start 540 | return start, end 541 | 542 | def _on_mouse_down(self, event: events.MouseDown) -> None: 543 | event.stop() 544 | offset = event.get_content_offset(self) 545 | target_row, target_column = self.get_target_document_location(offset) 546 | self.selection = Selection.cursor((target_row, target_column)) 547 | log.debug(f"started selection {self.selection!r}") 548 | self._selecting = True 549 | 550 | def _on_mouse_move(self, event: events.MouseMove) -> None: 551 | event.stop() 552 | if self._selecting: 553 | offset = event.get_content_offset(self) 554 | target = self.get_target_document_location(offset) 555 | selection_start, _ = self.selection 556 | self.selection = Selection(selection_start, target) 557 | log.debug(f"selection updated {self.selection!r}") 558 | 559 | def _on_mouse_up(self, event: events.MouseUp) -> None: 560 | event.stop() 561 | self._record_last_intentional_cell_width() 562 | self._selecting = False 563 | 564 | def _on_paste(self, event: events.Paste) -> None: 565 | text = event.text 566 | if text: 567 | self.insert_text(text, self.selection) 568 | event.stop() 569 | 570 | def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: 571 | """Return the column that the cell width corresponds to on the given row.""" 572 | total_cell_offset = 0 573 | line = self.document_lines[row_index] 574 | for column_index, character in enumerate(line): 575 | total_cell_offset += cell_len(character) 576 | if total_cell_offset >= cell_width + 1: 577 | log(f"cell width {cell_width} -> column_index {column_index}") 578 | return column_index 579 | return len(line) 580 | 581 | def watch_selection(self) -> None: 582 | self.scroll_cursor_visible() 583 | 584 | # --- Cursor utilities 585 | def scroll_cursor_visible(self): 586 | # The end of the selection is always considered to be position of the cursor 587 | # ... this is a constraint we need to enforce in code. 588 | row, column = self.selection.end 589 | text = self.active_line_text[:column] 590 | column_offset = cell_len(text) 591 | self.scroll_to_region( 592 | Region(x=column_offset, y=row, width=3, height=1), 593 | spacing=Spacing(right=self.gutter_width), 594 | animate=False, 595 | force=True, 596 | ) 597 | 598 | @property 599 | def cursor_at_first_row(self) -> bool: 600 | return self.selection.end[0] == 0 601 | 602 | @property 603 | def cursor_at_last_row(self) -> bool: 604 | return self.selection.end[0] == len(self.document_lines) - 1 605 | 606 | @property 607 | def cursor_at_start_of_row(self) -> bool: 608 | return self.selection.end[1] == 0 609 | 610 | @property 611 | def cursor_at_end_of_row(self) -> bool: 612 | cursor_row, cursor_column = self.selection.end 613 | row_length = len(self.document_lines[cursor_row]) 614 | cursor_at_end = cursor_column == row_length 615 | return cursor_at_end 616 | 617 | @property 618 | def cursor_at_start_of_document(self) -> bool: 619 | return self.cursor_at_first_row and self.cursor_at_start_of_row 620 | 621 | @property 622 | def cursor_at_end_of_document(self) -> bool: 623 | """True if the cursor is at the very end of the document.""" 624 | return self.cursor_at_last_row and self.cursor_at_end_of_row 625 | 626 | def cursor_to_line_end(self, select: bool = False) -> None: 627 | """Move the cursor to the end of the line. 628 | 629 | Args: 630 | select: Select the text between the old and new cursor locations. 631 | """ 632 | 633 | start, end = self.selection 634 | cursor_row, cursor_column = end 635 | target_column = len(self.document_lines[cursor_row]) 636 | 637 | if select: 638 | self.selection = Selection(start, target_column) 639 | else: 640 | self.selection = Selection.cursor((cursor_row, target_column)) 641 | 642 | self._record_last_intentional_cell_width() 643 | 644 | def cursor_to_line_start(self, select: bool = False) -> None: 645 | """Move the cursor to the start of the line. 646 | 647 | Args: 648 | select: Select the text between the old and new cursor locations. 649 | """ 650 | start, end = self.selection 651 | cursor_row, cursor_column = end 652 | if select: 653 | self.selection = Selection(start, (cursor_row, 0)) 654 | else: 655 | self.selection = Selection.cursor((cursor_row, 0)) 656 | print(f"new selection = {self.selection}") 657 | 658 | # ------ Cursor movement actions 659 | def action_cursor_left(self) -> None: 660 | """Move the cursor one position to the left. 661 | 662 | If the cursor is at the left edge of the document, try to move it to 663 | the end of the previous line. 664 | """ 665 | target_row, target_column = self.get_cursor_left_position() 666 | self.selection = Selection.cursor((target_row, target_column)) 667 | self._record_last_intentional_cell_width() 668 | 669 | def action_cursor_left_select(self): 670 | """Move the end of the selection one position to the left. 671 | 672 | This will expand or contract the selection. 673 | """ 674 | new_cursor_position = self.get_cursor_left_position() 675 | selection_start, selection_end = self.selection 676 | self.selection = Selection(selection_start, new_cursor_position) 677 | self._record_last_intentional_cell_width() 678 | 679 | def get_cursor_left_position(self) -> tuple[int, int]: 680 | """Get the position the cursor will move to if it moves left.""" 681 | if self.cursor_at_start_of_document: 682 | return 0, 0 683 | cursor_row, cursor_column = self.selection.end 684 | length_of_row_above = len(self.document_lines[cursor_row - 1]) 685 | target_row = cursor_row if cursor_column != 0 else cursor_row - 1 686 | target_column = cursor_column - 1 if cursor_column != 0 else length_of_row_above 687 | return target_row, target_column 688 | 689 | def action_cursor_right(self) -> None: 690 | """Move the cursor one position to the right. 691 | 692 | If the cursor is at the end of a line, attempt to go to the start of the next line. 693 | """ 694 | target = self.get_cursor_right_position() 695 | self.selection = Selection.cursor(target) 696 | self._record_last_intentional_cell_width() 697 | 698 | def action_cursor_right_select(self): 699 | """Move the end of the selection one position to the right. 700 | 701 | This will expand or contract the selection. 702 | """ 703 | new_cursor_position = self.get_cursor_right_position() 704 | selection_start, selection_end = self.selection 705 | self.selection = Selection(selection_start, new_cursor_position) 706 | self._record_last_intentional_cell_width() 707 | 708 | def get_cursor_right_position(self) -> tuple[int, int]: 709 | """Get the position the cursor will move to if it moves right.""" 710 | if self.cursor_at_end_of_document: 711 | return self.selection.end 712 | cursor_row, cursor_column = self.selection.end 713 | target_row = cursor_row + 1 if self.cursor_at_end_of_row else cursor_row 714 | target_column = 0 if self.cursor_at_end_of_row else cursor_column + 1 715 | return target_row, target_column 716 | 717 | def action_cursor_down(self) -> None: 718 | """Move the cursor down one cell.""" 719 | target = self.get_cursor_down_position() 720 | self.selection = Selection.cursor(target) 721 | 722 | def action_cursor_down_select(self) -> None: 723 | """Move the cursor down one cell, selecting the range between the old and new positions.""" 724 | target = self.get_cursor_down_position() 725 | start, end = self.selection 726 | self.selection = Selection(start, target) 727 | 728 | def get_cursor_down_position(self): 729 | """Get the position the cursor will move to if it moves down.""" 730 | cursor_row, cursor_column = self.selection.end 731 | if self.cursor_at_last_row: 732 | return cursor_row, len(self.document_lines[cursor_row]) 733 | 734 | target_row = min(len(self.document_lines) - 1, cursor_row + 1) 735 | # Attempt to snap last intentional cell length 736 | target_column = self.cell_width_to_column_index(self._last_intentional_cell_width, target_row) 737 | target_column = clamp(target_column, 0, len(self.document_lines[target_row])) 738 | return target_row, target_column 739 | 740 | def action_cursor_up(self) -> None: 741 | """Move the cursor up one cell.""" 742 | target = self.get_cursor_up_position() 743 | self.selection = Selection.cursor(target) 744 | 745 | def action_cursor_up_select(self) -> None: 746 | """Move the cursor up one cell, selecting the range between the old and new positions.""" 747 | target = self.get_cursor_up_position() 748 | start, end = self.selection 749 | self.selection = Selection(start, target) 750 | 751 | def get_cursor_up_position(self) -> tuple[int, int]: 752 | if self.cursor_at_first_row: 753 | return 0, 0 754 | cursor_row, cursor_column = self.selection.end 755 | target_row = max(0, cursor_row - 1) 756 | # Attempt to snap last intentional cell length 757 | target_column = self.cell_width_to_column_index(self._last_intentional_cell_width, target_row) 758 | target_column = clamp(target_column, 0, len(self.document_lines[target_row])) 759 | return target_row, target_column 760 | 761 | def action_cursor_line_end(self) -> None: 762 | self.cursor_to_line_end() 763 | 764 | def action_cursor_line_start(self) -> None: 765 | self.cursor_to_line_start() 766 | 767 | def action_cursor_left_word(self) -> None: 768 | """Move the cursor left by a single word, skipping spaces.""" 769 | 770 | if self.cursor_at_start_of_document: 771 | return 772 | 773 | cursor_row, cursor_column = self.selection.end 774 | 775 | # Check the current line for a word boundary 776 | line = self.document_lines[cursor_row][:cursor_column] 777 | matches = list(re.finditer(self._word_pattern, line)) 778 | 779 | if matches: 780 | # If a word boundary is found, move the cursor there 781 | cursor_column = matches[-1].start() 782 | elif cursor_row > 0: 783 | # If no word boundary is found and we're not on the first line, move to the end of the previous line 784 | cursor_row -= 1 785 | cursor_column = len(self.document_lines[cursor_row]) 786 | else: 787 | # If we're already on the first line and no word boundary is found, move to the start of the line 788 | cursor_column = 0 789 | 790 | self.selection = Selection.cursor((cursor_row, cursor_column)) 791 | self._record_last_intentional_cell_width() 792 | 793 | def action_cursor_right_word(self) -> None: 794 | """Move the cursor right by a single word, skipping spaces.""" 795 | 796 | if self.cursor_at_end_of_document: 797 | return 798 | 799 | cursor_row, cursor_column = self.selection.end 800 | 801 | # Check the current line for a word boundary 802 | line = self.document_lines[cursor_row][cursor_column:] 803 | matches = list(re.finditer(self._word_pattern, line)) 804 | 805 | if matches: 806 | # If a word boundary is found, move the cursor there 807 | cursor_column += matches[0].end() 808 | elif cursor_row < len(self.document_lines) - 1: 809 | # If no word boundary is found and we're not on the last line, move to the start of the next line 810 | cursor_row += 1 811 | cursor_column = 0 812 | else: 813 | # If we're already on the last line and no word boundary is found, move to the end of the line 814 | cursor_column = len(self.document_lines[cursor_row]) 815 | 816 | self.selection = Selection.cursor((cursor_row, cursor_column)) 817 | self._record_last_intentional_cell_width() 818 | 819 | @property 820 | def active_line_text(self) -> str: 821 | # TODO - consider empty documents 822 | return self.document_lines[self.selection.end[0]] 823 | 824 | def get_column_cell_width(self, row: int, column: int) -> int: 825 | """Given a row and column index within the editor, return the cell offset 826 | of the column from the start of the row (the left edge of the editor content area). 827 | """ 828 | line = self.document_lines[row] 829 | return cell_len(line[:column]) 830 | 831 | def _record_last_intentional_cell_width(self) -> None: 832 | row, column = self.selection.end 833 | column_cell_length = self.get_column_cell_width(row, column) 834 | log(f"last intentional cell width = {column_cell_length}") 835 | self._last_intentional_cell_width = column_cell_length 836 | 837 | # --- Editor operations 838 | def insert_text(self, text: str, position: tuple[int, int], move_cursor: bool = True) -> None: 839 | self.edit(Insert(text, position, position, move_cursor)) 840 | 841 | def insert_text_range( 842 | self, 843 | text: str, 844 | from_position: tuple[int, int], 845 | to_position: tuple[int, int], 846 | move_cursor: bool = True, 847 | ): 848 | self.edit(Insert(text, from_position, to_position, move_cursor)) 849 | 850 | def _insert_text_range( 851 | self, 852 | text: str, 853 | from_position: tuple[int, int], 854 | to_position: tuple[int, int], 855 | move_cursor: bool = True, 856 | ) -> None: 857 | """Insert text at a given range and move the cursor to the end of the inserted text.""" 858 | 859 | inserted_text = text 860 | lines = self.document_lines 861 | 862 | from_row, from_column = from_position 863 | to_row, to_column = to_position 864 | 865 | if from_position > to_position: 866 | from_row, from_column, to_row, to_column = ( 867 | to_row, 868 | to_column, 869 | from_row, 870 | from_column, 871 | ) 872 | 873 | insert_lines = inserted_text.splitlines() 874 | if inserted_text.endswith("\n"): 875 | # Special case where a single newline character is inserted. 876 | insert_lines.append("") 877 | 878 | before_selection = lines[from_row][:from_column] 879 | after_selection = lines[to_row][to_column:] 880 | 881 | insert_lines[0] = before_selection + insert_lines[0] 882 | destination_column = len(insert_lines[-1]) 883 | insert_lines[-1] = insert_lines[-1] + after_selection 884 | lines[from_row : to_row + 1] = insert_lines 885 | destination_row = from_row + len(insert_lines) - 1 886 | 887 | cursor_destination = (destination_row, destination_column) 888 | 889 | start_byte = self._position_to_byte_offset(from_position) 890 | if self._syntax_tree is not None: 891 | self._syntax_tree.edit( 892 | start_byte=start_byte, 893 | old_end_byte=self._position_to_byte_offset(to_position), 894 | new_end_byte=start_byte + len(inserted_text), 895 | start_point=from_position, 896 | old_end_point=to_position, 897 | new_end_point=cursor_destination, 898 | ) 899 | self._syntax_tree = self._parser.parse(self._read_callable, self._syntax_tree) 900 | self._prepare_highlights() 901 | self._refresh_size() 902 | if move_cursor: 903 | self.selection = Selection.cursor(cursor_destination) 904 | self.post_message(self.Changed(self, "")) 905 | 906 | def _position_to_byte_offset(self, position: tuple[int, int]) -> int: 907 | """Given a document coordinate, return the byte offset of that coordinate.""" 908 | 909 | # TODO - this assumes all line endings are a single byte `\n` 910 | lines = self.document_lines 911 | row, column = position 912 | lines_above = lines[:row] 913 | bytes_lines_above = sum(len(line) + 1 for line in lines_above) 914 | bytes_this_line_left_of_cursor = len(lines[row][:column]) 915 | return bytes_lines_above + bytes_this_line_left_of_cursor 916 | 917 | def dedent_line(self) -> None: 918 | """Reduces the indentation of the current line by one level. 919 | 920 | A dedent is simply a Delete operation on some amount of whitespace 921 | which may exist at the start of a line. 922 | """ 923 | cursor_row, cursor_column = self.selection.end 924 | 925 | # Define one level of indentation as four spaces 926 | indent_level = " " * 4 927 | 928 | current_line = self.document_lines[cursor_row] 929 | 930 | # If the line is indented, reduce the indentation 931 | # TODO - if the line is less than the indent level we should just dedent as far as possible. 932 | if current_line.startswith(indent_level): 933 | self.document_lines[cursor_row] = current_line[len(indent_level) :] 934 | 935 | if cursor_column > len(current_line): 936 | self.selection = Selection.cursor((cursor_row, len(current_line))) 937 | 938 | self._refresh_size() 939 | self.refresh() 940 | 941 | def delete_range( 942 | self, 943 | from_position: tuple[int, int], 944 | to_position: tuple[int, int], 945 | cursor_destination: tuple[int, int] | None = None, 946 | ) -> str: 947 | return self.edit(Delete(from_position, to_position, cursor_destination)) 948 | 949 | def _delete_range( 950 | self, 951 | from_position: tuple[int, int], 952 | to_position: tuple[int, int], 953 | cursor_destination: tuple[int, int] | None, 954 | ) -> str: 955 | """Delete text between `from_position` and `to_position`. 956 | 957 | Returns: 958 | A string containing the deleted text. 959 | """ 960 | 961 | from_row, from_column = from_position 962 | to_row, to_column = to_position 963 | 964 | lines = self.document_lines 965 | 966 | # Ensure that from_position is before to_position 967 | if from_position > to_position: 968 | from_row, from_column, to_row, to_column = ( 969 | to_row, 970 | to_column, 971 | from_row, 972 | from_column, 973 | ) 974 | 975 | # If the range is within a single line 976 | if from_row == to_row: 977 | line = lines[from_row] 978 | deleted_text = line[from_column:to_column] 979 | lines[from_row] = line[:from_column] + line[to_column:] 980 | else: 981 | # The range spans multiple lines 982 | start_line = lines[from_row] 983 | end_line = lines[to_row] 984 | 985 | deleted_text = start_line[from_column:] + "\n" 986 | for row in range(from_row + 1, to_row): 987 | deleted_text += lines[row] + "\n" 988 | 989 | deleted_text += end_line[:to_column] 990 | if to_column == len(end_line): 991 | deleted_text += "\n" 992 | 993 | # Update the lines at the start and end of the range 994 | lines[from_row] = start_line[:from_column] + end_line[to_column:] 995 | 996 | # Delete the lines in between 997 | del lines[from_row + 1 : to_row + 1] 998 | 999 | if self._syntax_tree is not None: 1000 | start_byte = self._position_to_byte_offset(from_position) 1001 | self._syntax_tree.edit( 1002 | start_byte=start_byte, 1003 | old_end_byte=self._position_to_byte_offset(to_position), 1004 | new_end_byte=start_byte, 1005 | start_point=from_position, 1006 | old_end_point=to_position, 1007 | new_end_point=from_position, 1008 | ) 1009 | self._syntax_tree = self._parser.parse(self._read_callable, self._syntax_tree) 1010 | self._prepare_highlights() 1011 | 1012 | self._refresh_size() 1013 | if cursor_destination is not None: 1014 | self.selection = Selection.cursor(cursor_destination) 1015 | else: 1016 | # Move the cursor to the start of the deleted range 1017 | self.selection = Selection.cursor((from_row, from_column)) 1018 | 1019 | self.post_message(self.Changed(self, "")) 1020 | return deleted_text 1021 | 1022 | def action_delete_left(self) -> None: 1023 | """Deletes the character to the left of the cursor and updates the cursor position.""" 1024 | if self.cursor_at_start_of_document: 1025 | return 1026 | 1027 | lines = self.document_lines 1028 | 1029 | start, end = self.selection 1030 | end_row, end_column = end 1031 | 1032 | if self.cursor_at_start_of_row: 1033 | to_position = (end_row - 1, len(lines[end_row - 1])) 1034 | else: 1035 | to_position = (end_row, end_column - 1) 1036 | 1037 | self.edit(Delete(start, to_position)) 1038 | 1039 | def action_delete_right(self) -> None: 1040 | """Deletes the character to the right of the cursor and keeps the cursor at the same position.""" 1041 | if self.cursor_at_end_of_document: 1042 | return 1043 | 1044 | start, end = self.selection 1045 | end_row, end_column = end 1046 | 1047 | if self.cursor_at_end_of_row: 1048 | to_position = (end_row + 1, 0) 1049 | else: 1050 | to_position = (end_row, end_column + 1) 1051 | 1052 | self.edit(Delete(start, to_position)) 1053 | 1054 | def action_delete_line(self) -> None: 1055 | """Deletes the lines which intersect with the selection.""" 1056 | start, end = self.selection 1057 | start_row, start_column = start 1058 | end_row, end_column = end 1059 | 1060 | from_position = (start_row, 0) 1061 | to_position = (end_row + 1, 0) 1062 | 1063 | self.edit(Delete(from_position, to_position)) 1064 | 1065 | def action_delete_to_start_of_line(self) -> None: 1066 | """Deletes from the cursor position to the start of the line.""" 1067 | from_position = self.selection.end 1068 | cursor_row, cursor_column = from_position 1069 | to_position = (cursor_row, 0) 1070 | self.edit(Delete(from_position, to_position)) 1071 | 1072 | def action_delete_to_end_of_line(self) -> None: 1073 | """Deletes from the cursor position to the end of the line.""" 1074 | from_position = self.selection.end 1075 | cursor_row, cursor_column = from_position 1076 | to_position = (cursor_row, len(self.document_lines[cursor_row])) 1077 | self.edit(Delete(from_position, to_position)) 1078 | 1079 | def action_delete_word_left(self) -> None: 1080 | """Deletes the word to the left of the cursor and updates the cursor position.""" 1081 | if self.cursor_at_start_of_document: 1082 | return 1083 | 1084 | # If there's a non-zero selection, then "delete word left" typically only 1085 | # deletes the characters within the selection range, ignoring word boundaries. 1086 | start, end = self.selection 1087 | if start != end: 1088 | self.edit(Delete(start, end)) 1089 | 1090 | cursor_row, cursor_column = end 1091 | 1092 | # Check the current line for a word boundary 1093 | line = self.document_lines[cursor_row][:cursor_column] 1094 | matches = list(re.finditer(self._word_pattern, line)) 1095 | 1096 | if matches: 1097 | # If a word boundary is found, delete the word 1098 | from_position = (cursor_row, matches[-1].start()) 1099 | elif cursor_row > 0: 1100 | # If no word boundary is found, and we're not on the first line, delete to the end of the previous line 1101 | from_position = (cursor_row - 1, len(self.document_lines[cursor_row - 1])) 1102 | else: 1103 | # If we're already on the first line and no word boundary is found, delete to the start of the line 1104 | from_position = (cursor_row, 0) 1105 | 1106 | self.edit(Delete(from_position, self.selection.end)) 1107 | 1108 | def action_delete_word_right(self) -> None: 1109 | """Deletes the word to the right of the cursor and keeps the cursor at the same position.""" 1110 | if self.cursor_at_end_of_document: 1111 | return 1112 | 1113 | start, end = self.selection 1114 | if start != end: 1115 | self.edit(Delete(start, end)) 1116 | 1117 | cursor_row, cursor_column = end 1118 | 1119 | # Check the current line for a word boundary 1120 | line = self.document_lines[cursor_row][cursor_column:] 1121 | matches = list(re.finditer(self._word_pattern, line)) 1122 | 1123 | if matches: 1124 | # If a word boundary is found, delete the word 1125 | to_position = (cursor_row, cursor_column + matches[0].end()) 1126 | elif cursor_row < len(self.document_lines) - 1: 1127 | # If no word boundary is found, and we're not on the last line, delete to the start of the next line 1128 | to_position = (cursor_row + 1, 0) 1129 | else: 1130 | # If we're already on the last line and no word boundary is found, delete to the end of the line 1131 | to_position = (cursor_row, len(self.document_lines[cursor_row])) 1132 | 1133 | self.edit(Delete(end, to_position)) 1134 | 1135 | # --- Debugging 1136 | @dataclass 1137 | class EditorDebug: 1138 | cursor: tuple[int, int] 1139 | language: str 1140 | document_size: Size 1141 | virtual_size: Size 1142 | scroll: Offset 1143 | undo_stack: list[Edit] 1144 | tree_sexp: str 1145 | active_line_text: str 1146 | active_line_cell_len: int 1147 | highlight_cache_key_count: int 1148 | highlight_cache_total_size: int 1149 | highlight_cache_current_row_size: int 1150 | highlight_cache_current_row: list[Highlight] 1151 | 1152 | def debug_state(self) -> EditorDebug: 1153 | return self.EditorDebug( 1154 | cursor=self.selection, 1155 | language=self.language, 1156 | document_size=self._document_size, 1157 | virtual_size=self.virtual_size, 1158 | scroll=self.scroll_offset, 1159 | undo_stack=list(reversed(self._undo_stack)), 1160 | # tree_sexp=self._syntax_tree.root_node.sexp(), 1161 | tree_sexp="", 1162 | active_line_text=repr(self.active_line_text), 1163 | active_line_cell_len=cell_len(self.active_line_text), 1164 | highlight_cache_key_count=len(self._highlights), 1165 | highlight_cache_total_size=sum(len(highlights) for key, highlights in self._highlights.items()), 1166 | highlight_cache_current_row_size=len(self._highlights[self.selection.end[0]]), 1167 | highlight_cache_current_row=self._highlights[self.selection.end[0]], 1168 | ) 1169 | 1170 | 1171 | def traverse_tree(cursor): 1172 | reached_root = False 1173 | while reached_root is False: 1174 | yield cursor.node 1175 | 1176 | if cursor.goto_first_child(): 1177 | continue 1178 | 1179 | if cursor.goto_next_sibling(): 1180 | continue 1181 | 1182 | retracing = True 1183 | while retracing: 1184 | if not cursor.goto_parent(): 1185 | retracing = False 1186 | reached_root = True 1187 | 1188 | if cursor.goto_next_sibling(): 1189 | retracing = False 1190 | 1191 | 1192 | # if __name__ == "__main__": 1193 | # language = Language(LANGUAGES_PATH.resolve(), "python") 1194 | # parser = Parser() 1195 | # parser.set_language(language) 1196 | # 1197 | # CODE = """\ 1198 | # from textual.app import App 1199 | # 1200 | # 1201 | # class ScreenApp(App): 1202 | # def on_mount(self) -> None: 1203 | # self.screen.styles.background = "darkblue" 1204 | # self.screen.styles.border = ("heavy", "white") 1205 | # 1206 | # 1207 | # if __name__ == "__main__": 1208 | # app = ScreenApp() 1209 | # app.run() 1210 | # """ 1211 | # 1212 | # document_lines = CODE.splitlines(keepends=False) 1213 | # 1214 | # def read_callable(byte_offset, point): 1215 | # row, column = point 1216 | # if row >= len(document_lines) or column >= len(document_lines[row]): 1217 | # return None 1218 | # return document_lines[row][column:].encode("utf8") 1219 | # 1220 | # tree = parser.parse(bytes(CODE, "utf-8")) 1221 | # 1222 | # print(list(traverse_tree(tree.walk()))) 1223 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023-present Anže Pečar 2 | # 3 | # SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /tests/test_tui.py: -------------------------------------------------------------------------------- 1 | def test_tui(): 2 | assert True 3 | --------------------------------------------------------------------------------