tcoda 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tcoda-0.8.0/.editorconfig +14 -0
- tcoda-0.8.0/.gitattributes +5 -0
- tcoda-0.8.0/.github/workflows/build.yml +151 -0
- tcoda-0.8.0/.gitignore +20 -0
- tcoda-0.8.0/.markdownlint-cli2.jsonc +8 -0
- tcoda-0.8.0/.python-version +1 -0
- tcoda-0.8.0/CLAUDE.md +21 -0
- tcoda-0.8.0/CONTRIBUTING.md +3 -0
- tcoda-0.8.0/LICENSE +5 -0
- tcoda-0.8.0/LICENSES/0BSD.txt +5 -0
- tcoda-0.8.0/PKG-INFO +72 -0
- tcoda-0.8.0/README.md +60 -0
- tcoda-0.8.0/REUSE.toml +14 -0
- tcoda-0.8.0/docs/DESIGN.md +112 -0
- tcoda-0.8.0/docs/chords.md +83 -0
- tcoda-0.8.0/docs/demo.md +33 -0
- tcoda-0.8.0/docs/dialogs/AboutDialog.md +36 -0
- tcoda-0.8.0/docs/dialogs/ConfirmDialog.md +33 -0
- tcoda-0.8.0/docs/dialogs/Dialog.md +41 -0
- tcoda-0.8.0/docs/dialogs/SettingsDialog.md +33 -0
- tcoda-0.8.0/docs/settings.md +49 -0
- tcoda-0.8.0/docs/terminal_title.md +36 -0
- tcoda-0.8.0/docs/widgets/ChordViewer.md +78 -0
- tcoda-0.8.0/docs/widgets/Countdown.md +27 -0
- tcoda-0.8.0/docs/widgets/DataTable.md +93 -0
- tcoda-0.8.0/docs/widgets/Footer.md +63 -0
- tcoda-0.8.0/docs/widgets/Header.md +23 -0
- tcoda-0.8.0/docs/widgets/LogViewer.md +70 -0
- tcoda-0.8.0/docs/widgets/MenuBar.md +62 -0
- tcoda-0.8.0/docs/widgets/Resizable.md +40 -0
- tcoda-0.8.0/pyproject.toml +38 -0
- tcoda-0.8.0/src/tcoda/__init__.py +15 -0
- tcoda-0.8.0/src/tcoda/__main__.py +10 -0
- tcoda-0.8.0/src/tcoda/_text.py +43 -0
- tcoda-0.8.0/src/tcoda/chords.py +249 -0
- tcoda-0.8.0/src/tcoda/demo/__init__.py +15 -0
- tcoda-0.8.0/src/tcoda/demo/_app.py +357 -0
- tcoda-0.8.0/src/tcoda/demo/_browser.py +176 -0
- tcoda-0.8.0/src/tcoda/demo/_filter.py +64 -0
- tcoda-0.8.0/src/tcoda/demo/_settings.py +57 -0
- tcoda-0.8.0/src/tcoda/demo/about.txt +21 -0
- tcoda-0.8.0/src/tcoda/dialogs/__init__.py +13 -0
- tcoda-0.8.0/src/tcoda/dialogs/_about.py +85 -0
- tcoda-0.8.0/src/tcoda/dialogs/_confirm.py +84 -0
- tcoda-0.8.0/src/tcoda/dialogs/_dialog.py +62 -0
- tcoda-0.8.0/src/tcoda/dialogs/_settings.py +353 -0
- tcoda-0.8.0/src/tcoda/dialogs/about_default.txt +9 -0
- tcoda-0.8.0/src/tcoda/log_sink.py +81 -0
- tcoda-0.8.0/src/tcoda/paths.py +51 -0
- tcoda-0.8.0/src/tcoda/py.typed +0 -0
- tcoda-0.8.0/src/tcoda/settings.py +285 -0
- tcoda-0.8.0/src/tcoda/terminal_title.py +88 -0
- tcoda-0.8.0/src/tcoda/version.py +24 -0
- tcoda-0.8.0/src/tcoda/widgets/__init__.py +65 -0
- tcoda-0.8.0/src/tcoda/widgets/_about_item.py +46 -0
- tcoda-0.8.0/src/tcoda/widgets/_chord_viewer.py +438 -0
- tcoda-0.8.0/src/tcoda/widgets/_countdown.py +99 -0
- tcoda-0.8.0/src/tcoda/widgets/_datatable.py +418 -0
- tcoda-0.8.0/src/tcoda/widgets/_dropdown.py +250 -0
- tcoda-0.8.0/src/tcoda/widgets/_footer.py +562 -0
- tcoda-0.8.0/src/tcoda/widgets/_header.py +169 -0
- tcoda-0.8.0/src/tcoda/widgets/_layers.py +32 -0
- tcoda-0.8.0/src/tcoda/widgets/_log_viewer.py +370 -0
- tcoda-0.8.0/src/tcoda/widgets/_menubar.py +236 -0
- tcoda-0.8.0/src/tcoda/widgets/_menuitem.py +244 -0
- tcoda-0.8.0/src/tcoda/widgets/_multi_select.py +303 -0
- tcoda-0.8.0/src/tcoda/widgets/_resizable.py +118 -0
- tcoda-0.8.0/src/tcoda/widgets/_settings_item.py +37 -0
- tcoda-0.8.0/tests/conftest.py +39 -0
- tcoda-0.8.0/tests/test_about.py +163 -0
- tcoda-0.8.0/tests/test_chord_viewer.py +236 -0
- tcoda-0.8.0/tests/test_chords.py +327 -0
- tcoda-0.8.0/tests/test_countdown.py +125 -0
- tcoda-0.8.0/tests/test_datatable.py +598 -0
- tcoda-0.8.0/tests/test_demo.py +194 -0
- tcoda-0.8.0/tests/test_footer.py +502 -0
- tcoda-0.8.0/tests/test_header.py +165 -0
- tcoda-0.8.0/tests/test_log_sink.py +162 -0
- tcoda-0.8.0/tests/test_log_viewer.py +632 -0
- tcoda-0.8.0/tests/test_menubar.py +643 -0
- tcoda-0.8.0/tests/test_multi_select.py +248 -0
- tcoda-0.8.0/tests/test_resizable.py +249 -0
- tcoda-0.8.0/tests/test_settings.py +277 -0
- tcoda-0.8.0/tests/test_settings_dialog.py +355 -0
- tcoda-0.8.0/tests/test_terminal_title.py +87 -0
- tcoda-0.8.0/uv.lock +210 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: 0BSD
|
|
4
|
+
|
|
5
|
+
root = true
|
|
6
|
+
|
|
7
|
+
[*]
|
|
8
|
+
end_of_line = lf
|
|
9
|
+
insert_final_newline = true
|
|
10
|
+
charset = utf-8
|
|
11
|
+
trim_trailing_whitespace = true
|
|
12
|
+
|
|
13
|
+
[*.md]
|
|
14
|
+
trim_trailing_whitespace = false
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: 0BSD
|
|
4
|
+
|
|
5
|
+
name: Build
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: ["**"]
|
|
9
|
+
tags: ["v[0-9]*"]
|
|
10
|
+
pull_request:
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
#-----------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
Lint:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- name: Checkout repository
|
|
22
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
23
|
+
with:
|
|
24
|
+
persist-credentials: false
|
|
25
|
+
|
|
26
|
+
- name: Install uv
|
|
27
|
+
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
28
|
+
|
|
29
|
+
# keep aligned with [tool.ruff] in pyproject.toml
|
|
30
|
+
- name: Check formatting
|
|
31
|
+
run: uvx --from 'ruff~=0.16.0' ruff format --check .
|
|
32
|
+
|
|
33
|
+
- name: Lint
|
|
34
|
+
run: uvx --from 'ruff~=0.16.0' ruff check .
|
|
35
|
+
|
|
36
|
+
- name: Check REUSE compliance
|
|
37
|
+
run: uvx reuse lint
|
|
38
|
+
|
|
39
|
+
#-----------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
Test:
|
|
42
|
+
strategy:
|
|
43
|
+
fail-fast: false
|
|
44
|
+
matrix:
|
|
45
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
46
|
+
python: ["3.12"]
|
|
47
|
+
include:
|
|
48
|
+
- os: ubuntu-latest
|
|
49
|
+
python: "3.14"
|
|
50
|
+
runs-on: ${{ matrix.os }}
|
|
51
|
+
steps:
|
|
52
|
+
# full history for hatch-vcs
|
|
53
|
+
- name: Checkout repository
|
|
54
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
55
|
+
with:
|
|
56
|
+
fetch-depth: 0
|
|
57
|
+
persist-credentials: false
|
|
58
|
+
|
|
59
|
+
- name: Install uv
|
|
60
|
+
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
61
|
+
with:
|
|
62
|
+
python-version: ${{ matrix.python }}
|
|
63
|
+
|
|
64
|
+
- name: Run tests
|
|
65
|
+
run: uv run --locked pytest
|
|
66
|
+
|
|
67
|
+
#-----------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
Build:
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
steps:
|
|
72
|
+
- name: Checkout repository
|
|
73
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
74
|
+
with:
|
|
75
|
+
fetch-depth: 0
|
|
76
|
+
persist-credentials: false
|
|
77
|
+
|
|
78
|
+
- name: Install uv
|
|
79
|
+
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
80
|
+
|
|
81
|
+
- name: Build project
|
|
82
|
+
run: uv build --no-sources
|
|
83
|
+
|
|
84
|
+
- name: Smoke-test wheel
|
|
85
|
+
run: |
|
|
86
|
+
uv run --isolated --no-project --with dist/*.whl python - <<'EOF'
|
|
87
|
+
import os
|
|
88
|
+
import tcoda
|
|
89
|
+
print(tcoda.__version__)
|
|
90
|
+
if os.environ["GITHUB_REF_TYPE"] == "tag":
|
|
91
|
+
expected = os.environ["GITHUB_REF_NAME"].removeprefix("v")
|
|
92
|
+
assert tcoda.__version__ == expected, (tcoda.__version__, expected)
|
|
93
|
+
EOF
|
|
94
|
+
|
|
95
|
+
- name: Upload artifacts
|
|
96
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
97
|
+
with:
|
|
98
|
+
name: dist
|
|
99
|
+
path: dist/
|
|
100
|
+
|
|
101
|
+
#-----------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
Release:
|
|
104
|
+
runs-on: ubuntu-latest
|
|
105
|
+
permissions:
|
|
106
|
+
contents: write
|
|
107
|
+
needs:
|
|
108
|
+
- Lint
|
|
109
|
+
- Test
|
|
110
|
+
- Build
|
|
111
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
112
|
+
steps:
|
|
113
|
+
- name: Download artifacts
|
|
114
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
115
|
+
with:
|
|
116
|
+
name: dist
|
|
117
|
+
path: dist/
|
|
118
|
+
|
|
119
|
+
- name: Create draft release
|
|
120
|
+
env:
|
|
121
|
+
GH_TOKEN: ${{ github.token }}
|
|
122
|
+
run: >-
|
|
123
|
+
gh release create "$GITHUB_REF_NAME" dist/*
|
|
124
|
+
--repo "$GITHUB_REPOSITORY"
|
|
125
|
+
--draft
|
|
126
|
+
--verify-tag
|
|
127
|
+
--generate-notes
|
|
128
|
+
|
|
129
|
+
#-----------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
PyPI:
|
|
132
|
+
runs-on: ubuntu-latest
|
|
133
|
+
permissions:
|
|
134
|
+
id-token: write
|
|
135
|
+
needs:
|
|
136
|
+
- Build
|
|
137
|
+
- Release
|
|
138
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
139
|
+
environment:
|
|
140
|
+
name: pypi
|
|
141
|
+
url: https://pypi.org/p/tcoda
|
|
142
|
+
|
|
143
|
+
steps:
|
|
144
|
+
- name: Download artifacts
|
|
145
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
146
|
+
with:
|
|
147
|
+
name: dist
|
|
148
|
+
path: dist/
|
|
149
|
+
|
|
150
|
+
- name: Publish to PyPI
|
|
151
|
+
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
tcoda-0.8.0/.gitignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: 0BSD
|
|
4
|
+
|
|
5
|
+
# Python-generated files
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[oc]
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
wheels/
|
|
11
|
+
*.egg-info
|
|
12
|
+
src/tcoda/version.py
|
|
13
|
+
|
|
14
|
+
# Virtual environments
|
|
15
|
+
.venv
|
|
16
|
+
|
|
17
|
+
# Tooling caches
|
|
18
|
+
.mypy_cache
|
|
19
|
+
.pytest_cache
|
|
20
|
+
.ruff_cache
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
tcoda-0.8.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# tcoda
|
|
2
|
+
|
|
3
|
+
**Read `docs/DESIGN.md` first.** It carries the conventions shared across every
|
|
4
|
+
widget and dialog, and the reasoning behind them.
|
|
5
|
+
|
|
6
|
+
## Instructions
|
|
7
|
+
|
|
8
|
+
This is a Textual-based library using Python 3.12. Use the `.venv` to call
|
|
9
|
+
`python`. Use `uv` for package management.
|
|
10
|
+
|
|
11
|
+
The codebase is ruff-formatted, `ruff` is available globally on this system.
|
|
12
|
+
|
|
13
|
+
The project is REUSE-compliant (`reuse` is available globally). New files
|
|
14
|
+
start with the SPDX header that `reuse annotate` writes; files that can't
|
|
15
|
+
carry a comment get covered in `REUSE.toml`. `reuse lint` must pass.
|
|
16
|
+
|
|
17
|
+
## Documentation
|
|
18
|
+
|
|
19
|
+
Every public widget, dialog and module has a doc under `docs/`. When changing
|
|
20
|
+
public behaviour, update that doc; when adding one, add a doc and a row to the
|
|
21
|
+
README table.
|
tcoda-0.8.0/LICENSE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Copyright (C) 2026 by René de Hesselle
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
|
4
|
+
|
|
5
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Copyright (C) 2026 by René de Hesselle
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
|
4
|
+
|
|
5
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
tcoda-0.8.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tcoda
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Textual coda: widgets, dialogs and helpers
|
|
5
|
+
Author-email: René de Hesselle <dehesselle@web.de>
|
|
6
|
+
License-Expression: 0BSD
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: platformdirs>=4.11.0
|
|
10
|
+
Requires-Dist: textual<9,>=8.2.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# tcoda — Textual coda
|
|
14
|
+
|
|
15
|
+
> _A coda is a final part that brings something to a close, completing what came before it._
|
|
16
|
+
|
|
17
|
+
tcoda is a widget library to extend Textual with things I regularly use in my projects.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
uv add tcoda # or: pip install tcoda
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Demo
|
|
26
|
+
|
|
27
|
+
`python -m tcoda` runs a demo app that uses every widget and dialog, the way
|
|
28
|
+
`python -m textual` runs Textual's own. See [docs/demo.md](docs/demo.md) for
|
|
29
|
+
where each component appears in it.
|
|
30
|
+
|
|
31
|
+
## Widgets — `tcoda.widgets`
|
|
32
|
+
|
|
33
|
+
| Widget | Description |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| [Header](docs/widgets/Header.md) | One-line header with clickable left, middle and right areas |
|
|
36
|
+
| [Footer](docs/widgets/Footer.md) | Key bar with pinned keys, sorting, and a status corner that holds widgets |
|
|
37
|
+
| [Countdown](docs/widgets/Countdown.md) | Seconds counter with a spinner for work in flight |
|
|
38
|
+
| [MenuBar](docs/widgets/MenuBar.md) | Midnight-Commander style pulldown menu bar, declared inline |
|
|
39
|
+
| [LogViewer](docs/widgets/LogViewer.md) | Slide-down panel showing the app's `logging` output, filterable by level and source |
|
|
40
|
+
| [ChordViewer](docs/widgets/ChordViewer.md) | which-key style panel listing what can follow a pending chord |
|
|
41
|
+
| [DataTable](docs/widgets/DataTable.md) | Textual's `DataTable` plus clickable sorting and per-cell tooltips |
|
|
42
|
+
| [Resizable](docs/widgets/Resizable.md) | Mixin that makes a pane resizable by dragging its border |
|
|
43
|
+
|
|
44
|
+
## Dialogs — `tcoda.dialogs`
|
|
45
|
+
|
|
46
|
+
| Dialog | Description |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| [Dialog](docs/dialogs/Dialog.md) | Shared chrome for centered modal dialogs |
|
|
49
|
+
| [ConfirmDialog](docs/dialogs/ConfirmDialog.md) | Generic yes/no question |
|
|
50
|
+
| [AboutDialog](docs/dialogs/AboutDialog.md) | About box read from a text file, with `AboutMenuItem` |
|
|
51
|
+
| [SettingsDialog](docs/dialogs/SettingsDialog.md) | Editor for every declared setting, with `SettingsMenuItem` |
|
|
52
|
+
|
|
53
|
+
## Modules
|
|
54
|
+
|
|
55
|
+
| Module | Description |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| [tcoda.chords](docs/chords.md) | VSCode-style multi-key bindings (`ctrl+k ctrl+s`) via an App mixin |
|
|
58
|
+
| [tcoda.terminal_title](docs/terminal_title.md) | Sets the terminal's window title and restores it on exit, via an App mixin |
|
|
59
|
+
| [tcoda.settings](docs/settings.md) | Self-registering typed settings, persisted only when overridden |
|
|
60
|
+
| [tcoda.log_sink](docs/widgets/LogViewer.md#the-sink-tcodalog_sink) | In-memory `logging` handler behind `LogViewer` |
|
|
61
|
+
| [tcoda.demo](docs/demo.md) | The demo app behind `python -m tcoda` |
|
|
62
|
+
|
|
63
|
+
The conventions every component follows, and why, are in
|
|
64
|
+
[docs/DESIGN.md](docs/DESIGN.md).
|
|
65
|
+
|
|
66
|
+
## AI disclosure
|
|
67
|
+
|
|
68
|
+
Substantial parts of this code were generated with Sonnet 5.x/Opus 5.x. Generated code was tested, but not every line was reviewed in detail.
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
[0BSD](LICENSES/0BSD.txt)
|
tcoda-0.8.0/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# tcoda — Textual coda
|
|
2
|
+
|
|
3
|
+
> _A coda is a final part that brings something to a close, completing what came before it._
|
|
4
|
+
|
|
5
|
+
tcoda is a widget library to extend Textual with things I regularly use in my projects.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
uv add tcoda # or: pip install tcoda
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Demo
|
|
14
|
+
|
|
15
|
+
`python -m tcoda` runs a demo app that uses every widget and dialog, the way
|
|
16
|
+
`python -m textual` runs Textual's own. See [docs/demo.md](docs/demo.md) for
|
|
17
|
+
where each component appears in it.
|
|
18
|
+
|
|
19
|
+
## Widgets — `tcoda.widgets`
|
|
20
|
+
|
|
21
|
+
| Widget | Description |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| [Header](docs/widgets/Header.md) | One-line header with clickable left, middle and right areas |
|
|
24
|
+
| [Footer](docs/widgets/Footer.md) | Key bar with pinned keys, sorting, and a status corner that holds widgets |
|
|
25
|
+
| [Countdown](docs/widgets/Countdown.md) | Seconds counter with a spinner for work in flight |
|
|
26
|
+
| [MenuBar](docs/widgets/MenuBar.md) | Midnight-Commander style pulldown menu bar, declared inline |
|
|
27
|
+
| [LogViewer](docs/widgets/LogViewer.md) | Slide-down panel showing the app's `logging` output, filterable by level and source |
|
|
28
|
+
| [ChordViewer](docs/widgets/ChordViewer.md) | which-key style panel listing what can follow a pending chord |
|
|
29
|
+
| [DataTable](docs/widgets/DataTable.md) | Textual's `DataTable` plus clickable sorting and per-cell tooltips |
|
|
30
|
+
| [Resizable](docs/widgets/Resizable.md) | Mixin that makes a pane resizable by dragging its border |
|
|
31
|
+
|
|
32
|
+
## Dialogs — `tcoda.dialogs`
|
|
33
|
+
|
|
34
|
+
| Dialog | Description |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| [Dialog](docs/dialogs/Dialog.md) | Shared chrome for centered modal dialogs |
|
|
37
|
+
| [ConfirmDialog](docs/dialogs/ConfirmDialog.md) | Generic yes/no question |
|
|
38
|
+
| [AboutDialog](docs/dialogs/AboutDialog.md) | About box read from a text file, with `AboutMenuItem` |
|
|
39
|
+
| [SettingsDialog](docs/dialogs/SettingsDialog.md) | Editor for every declared setting, with `SettingsMenuItem` |
|
|
40
|
+
|
|
41
|
+
## Modules
|
|
42
|
+
|
|
43
|
+
| Module | Description |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| [tcoda.chords](docs/chords.md) | VSCode-style multi-key bindings (`ctrl+k ctrl+s`) via an App mixin |
|
|
46
|
+
| [tcoda.terminal_title](docs/terminal_title.md) | Sets the terminal's window title and restores it on exit, via an App mixin |
|
|
47
|
+
| [tcoda.settings](docs/settings.md) | Self-registering typed settings, persisted only when overridden |
|
|
48
|
+
| [tcoda.log_sink](docs/widgets/LogViewer.md#the-sink-tcodalog_sink) | In-memory `logging` handler behind `LogViewer` |
|
|
49
|
+
| [tcoda.demo](docs/demo.md) | The demo app behind `python -m tcoda` |
|
|
50
|
+
|
|
51
|
+
The conventions every component follows, and why, are in
|
|
52
|
+
[docs/DESIGN.md](docs/DESIGN.md).
|
|
53
|
+
|
|
54
|
+
## AI disclosure
|
|
55
|
+
|
|
56
|
+
Substantial parts of this code were generated with Sonnet 5.x/Opus 5.x. Generated code was tested, but not every line was reviewed in detail.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
[0BSD](LICENSES/0BSD.txt)
|
tcoda-0.8.0/REUSE.toml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
|
|
3
|
+
# Files that can't carry an SPDX comment, or shouldn't: docs stay clean when
|
|
4
|
+
# rendered, the about boxes are shown verbatim, uv.lock is regenerated.
|
|
5
|
+
[[annotations]]
|
|
6
|
+
path = [
|
|
7
|
+
"**.md",
|
|
8
|
+
"src/tcoda/**.txt",
|
|
9
|
+
"src/tcoda/py.typed",
|
|
10
|
+
"uv.lock",
|
|
11
|
+
".python-version",
|
|
12
|
+
]
|
|
13
|
+
SPDX-FileCopyrightText = "2026 René de Hesselle <dehesselle@web.de>"
|
|
14
|
+
SPDX-License-Identifier = "0BSD"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# tcoda — design
|
|
2
|
+
|
|
3
|
+
This document holds the decisions that span more than one widget, dialog or
|
|
4
|
+
module. Reasoning specific to one component lives in that component's own doc,
|
|
5
|
+
next to what it explains — see the tables in the [README](../README.md).
|
|
6
|
+
|
|
7
|
+
## Why tcoda exists
|
|
8
|
+
|
|
9
|
+
The widgets here are meant to be shared between Textual apps, so a fix is made
|
|
10
|
+
once rather than in every app. Several docs open by naming the workarounds the
|
|
11
|
+
widget replaces.
|
|
12
|
+
|
|
13
|
+
The name is short for "Textual coda": what comes after the widgets Textual
|
|
14
|
+
itself provides.
|
|
15
|
+
|
|
16
|
+
## Styling is inline `DEFAULT_CSS`
|
|
17
|
+
|
|
18
|
+
All styling is inline `DEFAULT_CSS` — a library cannot rely on a consumer's
|
|
19
|
+
`CSS_PATH`. Textual scopes it to the declaring widget's subtree, and an app's
|
|
20
|
+
own stylesheet outranks it regardless of specificity, so anything here can be
|
|
21
|
+
overridden.
|
|
22
|
+
|
|
23
|
+
Where a choice is purely cosmetic and apps might reasonably differ, it is
|
|
24
|
+
exposed as a class attribute to override in a subclass rather than as a
|
|
25
|
+
constructor argument (e.g. `SettingsDialog.INSTANCE_GLYPH`,
|
|
26
|
+
`Countdown.SPINNER`). Where one consistent look across apps is the point, it
|
|
27
|
+
is not configurable at all (e.g. the `DataTable` sort glyphs).
|
|
28
|
+
|
|
29
|
+
## Naming: dialog, screen and overlays
|
|
30
|
+
|
|
31
|
+
Modal classes are named for what they are, not for the base class they
|
|
32
|
+
extend. A **`Dialog`** is a centered box that asks something and dismisses
|
|
33
|
+
with a result — `AboutDialog`, `ConfirmDialog`, `SettingsDialog`, and
|
|
34
|
+
whatever a consuming app adds. `MenuBarOverlay` and `MenuDropdown` are *not*
|
|
35
|
+
dialogs: they are transparent, render no chrome of their own, and exist only
|
|
36
|
+
to capture input while a menu is in use. **`Screen`** stays reserved for a
|
|
37
|
+
host app's own screens, such as its main one.
|
|
38
|
+
|
|
39
|
+
Textual has no `Dialog` of its own — its vocabulary stops at `Screen` and
|
|
40
|
+
`ModalScreen` — but it does not claim the term either, and everyone who has
|
|
41
|
+
met Qt, GTK or wxWidgets already knows what a dialog is.
|
|
42
|
+
|
|
43
|
+
The shared chrome is [`Dialog`](dialogs/Dialog.md).
|
|
44
|
+
|
|
45
|
+
## Widgets that own a key bind it on their screen
|
|
46
|
+
|
|
47
|
+
A widget with a key of its own ([`MenuBar`](widgets/MenuBar.md)'s F9,
|
|
48
|
+
[`LogViewer`](widgets/LogViewer.md)'s F1) installs the binding on whichever
|
|
49
|
+
screen it is mounted on. Textual only consults the focus chain plus the screen
|
|
50
|
+
and the app when resolving a key, so a widget that is never focused cannot own
|
|
51
|
+
one.
|
|
52
|
+
|
|
53
|
+
- The action is exported as a constant (`OPEN_ACTION`, `TOGGLE_ACTION`) so
|
|
54
|
+
something other than the key can reach it with `run_action(...)`, and so a
|
|
55
|
+
[`Footer`](widgets/Footer.md) pin keeps working when an app rebinds the key.
|
|
56
|
+
- `key=` changes the key; `key=None` installs nothing, leaving the app to bind
|
|
57
|
+
it.
|
|
58
|
+
- `show_key=False` keeps the binding out of the footer altogether, but *where*
|
|
59
|
+
it appears is never the widget's business — that is the footer's.
|
|
60
|
+
|
|
61
|
+
## Keys are shown through `App.get_key_display`
|
|
62
|
+
|
|
63
|
+
No widget formats a key itself. The [`Footer`](widgets/Footer.md), the
|
|
64
|
+
[`ChordViewer`](widgets/ChordViewer.md), the [chord](chords.md) status text
|
|
65
|
+
and a [`MenuItem`](widgets/MenuBar.md)'s shortcut all take a key in `Binding`
|
|
66
|
+
syntax and ask the app how to show it. Textual's answer is caret notation:
|
|
67
|
+
`^q`, `^k ^s`, lowercase `f5`, `esc`.
|
|
68
|
+
|
|
69
|
+
So the style is the app's choice, made once. An app that prefers `Ctrl+Q`
|
|
70
|
+
overrides `get_key_display` and every one of these follows, along with
|
|
71
|
+
Textual's own footer and key panel. A widget that accepted
|
|
72
|
+
pre-formatted text instead would be the one place that does not.
|
|
73
|
+
|
|
74
|
+
## Hide with `display`, never with an empty render
|
|
75
|
+
|
|
76
|
+
A widget that has nothing to show is hidden with `display = False`, not by
|
|
77
|
+
rendering an empty string. An empty render would mean "nothing to say", which
|
|
78
|
+
is not the same thing as a genuine zero, and a widget that conflates the two
|
|
79
|
+
cannot express either. It also matters structurally: the
|
|
80
|
+
[`Footer`](widgets/Footer.md)'s dividers follow `display`, so an empty-but-
|
|
81
|
+
displayed widget leaves a stray divider behind.
|
|
82
|
+
[`Countdown`](widgets/Countdown.md) renders `0` at zero for this reason.
|
|
83
|
+
|
|
84
|
+
## Mixins go first and have no `__init__`
|
|
85
|
+
|
|
86
|
+
Behaviour added to someone else's class ([`Resizable`](widgets/Resizable.md)
|
|
87
|
+
onto a widget, [`ChordMixin`](chords.md) onto an app) is a mixin, listed
|
|
88
|
+
*ahead* of the class it extends so its handlers run first in the MRO.
|
|
89
|
+
|
|
90
|
+
A mixin defines no `__init__`: one would have to cooperate with the signature
|
|
91
|
+
of every class it is mixed into. State it needs starts life as a class-level
|
|
92
|
+
default instead.
|
|
93
|
+
|
|
94
|
+
## `import tcoda` imports nothing
|
|
95
|
+
|
|
96
|
+
`tcoda/__init__.py` is deliberately empty of imports: pulling in `tcoda` must not
|
|
97
|
+
drag Textual (or a config file read) into a module that only wanted the
|
|
98
|
+
version. Consumers import from `tcoda.widgets`, `tcoda.dialogs`, `tcoda.settings`,
|
|
99
|
+
`tcoda.chords`, `tcoda.terminal_title` or `tcoda.log_sink`.
|
|
100
|
+
|
|
101
|
+
The same rule applies between subpackages: `tcoda.widgets` stays importable
|
|
102
|
+
without the settings machinery, so `SettingsMenuItem` imports
|
|
103
|
+
`SettingsDialog` lazily, when it is chosen.
|
|
104
|
+
|
|
105
|
+
## Persisted state is an optimisation, never a dependency
|
|
106
|
+
|
|
107
|
+
Every store built on `tcoda.paths` (the settings file, and anything an app keeps
|
|
108
|
+
in its per-user config/cache/data directories) is an optimisation: an absent,
|
|
109
|
+
unreadable or corrupt file means "no data", not a crash on startup. That is
|
|
110
|
+
why `read_json` degrades to a default instead of raising, and why writes go
|
|
111
|
+
through a temp file (`write_json_atomic`) so a crash or a concurrent read
|
|
112
|
+
never sees half a file.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Chords
|
|
2
|
+
|
|
3
|
+
VSCode-style bindings of several keystrokes in a row, declared in the ordinary
|
|
4
|
+
`BINDINGS` with the steps separated by a space. The mixin goes on the app,
|
|
5
|
+
because only the app sees a key before the focused widget does.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from tcoda.chords import ChordMixin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MyApp(ChordMixin, App[None]):
|
|
12
|
+
CHORD_TIMEOUT = 2.0 # the default; None waits forever
|
|
13
|
+
|
|
14
|
+
BINDINGS = [
|
|
15
|
+
Binding("ctrl+k ctrl+s", "save_all", "Save all", id="save-all"),
|
|
16
|
+
]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **It has to live on the App.** `App.on_event` is the one funnel every key
|
|
20
|
+
press passes through before priority bindings are checked and before the
|
|
21
|
+
focused widget sees the key. Anything later is too late — an `Input` would
|
|
22
|
+
already have acted on its own `ctrl+k`.
|
|
23
|
+
- **Everything else about a binding still works**: `check_action`, `show`,
|
|
24
|
+
`id`, keymaps (`set_keymap({"save-all": "ctrl+k ctrl+w"})`), and any node in
|
|
25
|
+
the focus chain can declare one. Textual stores a chord as one key that no
|
|
26
|
+
single press can match, so it already lists them — the mixin only adds the
|
|
27
|
+
matching.
|
|
28
|
+
- **A chord's first step beats a single-key binding on the same key**, however
|
|
29
|
+
near the focus that binding is: while `ctrl+k ctrl+s` is active, `ctrl+k`
|
|
30
|
+
starts it even in an `Input`, whose own `ctrl+k` deletes to the end of the
|
|
31
|
+
line. The exception is a key the focused widget would *type*, so `g g` does
|
|
32
|
+
not stop anyone typing a g.
|
|
33
|
+
- **A key that continues no chord abandons it and is swallowed**, `escape`
|
|
34
|
+
included, rather than being typed halfway through what was meant as a
|
|
35
|
+
command.
|
|
36
|
+
- **`CHORD_TIMEOUT` drops the steps typed so far**; it does not replay them to
|
|
37
|
+
the focused widget.
|
|
38
|
+
- **The [footer](widgets/Footer.md) shows a pending chord** in its status
|
|
39
|
+
corner, beside the app's own `status.update(...)` text rather than in place
|
|
40
|
+
of it, and displays chord keys step by step (`^k ^s`). Clicking one runs it.
|
|
41
|
+
The steps typed so far are also available as the app's `pending_chord`
|
|
42
|
+
reactive.
|
|
43
|
+
- **A [`ChordViewer`](widgets/ChordViewer.md) lists what can come next**, in
|
|
44
|
+
the style of which-key.nvim, if the user hesitates partway through a chord.
|
|
45
|
+
|
|
46
|
+
## Naming groups
|
|
47
|
+
|
|
48
|
+
A prefix that leads to further chords can be given a name for the
|
|
49
|
+
[`ChordViewer`](widgets/ChordViewer.md) to show, in a class variable next to
|
|
50
|
+
`BINDINGS`:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
class MyApp(ChordMixin, App[None]):
|
|
54
|
+
BINDINGS = [
|
|
55
|
+
Binding("ctrl+k g a", "stage", "Stage"),
|
|
56
|
+
Binding("ctrl+k g c", "commit", "Commit"),
|
|
57
|
+
]
|
|
58
|
+
CHORD_GROUPS = {"ctrl+k": "Keyboard", "ctrl+k g": "Git"}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **Only the names live there; the chords stay in `BINDINGS`.** That is what
|
|
62
|
+
keeps `check_action`, keymaps, the footer and focus-chain scoping working,
|
|
63
|
+
so the variable is deliberately `CHORD_GROUPS` and not `CHORDS`. A group is
|
|
64
|
+
also not a fake binding: it has no action to run and nothing to match, so
|
|
65
|
+
the mixin's matching does not change.
|
|
66
|
+
- **Any node with bindings may declare one**, app, screen or widget, and its
|
|
67
|
+
names apply on the same terms as its bindings: while it is in the focus
|
|
68
|
+
chain, with a node nearer the focus winning. A subclass adds to its
|
|
69
|
+
parents' names rather than replacing them all, as `BINDINGS` does.
|
|
70
|
+
- **A group has no condition of its own.** It is shown only while some chord
|
|
71
|
+
under it is, so it comes and goes with its chords.
|
|
72
|
+
- **A keymap that moves a chord's prefix does not move its group's name.**
|
|
73
|
+
`set_keymap` only knows about bindings.
|
|
74
|
+
- `chord_groups(screen)` returns the names in force, keyed by steps.
|
|
75
|
+
|
|
76
|
+
## Holding a chord
|
|
77
|
+
|
|
78
|
+
`hold_chord()` is for whatever has just shown the user what can come next
|
|
79
|
+
(the `ChordViewer` calls it when it appears). The pending chord then waits
|
|
80
|
+
until it completes or is abandoned, whatever `CHORD_TIMEOUT` says, and
|
|
81
|
+
`CHORD_BACK_KEY` (`"backspace"`, or None for no back key) drops the last step
|
|
82
|
+
instead of abandoning the chord. Stepping back from the first step abandons
|
|
83
|
+
it. A chord that actually continues with that key still wins.
|
tcoda-0.8.0/docs/demo.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Demo
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
python -m tcoda # wherever tcoda is installed
|
|
5
|
+
uv run python -m tcoda # from this repository
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
This is the same idea as `python -m textual`. The demo is a small browser
|
|
9
|
+
over tcoda's own installed source files, with every widget and dialog in its
|
|
10
|
+
natural place. All of it is ordinary consumer code, so
|
|
11
|
+
[`src/tcoda/demo/`](../src/tcoda/demo/) doubles as a worked example.
|
|
12
|
+
|
|
13
|
+
| Component | Where to find it |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| [Header](widgets/Header.md) | Top row. The middle tracks the screen title, which is the directory being browsed. Click the left area for About, the right one for versions. |
|
|
16
|
+
| [MenuBar](widgets/MenuBar.md) | F9, or click where it appears. Items use `@on(MenuItem.Pressed)` (File, Dialogs) and `action=` strings (View). View has one disabled item. |
|
|
17
|
+
| [Resizable](widgets/Resizable.md) | The directory tree on the left. Drag its right border. |
|
|
18
|
+
| [DataTable](widgets/DataTable.md) | The file list. Click a header to sort. Size and Modified show rounded values but rank by the exact bytes and mtime. Hover a cell for its tooltip. |
|
|
19
|
+
| [Footer](widgets/Footer.md) | Bottom row: `F9`, `F1` and `^q` pinned, the other keys sorted, and a countdown plus a file count in the status corner. |
|
|
20
|
+
| [Countdown](widgets/Countdown.md) | Footer status corner. It spins during an auto-refresh. Turning auto-refresh off in the settings hides it. |
|
|
21
|
+
| [LogViewer](widgets/LogViewer.md) | F1. Four `tcoda.demo.*` loggers write to it, so the source filter has something to filter. |
|
|
22
|
+
| [Chords](chords.md) and [ChordViewer](widgets/ChordViewer.md) | `ctrl+k` then a pause lists every chord: `s …` sorts, `d …` opens each dialog, `r` refreshes, `t` changes theme. |
|
|
23
|
+
| [Terminal title](terminal_title.md) | The terminal's window or tab title reads "tcoda demo" while the demo runs, and goes back to what it was on exit. |
|
|
24
|
+
| [ConfirmDialog](dialogs/ConfirmDialog.md) | Dialogs → Confirm, and Delete files (the `danger=True, focus_cancel=True` variant, which deletes nothing). |
|
|
25
|
+
| [Dialog](dialogs/Dialog.md) | Dialogs → Filter (F2): `FilterDialog`, the demo's own dialog on the shared chrome. |
|
|
26
|
+
| [AboutDialog](dialogs/AboutDialog.md) | Help → About: the demo's own about-text, with `$version` filled in through `variables=`. |
|
|
27
|
+
| [SettingsDialog](dialogs/SettingsDialog.md) and [settings](settings.md) | File → Settings. The dotted `Demo.Refresh` and `Demo.Table` groups include a `choices=` setting and a `SettingAction`. Changes apply when the dialog closes. |
|
|
28
|
+
|
|
29
|
+
- **Settings persist.** They go to the per-user settings file of an app named
|
|
30
|
+
`tcoda-demo`, and only overridden values are written.
|
|
31
|
+
- **`tcoda.demo` is an app, not a library module.** Importing it declares the
|
|
32
|
+
demo's settings and points `tcoda.settings.config` at that file, so nothing
|
|
33
|
+
else should import it.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# About dialog
|
|
2
|
+
|
|
3
|
+
A small modal that reads its text from a file: the first five lines are a
|
|
4
|
+
fixed header (never scrolled — a logo or tagline), everything after that is
|
|
5
|
+
a scrollable body capped at ten lines, so the dialog never grows past
|
|
6
|
+
fifteen lines or 57 columns wide.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from tcoda.widgets import AboutMenuItem
|
|
10
|
+
|
|
11
|
+
from myapp import __version__
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MainScreen(Screen):
|
|
15
|
+
def compose(self) -> ComposeResult:
|
|
16
|
+
with MenuBar():
|
|
17
|
+
with Menu("Help", id="help"):
|
|
18
|
+
yield AboutMenuItem(
|
|
19
|
+
path="myapp/about.txt",
|
|
20
|
+
variables={"app": "myapp", "version": __version__},
|
|
21
|
+
)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **`path` is optional.** Omit it and `AboutDialog` shows a bundled
|
|
25
|
+
placeholder — useful before an app has written its own about-text, or in
|
|
26
|
+
tests. `AboutMenuItem(path=...)` passes it straight through.
|
|
27
|
+
- **`variables` fills in `$name` / `${name}` placeholders** in the
|
|
28
|
+
about-text (via `string.Template.safe_substitute`) — handy for an app
|
|
29
|
+
name or version number that only the consuming app knows. It's optional;
|
|
30
|
+
omit it and the text is shown as-is. A placeholder with no matching key
|
|
31
|
+
is left in the output untouched rather than raising.
|
|
32
|
+
- **Pushed directly, without a menu item**, with
|
|
33
|
+
`self.app.push_screen(AboutDialog(path="myapp/about.txt"))`.
|
|
34
|
+
- **`escape` dismisses it** — inherited from [`Dialog`](Dialog.md), like every
|
|
35
|
+
other dialog here. There is no button row; this dialog only ever dismisses,
|
|
36
|
+
nothing to confirm.
|