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.
Files changed (86) hide show
  1. tcoda-0.8.0/.editorconfig +14 -0
  2. tcoda-0.8.0/.gitattributes +5 -0
  3. tcoda-0.8.0/.github/workflows/build.yml +151 -0
  4. tcoda-0.8.0/.gitignore +20 -0
  5. tcoda-0.8.0/.markdownlint-cli2.jsonc +8 -0
  6. tcoda-0.8.0/.python-version +1 -0
  7. tcoda-0.8.0/CLAUDE.md +21 -0
  8. tcoda-0.8.0/CONTRIBUTING.md +3 -0
  9. tcoda-0.8.0/LICENSE +5 -0
  10. tcoda-0.8.0/LICENSES/0BSD.txt +5 -0
  11. tcoda-0.8.0/PKG-INFO +72 -0
  12. tcoda-0.8.0/README.md +60 -0
  13. tcoda-0.8.0/REUSE.toml +14 -0
  14. tcoda-0.8.0/docs/DESIGN.md +112 -0
  15. tcoda-0.8.0/docs/chords.md +83 -0
  16. tcoda-0.8.0/docs/demo.md +33 -0
  17. tcoda-0.8.0/docs/dialogs/AboutDialog.md +36 -0
  18. tcoda-0.8.0/docs/dialogs/ConfirmDialog.md +33 -0
  19. tcoda-0.8.0/docs/dialogs/Dialog.md +41 -0
  20. tcoda-0.8.0/docs/dialogs/SettingsDialog.md +33 -0
  21. tcoda-0.8.0/docs/settings.md +49 -0
  22. tcoda-0.8.0/docs/terminal_title.md +36 -0
  23. tcoda-0.8.0/docs/widgets/ChordViewer.md +78 -0
  24. tcoda-0.8.0/docs/widgets/Countdown.md +27 -0
  25. tcoda-0.8.0/docs/widgets/DataTable.md +93 -0
  26. tcoda-0.8.0/docs/widgets/Footer.md +63 -0
  27. tcoda-0.8.0/docs/widgets/Header.md +23 -0
  28. tcoda-0.8.0/docs/widgets/LogViewer.md +70 -0
  29. tcoda-0.8.0/docs/widgets/MenuBar.md +62 -0
  30. tcoda-0.8.0/docs/widgets/Resizable.md +40 -0
  31. tcoda-0.8.0/pyproject.toml +38 -0
  32. tcoda-0.8.0/src/tcoda/__init__.py +15 -0
  33. tcoda-0.8.0/src/tcoda/__main__.py +10 -0
  34. tcoda-0.8.0/src/tcoda/_text.py +43 -0
  35. tcoda-0.8.0/src/tcoda/chords.py +249 -0
  36. tcoda-0.8.0/src/tcoda/demo/__init__.py +15 -0
  37. tcoda-0.8.0/src/tcoda/demo/_app.py +357 -0
  38. tcoda-0.8.0/src/tcoda/demo/_browser.py +176 -0
  39. tcoda-0.8.0/src/tcoda/demo/_filter.py +64 -0
  40. tcoda-0.8.0/src/tcoda/demo/_settings.py +57 -0
  41. tcoda-0.8.0/src/tcoda/demo/about.txt +21 -0
  42. tcoda-0.8.0/src/tcoda/dialogs/__init__.py +13 -0
  43. tcoda-0.8.0/src/tcoda/dialogs/_about.py +85 -0
  44. tcoda-0.8.0/src/tcoda/dialogs/_confirm.py +84 -0
  45. tcoda-0.8.0/src/tcoda/dialogs/_dialog.py +62 -0
  46. tcoda-0.8.0/src/tcoda/dialogs/_settings.py +353 -0
  47. tcoda-0.8.0/src/tcoda/dialogs/about_default.txt +9 -0
  48. tcoda-0.8.0/src/tcoda/log_sink.py +81 -0
  49. tcoda-0.8.0/src/tcoda/paths.py +51 -0
  50. tcoda-0.8.0/src/tcoda/py.typed +0 -0
  51. tcoda-0.8.0/src/tcoda/settings.py +285 -0
  52. tcoda-0.8.0/src/tcoda/terminal_title.py +88 -0
  53. tcoda-0.8.0/src/tcoda/version.py +24 -0
  54. tcoda-0.8.0/src/tcoda/widgets/__init__.py +65 -0
  55. tcoda-0.8.0/src/tcoda/widgets/_about_item.py +46 -0
  56. tcoda-0.8.0/src/tcoda/widgets/_chord_viewer.py +438 -0
  57. tcoda-0.8.0/src/tcoda/widgets/_countdown.py +99 -0
  58. tcoda-0.8.0/src/tcoda/widgets/_datatable.py +418 -0
  59. tcoda-0.8.0/src/tcoda/widgets/_dropdown.py +250 -0
  60. tcoda-0.8.0/src/tcoda/widgets/_footer.py +562 -0
  61. tcoda-0.8.0/src/tcoda/widgets/_header.py +169 -0
  62. tcoda-0.8.0/src/tcoda/widgets/_layers.py +32 -0
  63. tcoda-0.8.0/src/tcoda/widgets/_log_viewer.py +370 -0
  64. tcoda-0.8.0/src/tcoda/widgets/_menubar.py +236 -0
  65. tcoda-0.8.0/src/tcoda/widgets/_menuitem.py +244 -0
  66. tcoda-0.8.0/src/tcoda/widgets/_multi_select.py +303 -0
  67. tcoda-0.8.0/src/tcoda/widgets/_resizable.py +118 -0
  68. tcoda-0.8.0/src/tcoda/widgets/_settings_item.py +37 -0
  69. tcoda-0.8.0/tests/conftest.py +39 -0
  70. tcoda-0.8.0/tests/test_about.py +163 -0
  71. tcoda-0.8.0/tests/test_chord_viewer.py +236 -0
  72. tcoda-0.8.0/tests/test_chords.py +327 -0
  73. tcoda-0.8.0/tests/test_countdown.py +125 -0
  74. tcoda-0.8.0/tests/test_datatable.py +598 -0
  75. tcoda-0.8.0/tests/test_demo.py +194 -0
  76. tcoda-0.8.0/tests/test_footer.py +502 -0
  77. tcoda-0.8.0/tests/test_header.py +165 -0
  78. tcoda-0.8.0/tests/test_log_sink.py +162 -0
  79. tcoda-0.8.0/tests/test_log_viewer.py +632 -0
  80. tcoda-0.8.0/tests/test_menubar.py +643 -0
  81. tcoda-0.8.0/tests/test_multi_select.py +248 -0
  82. tcoda-0.8.0/tests/test_resizable.py +249 -0
  83. tcoda-0.8.0/tests/test_settings.py +277 -0
  84. tcoda-0.8.0/tests/test_settings_dialog.py +355 -0
  85. tcoda-0.8.0/tests/test_terminal_title.py +87 -0
  86. 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,5 @@
1
+ # SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
2
+ #
3
+ # SPDX-License-Identifier: 0BSD
4
+
5
+ * text=auto eol=lf
@@ -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,8 @@
1
+ // SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
2
+ //
3
+ // SPDX-License-Identifier: 0BSD
4
+ {
5
+ "ignores": [
6
+ "LICENSE"
7
+ ]
8
+ }
@@ -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.
@@ -0,0 +1,3 @@
1
+ # Contribution guidelines
2
+
3
+ I'm open to receiving bug reports, but I'm not really interested in anything else at this point. You can still bring forward whatever you want to say, just manage your expectations accordingly.
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.
@@ -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.